diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e2f0e55..da8a92b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,6 +18,8 @@ env: REGISTRY: ghcr.io IMAGE_NAME: cartsnitch/cartsnitch AUTH_IMAGE_NAME: cartsnitch/auth + RECEIPTWITNESS_IMAGE_NAME: cartsnitch/receiptwitness + API_IMAGE_NAME: cartsnitch/api jobs: lint: @@ -46,9 +48,59 @@ jobs: - name: Run tests run: npx vitest run + audit: + runs-on: runners-cartsnitch + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + - run: npm ci + - name: Check for vulnerabilities + run: npm audit --audit-level=high + + e2e: + runs-on: runners-cartsnitch + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npx playwright test + + lighthouse: + runs-on: runners-cartsnitch + needs: [test] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "20" + cache: npm + - run: npm ci + - run: npm run build + - name: Install Chromium for Lighthouse + run: | + npm install -g playwright + npx playwright install --with-deps chromium + - name: Start preview server + run: | + npm run preview & + npx wait-on http://localhost:4173/ --timeout 30000 + - name: Run Lighthouse CI + run: | + CHROME_PATH=$(find /home/runner/.cache/ms-playwright -name chrome -type f 2>/dev/null | head -1) + npm install -g @lhci/cli + CHROME_PATH="$CHROME_PATH" lhci autorun --chrome-flags="--headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage" + build-and-push: runs-on: runners-cartsnitch - needs: [lint, test] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [lint, test, e2e] outputs: calver_tag: ${{ steps.calver.outputs.version }} steps: @@ -73,6 +125,13 @@ jobs: echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "CalVer tag: $VERSION" + - name: Log in to Docker Hub + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Log in to GHCR if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: docker/login-action@v3 @@ -110,7 +169,8 @@ jobs: build-and-push-auth: runs-on: runners-cartsnitch - needs: [lint, test] + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [lint, test, e2e] outputs: calver_tag: ${{ steps.calver.outputs.version }} steps: @@ -134,6 +194,13 @@ jobs: fi echo "version=$VERSION" >> "$GITHUB_OUTPUT" + - name: Log in to Docker Hub + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Log in to GHCR if: github.event_name == 'push' && github.ref == 'refs/heads/main' uses: docker/login-action@v3 @@ -161,10 +228,122 @@ jobs: tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} + build-and-push-receiptwitness: + runs-on: runners-cartsnitch + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + 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 Docker Hub + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - 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 + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.RECEIPTWITNESS_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 receiptwitness image + uses: docker/build-push-action@v6 + with: + context: . + file: ./receiptwitness/Dockerfile + push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + build-and-push-api: + runs-on: runners-cartsnitch + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + 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 Docker Hub + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - 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 (API) + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ env.REGISTRY }}/${{ env.API_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 API Docker image + uses: docker/build-push-action@v6 + with: + context: . + file: ./api/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] - if: github.event_name == 'push' && github.ref == 'refs/heads/main' + needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api] + if: always() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/main' steps: - name: Generate GitHub App token id: app-token @@ -189,17 +368,35 @@ jobs: - name: Install kustomize uses: imranismail/setup-kustomize@v2 - - name: Update dev overlay image tag + - name: Update frontend image tag + if: needs.build-and-push.result == 'success' run: | cd infra/apps/overlays/dev kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }} + + - name: Update auth image tag + if: needs.build-and-push-auth.result == 'success' + run: | + cd infra/apps/overlays/dev kustomize edit set image ghcr.io/cartsnitch/auth:${{ needs.build-and-push-auth.outputs.calver_tag }} + - name: Update receiptwitness image tag + if: needs.build-and-push-receiptwitness.result == 'success' + run: | + cd infra/apps/overlays/dev + kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ needs.build-and-push-receiptwitness.outputs.calver_tag }} + + - name: Update api image tag + if: needs.build-and-push-api.result == 'success' + run: | + cd infra/apps/overlays/dev + kustomize edit set image ghcr.io/cartsnitch/api:${{ needs.build-and-push-api.outputs.calver_tag }} + - name: Commit and push to infra run: | cd infra 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 commit -m "ci(dev): update cartsnitch, auth, receiptwitness, and api images" git push origin main diff --git a/api/alembic/versions/003_make_users_hashed_password_nullable.py b/api/alembic/versions/003_make_users_hashed_password_nullable.py new file mode 100644 index 0000000..8aec2bc --- /dev/null +++ b/api/alembic/versions/003_make_users_hashed_password_nullable.py @@ -0,0 +1,26 @@ +"""Make users.hashed_password nullable. + +Better-Auth inserts users without hashed_password (passwords live in the +accounts table). This column is now purely optional. + +Revision ID: 003_make_users_hashed_password_nullable +Revises: 002_better_auth_tables +Create Date: 2026-03-30 +""" + +import sqlalchemy as sa + +from alembic import op + +revision = "003_make_users_hashed_password_nullable" +down_revision = "002_better_auth_tables" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=True) + + +def downgrade() -> None: + op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=False) diff --git a/api/alembic/versions/004_fix_user_id_text.py b/api/alembic/versions/004_fix_user_id_text.py new file mode 100644 index 0000000..a52bf9d --- /dev/null +++ b/api/alembic/versions/004_fix_user_id_text.py @@ -0,0 +1,122 @@ +"""Fix users.id UUID->text type mismatch for Better-Auth compatibility. + +Better-Auth generates nanoid-style text IDs (e.g. pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI), +but the users table was using PostgreSQL uuid type. When Better-Auth tries to INSERT +a new user, Postgres throws: + ERROR: invalid input syntax for type uuid: "pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI" + +The sessions, accounts, and verifications tables already use text IDs — only users, +user_store_accounts.user_id, and purchases.user_id needed fixing. + +Revision ID: 004_fix_user_id_text +Revises: 003_make_users_hashed_password_nullable +Create Date: 2026-03-31 +""" + +import sqlalchemy as sa +from sqlalchemy import text + +from alembic import op + +revision = "004_fix_user_id_text" +down_revision = "003_make_users_hashed_password_nullable" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Step 1: Drop existing FK constraints + op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey")) + op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey")) + + # Step 2: Alter users.id from uuid to text + op.alter_column( + "users", + "id", + type_=sa.Text(), + existing_type=sa.UUID(), + postgresql_using="id::text", + ) + + # Step 3: Alter user_store_accounts.user_id from uuid to text + op.alter_column( + "user_store_accounts", + "user_id", + type_=sa.Text(), + existing_type=sa.UUID(), + postgresql_using="user_id::text", + ) + + # Step 4: Alter purchases.user_id from uuid to text + op.alter_column( + "purchases", + "user_id", + type_=sa.Text(), + existing_type=sa.UUID(), + postgresql_using="user_id::text", + ) + + # Step 5: Re-add FK constraints + op.execute( + text( + "ALTER TABLE user_store_accounts " + "ADD CONSTRAINT user_store_accounts_user_id_fkey " + "FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE" + ) + ) + op.execute( + text( + "ALTER TABLE purchases " + "ADD CONSTRAINT purchases_user_id_fkey " + "FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE" + ) + ) + + +def downgrade() -> None: + # Drop FK constraints + op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey")) + op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey")) + + # Revert users.id from text to uuid + op.alter_column( + "users", + "id", + type_=sa.UUID(), + existing_type=sa.Text(), + postgresql_using="id::uuid", + ) + + # Revert user_store_accounts.user_id from text to uuid + op.alter_column( + "user_store_accounts", + "user_id", + type_=sa.UUID(), + existing_type=sa.Text(), + postgresql_using="user_id::uuid", + ) + + # Revert purchases.user_id from text to uuid + op.alter_column( + "purchases", + "user_id", + type_=sa.UUID(), + existing_type=sa.Text(), + postgresql_using="user_id::uuid", + ) + + # Re-add FK constraints (PostgreSQL will auto-name them) + op.execute( + text( + "ALTER TABLE user_store_accounts " + "ADD CONSTRAINT user_store_accounts_user_id_fkey " + "FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE" + ) + ) + op.execute( + text( + "ALTER TABLE purchases " + "ADD CONSTRAINT purchases_user_id_fkey " + "FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE" + ) + ) diff --git a/api/alembic/versions/005_add_email_inbound_token.py b/api/alembic/versions/005_add_email_inbound_token.py new file mode 100644 index 0000000..4fb7c2c --- /dev/null +++ b/api/alembic/versions/005_add_email_inbound_token.py @@ -0,0 +1,49 @@ +"""Add email_inbound_token to users. + +Revision ID: 005_add_email_inbound_token +Revises: 004_fix_user_id_text +Create Date: 2026-04-02 +""" + +import secrets + +import sqlalchemy as sa + +from alembic import op + +revision = "005_add_email_inbound_token" +down_revision = "004_fix_user_id_text" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + # Add column nullable first so existing rows can be backfilled + op.add_column( + "users", + sa.Column("email_inbound_token", sa.String(22), nullable=True), + ) + + # Backfill existing users with unique tokens + connection = op.get_bind() + result = connection.execute(sa.text("SELECT id FROM users WHERE email_inbound_token IS NULL")) + for (user_id,) in result: + token = secrets.token_urlsafe(16) + connection.execute( + sa.text("UPDATE users SET email_inbound_token = :token WHERE id = :id"), + {"token": token, "id": user_id}, + ) + + # Now enforce non-null and unique + op.alter_column("users", "email_inbound_token", nullable=False) + op.create_index( + "ix_users_email_inbound_token", + "users", + ["email_inbound_token"], + unique=True, + ) + + +def downgrade() -> None: + op.drop_index("ix_users_email_inbound_token", table_name="users") + op.drop_column("users", "email_inbound_token") diff --git a/api/src/cartsnitch_api/auth/dependencies.py b/api/src/cartsnitch_api/auth/dependencies.py index 93f8eb8..6fe1db4 100644 --- a/api/src/cartsnitch_api/auth/dependencies.py +++ b/api/src/cartsnitch_api/auth/dependencies.py @@ -5,8 +5,6 @@ Sessions are verified by querying the shared sessions table directly. """ from datetime import UTC, datetime -from uuid import UUID - from fastapi import Cookie, Depends, Header, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy import text @@ -23,10 +21,10 @@ bearer_scheme = HTTPBearer(auto_error=False) SESSION_COOKIE_NAME = "better-auth.session_token" -async def _validate_session_token(token: str, db: AsyncSession) -> UUID: +async def _validate_session_token(token: str, db: AsyncSession) -> str: """Validate a Better-Auth session token against the sessions table. - Returns the user_id (as UUID) if the session is valid and not expired. + Returns the user_id (as str) if the session is valid and not expired. """ result = await db.execute( text("SELECT user_id, expires_at FROM sessions WHERE token = :token"), @@ -51,14 +49,14 @@ async def _validate_session_token(token: str, db: AsyncSession) -> UUID: detail="Session expired", ) - return UUID(str(user_id)) + return str(user_id) async def get_current_user( request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme), db: AsyncSession = Depends(get_db), -) -> UUID: +) -> str: """Extract and validate the session token from cookie or Authorization header. Checks in order: diff --git a/api/src/cartsnitch_api/auth/routes.py b/api/src/cartsnitch_api/auth/routes.py index 81cae2f..1400d7a 100644 --- a/api/src/cartsnitch_api/auth/routes.py +++ b/api/src/cartsnitch_api/auth/routes.py @@ -5,13 +5,14 @@ the Better-Auth service (auth/). This router provides user profile endpoints that query our own user data from the shared database. """ -from uuid import UUID - from fastapi import APIRouter, Depends, HTTPException, status +from pydantic import BaseModel +from sqlalchemy import select 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.models import User from cartsnitch_api.schemas import ( UpdateUserRequest, UserResponse, @@ -21,9 +22,14 @@ from cartsnitch_api.services.auth import AuthService router = APIRouter(prefix="/auth", tags=["auth"]) +class EmailInAddressResponse(BaseModel): + email_address: str + instructions: str + + @router.get("/me", response_model=UserResponse) async def get_me( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = AuthService(db) @@ -38,7 +44,7 @@ async def get_me( @router.patch("/me", response_model=UserResponse) async def update_me( body: UpdateUserRequest, - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = AuthService(db) @@ -54,7 +60,7 @@ async def update_me( @router.delete("/me", status_code=status.HTTP_204_NO_CONTENT) async def delete_me( - user_id: UUID = Depends(get_current_user), + user_id: str = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): svc = AuthService(db) @@ -64,3 +70,23 @@ async def delete_me( raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail="User not found" ) from None + + +@router.get("/me/email-in-address", response_model=EmailInAddressResponse) +async def get_email_in_address( + user_id: str = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + result = await db.execute(select(User.email_inbound_token).where(User.id == user_id)) + token = result.scalar_one_or_none() + if not token: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="Email inbound token not found" + ) from None + return EmailInAddressResponse( + email_address=f"receipts+{token}@receipts.cartsnitch.com", + instructions=( + "Forward your digital receipt emails to this address. " + "We currently support Meijer, Kroger, and Target receipt emails." + ), + ) diff --git a/api/src/cartsnitch_api/main.py b/api/src/cartsnitch_api/main.py index 1cd54ef..4df6f09 100644 --- a/api/src/cartsnitch_api/main.py +++ b/api/src/cartsnitch_api/main.py @@ -2,7 +2,7 @@ from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import APIRouter, FastAPI from cartsnitch_api.auth.routes import router as auth_router from cartsnitch_api.middleware.cors import add_cors_middleware @@ -46,15 +46,19 @@ def create_app() -> FastAPI: # Routers app.include_router(health_router) app.include_router(auth_router) - app.include_router(stores_router) - app.include_router(purchases_router) - app.include_router(products_router) - app.include_router(prices_router) - app.include_router(coupons_router) - app.include_router(shopping_router) - app.include_router(alerts_router) - app.include_router(scraping_router) - app.include_router(public_router) + + # Data endpoints mounted under /api/v1 + v1_router = APIRouter(prefix="/api/v1") + v1_router.include_router(stores_router) + v1_router.include_router(purchases_router) + v1_router.include_router(products_router) + v1_router.include_router(prices_router) + v1_router.include_router(coupons_router) + v1_router.include_router(shopping_router) + v1_router.include_router(alerts_router) + v1_router.include_router(scraping_router) + v1_router.include_router(public_router) + app.include_router(v1_router) return app diff --git a/api/src/cartsnitch_api/models/purchase.py b/api/src/cartsnitch_api/models/purchase.py index f57fde9..97f577d 100644 --- a/api/src/cartsnitch_api/models/purchase.py +++ b/api/src/cartsnitch_api/models/purchase.py @@ -32,8 +32,8 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base): __tablename__ = "purchases" - user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False) - store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) + store_id: Mapped[str] = mapped_column(ForeignKey("stores.id"), nullable=False) store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id")) receipt_id: Mapped[str] = mapped_column(String(200), nullable=False) purchase_date: Mapped[date] = mapped_column(Date, nullable=False) diff --git a/api/src/cartsnitch_api/models/user.py b/api/src/cartsnitch_api/models/user.py index 56482b0..89390a3 100644 --- a/api/src/cartsnitch_api/models/user.py +++ b/api/src/cartsnitch_api/models/user.py @@ -1,10 +1,10 @@ """User and UserStoreAccount models.""" -import uuid +import secrets from datetime import datetime from typing import TYPE_CHECKING -from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint +from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from cartsnitch_api.constants import AccountStatus @@ -16,14 +16,21 @@ if TYPE_CHECKING: from cartsnitch_api.models.store import Store -class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): +class User(TimestampMixin, Base): """Application user.""" __tablename__ = "users" + id: Mapped[str] = mapped_column(Text, primary_key=True) email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) display_name: Mapped[str | None] = mapped_column(String(100)) + email_inbound_token: Mapped[str] = mapped_column( + String(22), + nullable=False, + unique=True, + default=lambda: secrets.token_urlsafe(16), + ) # Relationships store_accounts: Mapped[list["UserStoreAccount"]] = relationship(back_populates="user") @@ -36,8 +43,8 @@ class UserStoreAccount(UUIDPrimaryKeyMixin, TimestampMixin, Base): __tablename__ = "user_store_accounts" __table_args__ = (UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),) - user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False) - store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False) + user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) + store_id: Mapped[str] = mapped_column(ForeignKey("stores.id"), nullable=False) session_data: Mapped[dict | None] = mapped_column(EncryptedJSON) session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) diff --git a/api/src/cartsnitch_api/schemas.py b/api/src/cartsnitch_api/schemas.py index 1ba727e..68e1dbe 100644 --- a/api/src/cartsnitch_api/schemas.py +++ b/api/src/cartsnitch_api/schemas.py @@ -1,7 +1,6 @@ """Pydantic v2 request/response schemas for all API endpoints.""" from datetime import datetime -from uuid import UUID from pydantic import BaseModel, EmailStr, Field @@ -16,7 +15,7 @@ class UpdateUserRequest(BaseModel): class UserResponse(BaseModel): - id: UUID + id: str email: str display_name: str created_at: datetime diff --git a/api/src/cartsnitch_api/services/auth.py b/api/src/cartsnitch_api/services/auth.py index 91724af..4894150 100644 --- a/api/src/cartsnitch_api/services/auth.py +++ b/api/src/cartsnitch_api/services/auth.py @@ -5,8 +5,6 @@ handled by the Better-Auth service (auth/). This service provides user lookup and profile update operations for the API gateway. """ -from uuid import UUID - from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -15,7 +13,7 @@ class AuthService: def __init__(self, db: AsyncSession) -> None: self.db = db - async def get_user(self, user_id: UUID) -> dict: + async def get_user(self, user_id: str) -> dict: from cartsnitch_api.models import User result = await self.db.execute(select(User).where(User.id == user_id)) @@ -30,7 +28,7 @@ class AuthService: "created_at": user.created_at, } - async def update_user(self, user_id: UUID, **fields) -> dict: + async def update_user(self, user_id: str, **fields) -> dict: from cartsnitch_api.models import User result = await self.db.execute(select(User).where(User.id == user_id)) @@ -58,7 +56,7 @@ class AuthService: "created_at": user.created_at, } - async def delete_user(self, user_id: UUID) -> None: + async def delete_user(self, user_id: str) -> None: from cartsnitch_api.models import User result = await self.db.execute(select(User).where(User.id == user_id)) diff --git a/api/tests/test_e2e/test_email_in_address.py b/api/tests/test_e2e/test_email_in_address.py new file mode 100644 index 0000000..7886572 --- /dev/null +++ b/api/tests/test_e2e/test_email_in_address.py @@ -0,0 +1,61 @@ +"""Tests for GET /auth/me/email-in-address endpoint.""" + +import pytest +from httpx import AsyncClient + + +@pytest.mark.asyncio +async def test_get_email_in_address_authenticated(client: AsyncClient, auth_headers: dict): + """Authenticated user gets their email-in address.""" + response = await client.get( + "/auth/me/email-in-address", + headers=auth_headers, + ) + + assert response.status_code == 200 + data = response.json() + assert "email_address" in data + assert data["email_address"].startswith("receipts+") + assert data["email_address"].endswith("@receipts.cartsnitch.com") + assert len(data["email_address"]) > len("receipts+@receipts.cartsnitch.com") + assert "instructions" in data + assert "Meijer" in data["instructions"] + assert "Kroger" in data["instructions"] + assert "Target" in data["instructions"] + + +@pytest.mark.asyncio +async def test_get_email_in_address_unauthenticated(client: AsyncClient): + """Unauthenticated request returns 401.""" + response = await client.get("/auth/me/email-in-address") + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_email_in_address_invalid_token(client: AsyncClient): + """Invalid JWT token returns 401.""" + response = await client.get( + "/auth/me/email-in-address", + headers={"Authorization": "Bearer invalid-token-xyz"}, + ) + assert response.status_code == 401 + + +@pytest.mark.asyncio +async def test_email_address_format(client: AsyncClient, auth_headers: dict): + """Email address format is receipts+{22-char-urlsafe-token}@receipts.cartsnitch.com.""" + response = await client.get( + "/auth/me/email-in-address", + headers=auth_headers, + ) + + assert response.status_code == 200 + data = response.json() + email = data["email_address"] + # Format: receipts+<22-char-urlsafe-token>@receipts.cartsnitch.com + assert email.startswith("receipts+") + assert email.endswith("@receipts.cartsnitch.com") + # token_urlsafe(16) produces 22 chars + middle = email[len("receipts+") : -len("@receipts.cartsnitch.com")] + assert len(middle) == 22 + assert "@" not in middle diff --git a/api/tests/test_openapi.py b/api/tests/test_openapi.py index 97eef19..7379f84 100644 --- a/api/tests/test_openapi.py +++ b/api/tests/test_openapi.py @@ -6,13 +6,14 @@ from httpx import ASGITransport, AsyncClient from cartsnitch_api.main import app EXPECTED_ROUTES = [ - # Auth (6) + # Auth (7) ("post", "/auth/register"), ("post", "/auth/login"), ("post", "/auth/refresh"), ("get", "/auth/me"), ("patch", "/auth/me"), ("delete", "/auth/me"), + ("get", "/auth/me/email-in-address"), # Stores (4) ("get", "/stores"), ("get", "/me/stores"), @@ -89,4 +90,4 @@ async def test_route_count(): if method in ("get", "post", "put", "delete", "patch"): count += 1 - assert count == 33, f"Expected 33 routes, found {count}" + assert count == 34, f"Expected 34 routes, found {count}" diff --git a/auth/src/auth.ts b/auth/src/auth.ts index 1215cdb..eae43b8 100644 --- a/auth/src/auth.ts +++ b/auth/src/auth.ts @@ -36,6 +36,15 @@ export const auth = betterAuth({ }, session: { + modelName: "sessions", + fields: { + userId: "user_id", + expiresAt: "expires_at", + ipAddress: "ip_address", + userAgent: "user_agent", + createdAt: "created_at", + updatedAt: "updated_at", + }, expiresIn: 60 * 60 * 24 * 7, // 7 days updateAge: 60 * 60 * 24, // refresh after 1 day cookieCache: { diff --git a/common/src/cartsnitch_common/models/user.py b/common/src/cartsnitch_common/models/user.py index 5e35e5a..4382a08 100644 --- a/common/src/cartsnitch_common/models/user.py +++ b/common/src/cartsnitch_common/models/user.py @@ -21,7 +21,7 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base): __tablename__ = "users" email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) - hashed_password: Mapped[str] = mapped_column(String(255), nullable=False) + hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True) 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) diff --git a/docs/uat-runbook.md b/docs/uat-runbook.md new file mode 100644 index 0000000..787c5b1 --- /dev/null +++ b/docs/uat-runbook.md @@ -0,0 +1,151 @@ +# 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 | + diff --git a/e2e/fixtures.ts b/e2e/fixtures.ts new file mode 100644 index 0000000..07e12e6 --- /dev/null +++ b/e2e/fixtures.ts @@ -0,0 +1,12 @@ +import { test as base, expect } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; + +export const test = base.extend<{ axeCheck: void }>({ + axeCheck: [async ({ page }, use) => { + await use(); + const results = await new AxeBuilder({ page }).analyze(); + expect(results.violations).toEqual([]); + }, { auto: true }], +}); + +export { expect } from "@playwright/test"; diff --git a/e2e/journeys/j1-registration-login.spec.ts b/e2e/journeys/j1-registration-login.spec.ts new file mode 100644 index 0000000..ec116ab --- /dev/null +++ b/e2e/journeys/j1-registration-login.spec.ts @@ -0,0 +1,56 @@ +import { test, expect } from '@playwright/test'; + +const uniqueEmail = () => `betty+e2e-${Date.now()}@cartsnitch.test`; + +test.describe('J1: Registration and Login', () => { + test('can register a new account and lands on dashboard', async ({ page }) => { + await page.goto('/register'); + await page.fill('[placeholder="Full Name"]', 'Betty Tester'); + await page.fill('[placeholder="Email"]', uniqueEmail()); + await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!'); + await page.click('button[type="submit"]'); + + // With VITE_MOCK_AUTH=true the app navigates to "/" on success + await expect(page).toHaveURL('http://localhost:5173/'); + await expect(page.getByRole('heading', { name: /cart/i })).toBeVisible(); + }); + + test('shows validation error when registration fields are empty', async ({ page }) => { + await page.goto('/register'); + await page.click('button[type="submit"]'); + + await expect(page.locator('.bg-red-50')).toContainText('Please fill in all fields'); + }); + + test('can navigate from register to login', async ({ page }) => { + await page.goto('/register'); + await page.getByRole('link', { name: /sign in/i }).click(); + + await expect(page).toHaveURL(/\/login/); + await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible(); + }); + + test('can sign in with credentials and land on dashboard', async ({ page }) => { + // Register first so we have a real account + const email = uniqueEmail(); + await page.goto('/register'); + await page.fill('[placeholder="Full Name"]', 'Login Betty'); + await page.fill('[placeholder="Email"]', email); + await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!'); + await page.click('button[type="submit"]'); + await expect(page).toHaveURL('http://localhost:5173/'); + + // Sign out by clearing the mock session (reload with no session) + await page.goto('/'); + await page.reload(); + + // Now sign in + await page.goto('/login'); + await page.fill('[placeholder="Email"]', email); + await page.fill('[placeholder="Password"]', 'TestPass123!'); + await page.click('button[type="submit"]'); + + await expect(page).toHaveURL('http://localhost:5173/'); + }); + +}); diff --git a/e2e/journeys/j8-unauth-access.spec.ts b/e2e/journeys/j8-unauth-access.spec.ts new file mode 100644 index 0000000..9ed40da --- /dev/null +++ b/e2e/journeys/j8-unauth-access.spec.ts @@ -0,0 +1,49 @@ +import { test, expect } from '@playwright/test'; + +test.describe('J8: Unauthenticated Access', () => { + test('redirects /dashboard (/) to /login when not authenticated', async ({ page }) => { + // No session cookie — start fresh + await page.context().clearCookies(); + await page.goto('/'); + + await expect(page).toHaveURL(/\/login/); + await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible(); + }); + + test('redirects /purchases to /login when not authenticated', async ({ page }) => { + await page.context().clearCookies(); + await page.goto('/purchases'); + + await expect(page).toHaveURL(/\/login/); + await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible(); + }); + + test('redirects /products to /login when not authenticated', async ({ page }) => { + await page.context().clearCookies(); + await page.goto('/products'); + + await expect(page).toHaveURL(/\/login/); + await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible(); + }); + + test('redirects /coupons to /login when not authenticated', async ({ page }) => { + await page.context().clearCookies(); + await page.goto('/coupons'); + + await expect(page).toHaveURL(/\/login/); + await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible(); + }); + + test('shows loading spinner while auth session is pending', async ({ page }) => { + // Intercept but don't respond — session stays pending + await page.context().clearCookies(); + await page.request.fetch('/api/auth/session', { + method: 'GET', + }); + + // Just navigate to a protected route — ProtectedRoute will show spinner while session is pending + await page.goto('/purchases'); + // Spinner is visible briefly; once resolved, should redirect to login + await expect(page).toHaveURL(/\/login/, { timeout: 10_000 }); + }); +}); diff --git a/e2e/smoke.spec.ts b/e2e/smoke.spec.ts new file mode 100644 index 0000000..2819d15 --- /dev/null +++ b/e2e/smoke.spec.ts @@ -0,0 +1,8 @@ +import { test, expect } from './fixtures'; + +test('app loads', async ({ page }) => { + await page.goto('/'); + // Unauthenticated users are redirected to /login + await expect(page).toHaveURL(/\/login/); + await expect(page.getByRole('heading', { name: /CartSnitch/i })).toBeVisible(); +}); diff --git a/lighthouserc.json b/lighthouserc.json new file mode 100644 index 0000000..f85a377 --- /dev/null +++ b/lighthouserc.json @@ -0,0 +1,24 @@ +{ + "ci": { + "collect": { + "staticDistDir": "./dist", + "url": ["http://localhost:4173/"], + "numberOfRuns": 1, + "settings": { + "chromeFlags": ["--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"], + "skipAudits": ["bf-cache"], + "disableFullPageScreenshot": true + } + }, + "assert": { + "assertions": { + "categories:performance": ["warn", { "minScore": 0.7 }], + "categories:accessibility": ["error", { "minScore": 0.9 }], + "categories:best-practices": ["warn", { "minScore": 0.8 }] + } + }, + "upload": { + "target": "temporary-public-storage" + } + } +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 8200b00..04163c5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "dependencies": { "@tanstack/react-query": "^5.0.0", "better-auth": "^1.2.0", + "picomatch": "4.0.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.0.0", @@ -17,19 +18,23 @@ "zustand": "^5.0.0" }, "devDependencies": { + "@axe-core/playwright": "^4.10.0", "@eslint/js": "^9.39.4", + "@playwright/test": "^1.58.2", "@tailwindcss/vite": "^4.0.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.2", "@types/node": "^24.12.0", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", - "@vitejs/plugin-react": "^4.5.2", + "@vitejs/plugin-react": "^4.7.0", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", "jsdom": "^25.0.1", + "msw": "^2.12.14", + "playwright": "^1.58.2", "tailwindcss": "^4.0.0", "typescript": "^5.7.3", "typescript-eslint": "^8.56.1", @@ -66,6 +71,19 @@ "devOptional": true, "license": "ISC" }, + "node_modules/@axe-core/playwright": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/@axe-core/playwright/-/playwright-4.11.1.tgz", + "integrity": "sha512-mKEfoUIB1MkVTht0BGZFXtSAEKXMJoDkyV5YZ9jbBmZCcWDz71tegNsdTkIN8zc/yMi5Gm2kx7Z5YQ9PfWNAWw==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "axe-core": "~4.11.1" + }, + "peerDependencies": { + "playwright-core": ">= 1.0.0" + } + }, "node_modules/@babel/code-frame": { "version": "7.29.0", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", @@ -2474,6 +2492,94 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz", + "integrity": "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "resolved": "https://registry.npmjs.org/@inquirer/confirm/-/confirm-5.1.21.tgz", + "integrity": "sha512-KR8edRkIsUayMXV+o3Gv+q4jlhENF9nMYUZs9PA2HzrXeHI8M5uDag70U7RJn9yyiMZSbtF5/UexBtAVtZGSbQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/core/-/core-10.3.2.tgz", + "integrity": "sha512-43RTuEbfP8MbKzedNqBrlhhNKVwoK//vUFNW3Q3vZ88BLcrs4kYpGg+B2mm5p2K/HfygoCxuKwJJiv8PbGmE0A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "resolved": "https://registry.npmjs.org/@inquirer/figures/-/figures-1.0.15.tgz", + "integrity": "sha512-t2IEY+unGHOzAaVM5Xx6DEWKeXlDDcNPeDyUpsRc6CUhBfU3VQOEl+Vssh7VNp1dR8MdUJBWhuObjXCsVpjN5g==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "resolved": "https://registry.npmjs.org/@inquirer/type/-/type-3.0.10.tgz", + "integrity": "sha512-BvziSRxfz5Ov8ch0z/n3oijRSEcEsHnhggm4xFZe93DHcUCTlutlq9Ox4SVENAfcRD22UQq7T/atg9Wr3k09eA==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, "node_modules/@isaacs/cliui": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-9.0.0.tgz", @@ -2545,6 +2651,24 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@mswjs/interceptors": { + "version": "0.41.3", + "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.41.3.tgz", + "integrity": "sha512-cXu86tF4VQVfwz8W1SPbhoRyHJkti6mjH/XJIxp40jhO4j2k1m4KYrEykxqWPkFF3vrK4rgQppBh//AwyGSXPA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@open-draft/deferred-promise": "^2.2.0", + "@open-draft/logger": "^0.3.0", + "@open-draft/until": "^2.0.0", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "strict-event-emitter": "^0.5.1" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@noble/ciphers": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz", @@ -2569,6 +2693,31 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@open-draft/deferred-promise": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", + "integrity": "sha512-CecwLWx3rhxVQF6V4bAgPS5t+So2sTbPgAzafKkVizyi7tlwpcFpdFqq+wqF2OwNBmqFuu6tOyouTuxgpMfzmA==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@open-draft/logger": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@open-draft/logger/-/logger-0.3.0.tgz", + "integrity": "sha512-X2g45fzhxH238HKO4xbSr7+wBS8Fvw6ixhTDuvLd5mqh6bJJCFAPwU9mPDxbcrRtfxv4u5IHCEH77BmxvXmmxQ==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "is-node-process": "^1.2.0", + "outvariant": "^1.4.0" + } + }, + "node_modules/@open-draft/until": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@open-draft/until/-/until-2.1.0.tgz", + "integrity": "sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@opentelemetry/api": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", @@ -2588,6 +2737,22 @@ "node": ">=14" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@reduxjs/toolkit": { "version": "2.11.2", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", @@ -3292,6 +3457,70 @@ "node": ">=14.0.0" } }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.1.0", + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": { + "version": "1.8.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { + "version": "1.1.0", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1", + "@tybys/wasm-util": "^0.10.1" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": { + "version": "0.10.1", + "dev": true, + "inBundle": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": { + "version": "2.8.1", + "dev": true, + "inBundle": true, + "license": "0BSD", + "optional": true + }, "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.2.1.tgz", @@ -3636,6 +3865,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/statuses": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/@types/statuses/-/statuses-2.0.6.tgz", + "integrity": "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/trusted-types": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", @@ -4134,9 +4370,8 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, + "devOptional": true, "license": "MIT", - "peer": true, "engines": { "node": ">=8" } @@ -4145,7 +4380,7 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-convert": "^2.0.1" @@ -4273,6 +4508,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/axe-core": { + "version": "4.11.1", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.1.tgz", + "integrity": "sha512-BASOg+YwO2C+346x3LZOeoovTIoTrRqEsqMa6fmfAV0P+U9mFr9NsyOEpiYvFjbc64NMrSswhV50WdXzdb/Z5A==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.16", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.16.tgz", @@ -4669,6 +4914,49 @@ "node": ">= 16" } }, + "node_modules/cli-width": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz", + "integrity": "sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "devOptional": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/cliui/node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/clsx": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", @@ -4682,7 +4970,7 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "color-name": "~1.1.4" @@ -4695,7 +4983,7 @@ "version": "1.1.4", "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/combined-stream": { @@ -5194,6 +5482,13 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "devOptional": true, + "license": "MIT" + }, "node_modules/enhanced-resolve": { "version": "5.20.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.0.tgz", @@ -5420,7 +5715,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, + "devOptional": true, "license": "MIT", "engines": { "node": ">=6" @@ -5783,9 +6078,9 @@ } }, "node_modules/flatted": { - "version": "3.4.1", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz", - "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -5930,6 +6225,16 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -6121,6 +6426,16 @@ "dev": true, "license": "ISC" }, + "node_modules/graphql": { + "version": "16.13.2", + "resolved": "https://registry.npmjs.org/graphql/-/graphql-16.13.2.tgz", + "integrity": "sha512-5bJ+nf/UCpAjHM8i06fl7eLyVC9iuNAjm9qzkiu2ZGhM0VscSvS6WDPfAwkdkBuoXGM9FJSbKl6wylMwP9Ktig==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -6215,6 +6530,13 @@ "node": ">= 0.4" } }, + "node_modules/headers-polyfill": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/headers-polyfill/-/headers-polyfill-4.0.3.tgz", + "integrity": "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/hermes-estree": { "version": "0.25.1", "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", @@ -6535,6 +6857,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-generator-function": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", @@ -6601,6 +6933,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-node-process": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/is-node-process/-/is-node-process-1.2.0.tgz", + "integrity": "sha512-Vg4o6/fqPxIjtxgUH5QLJhwZ7gW5diGCVlXpuUfELC62CuxM1iHcRe51f2W1FDy04Ai4KJkagKjx3XaqyfRKXw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/is-number-object": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", @@ -7467,6 +7806,110 @@ "devOptional": true, "license": "MIT" }, + "node_modules/msw": { + "version": "2.12.14", + "resolved": "https://registry.npmjs.org/msw/-/msw-2.12.14.tgz", + "integrity": "sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ==", + "devOptional": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@inquirer/confirm": "^5.0.0", + "@mswjs/interceptors": "^0.41.2", + "@open-draft/deferred-promise": "^2.2.0", + "@types/statuses": "^2.0.6", + "cookie": "^1.0.2", + "graphql": "^16.12.0", + "headers-polyfill": "^4.0.2", + "is-node-process": "^1.2.0", + "outvariant": "^1.4.3", + "path-to-regexp": "^6.3.0", + "picocolors": "^1.1.1", + "rettime": "^0.10.1", + "statuses": "^2.0.2", + "strict-event-emitter": "^0.5.1", + "tough-cookie": "^6.0.0", + "type-fest": "^5.2.0", + "until-async": "^3.0.2", + "yargs": "^17.7.2" + }, + "bin": { + "msw": "cli/index.js" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/mswjs" + }, + "peerDependencies": { + "typescript": ">= 4.8.x" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/msw/node_modules/tldts": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.0.27.tgz", + "integrity": "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.0.27" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/msw/node_modules/tldts-core": { + "version": "7.0.27", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.0.27.tgz", + "integrity": "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/msw/node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "devOptional": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/msw/node_modules/type-fest": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-5.5.0.tgz", + "integrity": "sha512-PlBfpQwiUvGViBNX84Yxwjsdhd1TUlXr6zjX7eoirtCPIr08NAmxwa+fcYBTeRQxHo9YC9wwF3m9i700sHma8g==", + "devOptional": true, + "license": "(MIT OR CC0-1.0)", + "dependencies": { + "tagged-tag": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz", + "integrity": "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -7584,6 +8027,13 @@ "node": ">= 0.8.0" } }, + "node_modules/outvariant": { + "version": "1.4.3", + "resolved": "https://registry.npmjs.org/outvariant/-/outvariant-1.4.3.tgz", + "integrity": "sha512-+Sl2UErvtsoajRDKCE5/dBz4DIvHXQQnAxtQTF04OJxY0+DyZXSo5P5Bb7XYWOh81syohlYL24hbDwxedPUJCA==", + "devOptional": true, + "license": "MIT" + }, "node_modules/own-keys": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", @@ -7721,6 +8171,13 @@ "node": "20 || >=22" } }, + "node_modules/path-to-regexp": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz", + "integrity": "sha512-Yhpw4T9C6hPpgPeA28us07OJeqZ5EzQTkbfwuhsUg0c237RomFoETJgmp2sa3F/41gfLE6G5cqcYwznmeEeOlQ==", + "devOptional": true, + "license": "MIT" + }, "node_modules/pathe": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", @@ -7746,10 +8203,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", - "devOptional": true, + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -7758,6 +8214,53 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/possible-typed-array-names": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", @@ -7860,16 +8363,6 @@ "node": ">=6" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/react": { "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", @@ -8134,6 +8627,16 @@ "regjsparser": "bin/parser" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -8181,6 +8684,13 @@ "node": ">=4" } }, + "node_modules/rettime": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/rettime/-/rettime-0.10.1.tgz", + "integrity": "sha512-uyDrIlUEH37cinabq0AX4QbgV4HbFZ/gqoiunWQ1UqBtRvTTytwhNYjE++pO/MjPTZL5KQCf2bEoJ/BJNVQ5Kw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/rollup": { "version": "4.59.0", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.59.0.tgz", @@ -8259,27 +8769,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safe-push-apply": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", @@ -8355,13 +8844,13 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/set-cookie-parser": { @@ -8529,7 +9018,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz", "integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==", - "dev": true, + "devOptional": true, "license": "ISC", "engines": { "node": ">=14" @@ -8637,6 +9126,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/std-env": { "version": "3.10.0", "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", @@ -8658,6 +9157,28 @@ "node": ">= 0.4" } }, + "node_modules/strict-event-emitter": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.5.1.tgz", + "integrity": "sha512-vMgjE/GGEPEFnhFub6pa4FmJBRBVOLpIII2hvCZ8Kzb7K0hlHo7mQv6xYrBvCL2LtAIBwFUK8wvuJgTVSQ5MFQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -8760,6 +9281,19 @@ "node": ">=4" } }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-comments": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/strip-comments/-/strip-comments-2.0.1.tgz", @@ -8849,6 +9383,19 @@ "devOptional": true, "license": "MIT" }, + "node_modules/tagged-tag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/tagged-tag/-/tagged-tag-1.0.0.tgz", + "integrity": "sha512-yEFYrVhod+hdNyx7g5Bnkkb0G6si8HJurOoOEgC8B/O0uXLHlaey/65KRv6cuWBNhBgHKAROVpc7QyYqE5gFng==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tailwindcss": { "version": "4.2.1", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.1.tgz", @@ -9152,7 +9699,7 @@ "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -9279,6 +9826,16 @@ "node": ">= 10.0.0" } }, + "node_modules/until-async": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/until-async/-/until-async-3.0.2.tgz", + "integrity": "sha512-IiSk4HlzAMqTUseHHe3VhIGyuFmN90zMTpD3Z3y8jeQbzLIq500MVM7Jq2vUAnTKAFPJrqwkzr6PoTcPhGcOiw==", + "devOptional": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/kettanaito" + } + }, "node_modules/upath": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/upath/-/upath-1.2.0.tgz", @@ -9883,31 +10440,6 @@ "rollup": "^1.20.0 || ^2.0.0" } }, - "node_modules/workbox-build/node_modules/@rollup/pluginutils": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-3.1.0.tgz", - "integrity": "sha512-GksZ6pr6TpIjHm8h9lSQ8pi8BE9VeubNT0OMJ3B5uZJ8pz73NPiqOtCog/x2/QzM1ENChPKxMDhiQuRHsqc+lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "0.0.39", - "estree-walker": "^1.0.1", - "picomatch": "^2.2.2" - }, - "engines": { - "node": ">= 8.0.0" - }, - "peerDependencies": { - "rollup": "^1.20.0||^2.0.0" - } - }, - "node_modules/workbox-build/node_modules/@types/estree": { - "version": "0.0.39", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-0.0.39.tgz", - "integrity": "sha512-EYNwp3bU+98cpU4lAWYYL7Zz+2gryWH1qbdDTidVd6hkiR6weksdbMadyXKXNPEkQFhXM+hVO9ZygomHXp+AIw==", - "dev": true, - "license": "MIT" - }, "node_modules/workbox-build/node_modules/ajv": { "version": "8.18.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", @@ -9925,13 +10457,6 @@ "url": "https://github.com/sponsors/epoberezkin" } }, - "node_modules/workbox-build/node_modules/estree-walker": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-1.0.1.tgz", - "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", - "dev": true, - "license": "MIT" - }, "node_modules/workbox-build/node_modules/json-schema-traverse": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", @@ -9949,19 +10474,6 @@ "sourcemap-codec": "^1.4.8" } }, - "node_modules/workbox-build/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/workbox-build/node_modules/pretty-bytes": { "version": "5.6.0", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", @@ -10128,6 +10640,21 @@ "workbox-core": "7.4.0" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/ws": { "version": "8.19.0", "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", @@ -10167,6 +10694,16 @@ "devOptional": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -10174,6 +10711,35 @@ "dev": true, "license": "ISC" }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "devOptional": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -10187,6 +10753,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/yoctocolors-cjs/-/yoctocolors-cjs-2.1.3.tgz", + "integrity": "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw==", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/zod": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", diff --git a/package.json b/package.json index 67e3891..6f11531 100644 --- a/package.json +++ b/package.json @@ -9,11 +9,13 @@ "lint": "eslint .", "preview": "vite preview", "test": "NODE_ENV=test vitest run", - "test:watch": "NODE_ENV=test vitest" + "test:watch": "NODE_ENV=test vitest", + "test:e2e": "npx playwright test" }, "dependencies": { "@tanstack/react-query": "^5.0.0", "better-auth": "^1.2.0", + "picomatch": "4.0.4", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^7.0.0", @@ -21,24 +23,33 @@ "zustand": "^5.0.0" }, "devDependencies": { + "@axe-core/playwright": "^4.10.0", "@eslint/js": "^9.39.4", + "@playwright/test": "^1.58.2", "@tailwindcss/vite": "^4.0.0", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.2", "@types/node": "^24.12.0", "@types/react": "^18.3.28", "@types/react-dom": "^18.3.7", - "@vitejs/plugin-react": "^4.5.2", + "@vitejs/plugin-react": "^4.7.0", "eslint": "^9.39.4", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", "globals": "^17.4.0", "jsdom": "^25.0.1", + "msw": "^2.12.14", + "playwright": "^1.58.2", "tailwindcss": "^4.0.0", "typescript": "^5.7.3", "typescript-eslint": "^8.56.1", "vite": "^6.3.5", "vite-plugin-pwa": "^0.21.2", "vitest": "^3.2.4" + }, + "overrides": { + "@rollup/pluginutils": "5.3.0", + "flatted": "^3.4.2", + "serialize-javascript": "7.0.5" } } diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..b22d74a --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, devices } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] }, + }, + ], + webServer: { + command: 'VITE_MOCK_AUTH=true npm run dev', + url: 'http://localhost:5173', + reuseExistingServer: !process.env.CI, + }, + use: { + baseURL: 'http://localhost:5173', + }, +}); diff --git a/public/robots.txt b/public/robots.txt new file mode 100644 index 0000000..f1384ca --- /dev/null +++ b/public/robots.txt @@ -0,0 +1,4 @@ +User-agent: * +Allow: / + +Sitemap: https://cartsnitch.com/sitemap.xml diff --git a/receiptwitness/.github/workflows/ci.yml b/receiptwitness/.github/workflows/ci.yml deleted file mode 100644 index 785af69..0000000 --- a/receiptwitness/.github/workflows/ci.yml +++ /dev/null @@ -1,168 +0,0 @@ -name: CI - -on: - push: - branches: [main] - pull_request: - branches: [main] - -concurrency: - group: ci-${{ github.ref }} - cancel-in-progress: true - -permissions: - contents: write - packages: write - -env: - REGISTRY: ghcr.io - IMAGE_NAME: cartsnitch/receiptwitness - -jobs: - lint: - runs-on: runners-cartsnitch - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: pip - - name: Install cartsnitch-common from GitHub - run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b" - - run: pip install ruff - - name: Ruff lint - run: ruff check . - - name: Ruff format check - run: ruff format --check . - - typecheck: - runs-on: runners-cartsnitch - continue-on-error: true - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: pip - - name: Install cartsnitch-common from GitHub - run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b" - - run: pip install -e ".[dev]" mypy - - name: Type check - run: mypy src/receiptwitness - - test: - runs-on: runners-cartsnitch - services: - postgres: - image: postgres:15-alpine - credentials: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - env: - POSTGRES_USER: cartsnitch - POSTGRES_PASSWORD: cartsnitch_test - POSTGRES_DB: cartsnitch_test - ports: - - 5432:5432 - options: >- - --health-cmd pg_isready - --health-interval 10s - --health-timeout 5s - --health-retries 5 - redis: - image: redis:7-alpine - credentials: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - ports: - - 6379:6379 - options: >- - --health-cmd "redis-cli ping" - --health-interval 10s - --health-timeout 5s - --health-retries 5 - env: - DATABASE_URL: postgresql://cartsnitch:cartsnitch_test@localhost:5432/cartsnitch_test - REDIS_URL: redis://localhost:6379/0 - ENCRYPTION_KEY: dGVzdC1lbmNyeXB0aW9uLWtleS0xMjM0NTY3ODk= - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-python@v5 - with: - python-version: "3.12" - cache: pip - - name: Install cartsnitch-common from GitHub - run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b" - - run: pip install -e ".[dev]" - - name: Install Playwright browsers - run: playwright install chromium --with-deps - - name: Run tests - run: pytest --tb=short -q - - build-and-push: - runs-on: runners-cartsnitch - needs: [lint, test] - 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" - echo "CalVer tag: $VERSION" - - - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 - - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - - name: Log in to GHCR - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ github.actor }} - password: ${{ secrets.GITHUB_TOKEN }} - - - name: Extract metadata - id: meta - uses: docker/metadata-action@v5 - with: - images: ${{ env.REGISTRY }}/${{ env.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 Docker image - uses: docker/build-push-action@v6 - with: - context: . - push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} - tags: ${{ steps.meta.outputs.tags }} - labels: ${{ steps.meta.outputs.labels }} - target: prod - cache-from: type=gha - cache-to: type=gha,mode=max - - - name: Create git tag - if: github.event_name == 'push' && github.ref == 'refs/heads/main' - run: | - git tag "v${{ steps.calver.outputs.version }}" - git push origin "v${{ steps.calver.outputs.version }}" diff --git a/receiptwitness/Dockerfile b/receiptwitness/Dockerfile index bb6300d..8fead61 100644 --- a/receiptwitness/Dockerfile +++ b/receiptwitness/Dockerfile @@ -3,24 +3,21 @@ FROM python:3.12-slim AS build WORKDIR /app -# 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) +# build-essential and libpq-dev are needed to compile any C-extension wheels +# (e.g. psycopg2 fallback). No git needed — common/ is copied from the repo root. RUN apt-get update && apt-get install -y --no-install-recommends \ - git \ libpq-dev \ build-essential \ && rm -rf /var/lib/apt/lists/* -COPY pyproject.toml ./ -COPY src/ ./src/ +# Build context is the repo root. These paths are relative to the root. +COPY receiptwitness/pyproject.toml ./ +COPY receiptwitness/src/ ./src/ +COPY common/ ./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" \ - . +# Install from the local common/ (cartsnitch-common>=0.1.0 in pyproject.toml +# will be satisfied by the local package) then install receiptwitness itself. +RUN pip install --no-cache-dir --prefix=/install ./common/ . # Stage 2: Production image with Playwright + Chromium FROM python:3.12-slim AS prod @@ -51,7 +48,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ RUN adduser --system --group --uid 1000 app COPY --from=build /install /usr/local -COPY src/ ./src/ +COPY receiptwitness/src/ ./src/ # Install Playwright Chromium browser (runs as root; /opt/playwright is world-readable) RUN PLAYWRIGHT_BROWSERS_PATH=/opt/playwright playwright install chromium diff --git a/receiptwitness/pyproject.toml b/receiptwitness/pyproject.toml index f32acfc..dd3d6ea 100644 --- a/receiptwitness/pyproject.toml +++ b/receiptwitness/pyproject.toml @@ -14,11 +14,13 @@ dependencies = [ "cryptography>=42.0,<44.0", "fastapi>=0.115,<1.0", "uvicorn[standard]>=0.30,<1.0", + "beautifulsoup4>=4.12,<5.0", "redis>=5.0,<6.0", "pydantic>=2.0,<3.0", "pydantic-settings>=2.0,<3.0", "sqlalchemy[asyncio]>=2.0,<3.0", "asyncpg>=0.29,<1.0", + "resend>=2.0", ] [project.optional-dependencies] @@ -27,6 +29,9 @@ dev = [ "pytest-asyncio>=0.23", "ruff>=0.3", "pytest-cov>=5.0", + "fakeredis[aioredis]>=2.20", + "httpx>=0.27", + "python-multipart>=0.0.9", ] [tool.hatch.build.targets.wheel] diff --git a/receiptwitness/src/receiptwitness/api/routes.py b/receiptwitness/src/receiptwitness/api/routes.py index 23cc109..483cdcc 100644 --- a/receiptwitness/src/receiptwitness/api/routes.py +++ b/receiptwitness/src/receiptwitness/api/routes.py @@ -1,9 +1,61 @@ """Internal API routes for triggering scrapes and checking status.""" -from fastapi import APIRouter +import hashlib +import hmac +import re +import time + +from fastapi import APIRouter, HTTPException, Request + +from receiptwitness.config import settings +from receiptwitness.queue.email import EmailJob, enqueue_email, get_redis router = APIRouter() +TOKEN_PATTERN = re.compile(r"receipts\+([A-Za-z0-9_-]+)@") + + +def verify_mailgun_signature(token: str, timestamp: str, signature: str) -> bool: + """Verify Mailgun webhook signature.""" + if abs(time.time() - int(timestamp)) > 300: # 5 min freshness + return False + key = settings.mailgun_webhook_signing_key.encode() + hmac_digest = hmac.new(key, f"{timestamp}{token}".encode(), hashlib.sha256).hexdigest() + return hmac.compare_digest(signature, hmac_digest) + + +@router.post("/inbound/email") +async def receive_inbound_email(request: Request): + form = await request.form() + # 1. Verify Mailgun signature + token = str(form.get("token", "")) + timestamp = str(form.get("timestamp", "")) + signature = str(form.get("signature", "")) + if not verify_mailgun_signature(token, timestamp, signature): + raise HTTPException(status_code=406, detail="Invalid signature") + # 2. Extract account token from recipient + recipient = str(form.get("recipient", "")) + match = TOKEN_PATTERN.search(recipient) + if not match: + raise HTTPException(status_code=406, detail="Invalid recipient") + account_token = match.group(1) + # 3. Enqueue — worker resolves token -> user_id + body_html_val = form.get("body-html") + body_plain_val = form.get("body-plain") + job = EmailJob( + user_id=account_token, + sender=str(form.get("sender", "")), + recipient=recipient, + subject=str(form.get("subject", "")), + body_html=str(body_html_val) if body_html_val is not None else None, + body_plain=str(body_plain_val) if body_plain_val is not None else None, + received_at=str(form.get("timestamp", "")), + message_id=str(form.get("Message-Id", "")), + ) + client = await get_redis() + await enqueue_email(client, job) + return {"status": "queued"} + @router.get("/health") async def health(): diff --git a/receiptwitness/src/receiptwitness/config.py b/receiptwitness/src/receiptwitness/config.py index 1341f3f..358b965 100644 --- a/receiptwitness/src/receiptwitness/config.py +++ b/receiptwitness/src/receiptwitness/config.py @@ -22,5 +22,13 @@ class ReceiptWitnessSettings(BaseSettings): headless: bool = True browser_timeout_ms: int = 60000 + # Email notifications (Resend) + resend_api_key: str = "" + notification_email_from: str = "notifications@cartsnitch.com" + notifications_enabled: bool = False + + # Mailgun inbound email webhook + mailgun_webhook_signing_key: str = "" + settings = ReceiptWitnessSettings() diff --git a/receiptwitness/src/receiptwitness/events.py b/receiptwitness/src/receiptwitness/events.py index 3d75614..a9e6204 100644 --- a/receiptwitness/src/receiptwitness/events.py +++ b/receiptwitness/src/receiptwitness/events.py @@ -2,12 +2,17 @@ import json import logging +import uuid from datetime import UTC, datetime from decimal import Decimal import redis.asyncio as aioredis +from cartsnitch_common.database import get_async_session_factory +from cartsnitch_common.models.user import User +from sqlalchemy import select from receiptwitness.config import settings +from receiptwitness.notifications.email import send_receipt_notification logger = logging.getLogger(__name__) @@ -39,6 +44,36 @@ async def get_redis_client() -> aioredis.Redis: return aioredis.Redis(connection_pool=_get_pool()) +async def _send_notification_for_event(payload: dict) -> None: + """Look up user email and send receipt notification. Silently skips on error.""" + try: + user_uuid = uuid.UUID(payload["user_id"]) + except (ValueError, KeyError): + logger.warning("Invalid user_id in event payload: %s", payload.get("user_id")) + return + + try: + session_factory = get_async_session_factory(settings.database_url) + async with session_factory() as session: + result = await session.execute(select(User.email).where(User.id == user_uuid)) + row = result.scalar_one_or_none() + if not row: + logger.warning("User %s not found for notification", user_uuid) + return + user_email = row + except Exception: + logger.exception("Failed to look up user email for notification") + return + + await send_receipt_notification( + user_email=user_email, + store_name=payload["store_slug"], + item_count=payload["item_count"], + total=payload["total"], + purchase_date=payload["purchase_date"], + ) + + async def publish_receipt_ingested( user_id: str, store_slug: str, @@ -48,18 +83,19 @@ async def publish_receipt_ingested( total: Decimal | float, ) -> None: """Publish a cartsnitch.receipts.ingested event after successful ingestion.""" + payload = { + "user_id": user_id, + "store_slug": store_slug, + "purchase_id": purchase_id, + "purchase_date": purchase_date, + "item_count": item_count, + "total": float(total) if isinstance(total, Decimal) else total, + } event = { "event_type": CHANNEL_RECEIPTS_INGESTED, "timestamp": datetime.now(UTC).isoformat(), "service": "receiptwitness", - "payload": { - "user_id": user_id, - "store_slug": store_slug, - "purchase_id": purchase_id, - "purchase_date": purchase_date, - "item_count": item_count, - "total": float(total) if isinstance(total, Decimal) else total, - }, + "payload": payload, } try: @@ -73,3 +109,5 @@ async def publish_receipt_ingested( except aioredis.ConnectionError: logger.error("Failed to publish event — Redis/DragonflyDB connection error") raise + else: + await _send_notification_for_event(payload) diff --git a/receiptwitness/src/receiptwitness/notifications/__init__.py b/receiptwitness/src/receiptwitness/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/receiptwitness/src/receiptwitness/notifications/email.py b/receiptwitness/src/receiptwitness/notifications/email.py new file mode 100644 index 0000000..d5723f2 --- /dev/null +++ b/receiptwitness/src/receiptwitness/notifications/email.py @@ -0,0 +1,45 @@ +"""Email notifications via Resend.""" + +import asyncio +import html +import logging + +import resend + +from receiptwitness.config import settings + +logger = logging.getLogger(__name__) + + +async def send_receipt_notification( + user_email: str, + store_name: str, + item_count: int, + total: float, + purchase_date: str, +) -> None: + """Send receipt ingestion confirmation email via Resend.""" + if not settings.notifications_enabled or not settings.resend_api_key: + logger.debug("Notifications disabled — skipping email send") + return + + resend.api_key = settings.resend_api_key + store_name_safe = html.escape(store_name) + purchase_date_safe = html.escape(purchase_date) + try: + await asyncio.to_thread( + resend.Emails.send, + { + "from": settings.notification_email_from, + "to": [user_email], + "subject": f"Receipt processed: {store_name} - ${total:.2f}", + "html": ( + f"

Your receipt from {store_name_safe} on " + f"{purchase_date_safe} has been processed.

" + f"

{item_count} items, total: ${total:.2f}

" + ), + }, + ) + logger.info("Receipt notification sent to %s", user_email) + except Exception: + logger.exception("Failed to send receipt notification to %s", user_email) diff --git a/receiptwitness/src/receiptwitness/parsers/email/__init__.py b/receiptwitness/src/receiptwitness/parsers/email/__init__.py new file mode 100644 index 0000000..9d01da5 --- /dev/null +++ b/receiptwitness/src/receiptwitness/parsers/email/__init__.py @@ -0,0 +1 @@ +"""Email receipt parsers for retailer email receipts.""" diff --git a/receiptwitness/src/receiptwitness/parsers/email/base.py b/receiptwitness/src/receiptwitness/parsers/email/base.py new file mode 100644 index 0000000..a25535e --- /dev/null +++ b/receiptwitness/src/receiptwitness/parsers/email/base.py @@ -0,0 +1,32 @@ +"""Base interface for email receipt parsers.""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field + + +@dataclass +class EmailReceipt: + """Raw email data before parsing.""" + + sender: str + recipient: str + subject: str + body_html: str | None = None + body_plain: str | None = None + received_at: str | None = None + raw_headers: dict = field(default_factory=dict) + + +class BaseEmailParser(ABC): + """All retailer email parsers implement this interface.""" + + @abstractmethod + def can_parse(self, email: EmailReceipt) -> bool: + """Return True if this parser handles this email.""" + ... + + @abstractmethod + def parse(self, email: EmailReceipt) -> dict: + """Parse email into a dict matching PurchaseCreate schema fields. + Must include an items list matching PurchaseItemCreate fields.""" + ... diff --git a/receiptwitness/src/receiptwitness/parsers/email/detector.py b/receiptwitness/src/receiptwitness/parsers/email/detector.py new file mode 100644 index 0000000..e71f769 --- /dev/null +++ b/receiptwitness/src/receiptwitness/parsers/email/detector.py @@ -0,0 +1,25 @@ +"""Detect which retailer sent a receipt email.""" + +import re + +from receiptwitness.parsers.email.base import EmailReceipt + +RETAILER_PATTERNS: dict[str, list[str]] = { + "meijer": [r"@meijer\.com$", r"@email\.meijer\.com$"], + "kroger": [r"@kroger\.com$", r"@email\.kroger\.com$"], + "target": [r"@target\.com$", r"@email\.target\.com$"], +} + + +def detect_retailer(email: EmailReceipt) -> str | None: + """Return retailer slug or None if unrecognized.""" + sender = email.sender.lower().strip() + # Extract email from "Name " format + match = re.search(r"<([^>]+)>", sender) + if match: + sender = match.group(1) + for retailer, patterns in RETAILER_PATTERNS.items(): + for pattern in patterns: + if re.search(pattern, sender): + return retailer + return None diff --git a/receiptwitness/src/receiptwitness/parsers/email/kroger.py b/receiptwitness/src/receiptwitness/parsers/email/kroger.py new file mode 100644 index 0000000..364f59e --- /dev/null +++ b/receiptwitness/src/receiptwitness/parsers/email/kroger.py @@ -0,0 +1,157 @@ +"""Kroger email receipt parser.""" + +import logging +import re +from datetime import datetime +from decimal import Decimal, InvalidOperation + +from bs4 import BeautifulSoup + +from receiptwitness.parsers.email.base import BaseEmailParser, EmailReceipt + +logger = logging.getLogger(__name__) + + +def _to_decimal(value: str | float | int | None, default: Decimal = Decimal("0")) -> Decimal: + """Safely convert a value to Decimal.""" + if value is None: + return default + try: + return Decimal(str(value).replace("$", "").replace(",", "").strip()) + except (InvalidOperation, ValueError): + return default + + +def _extract_total(body: str) -> Decimal: + """Extract the transaction total from email body.""" + patterns = [ + r"Total[:\s]*\$?([0-9,]+\.[0-9]{2})", + r"Amount[:\s]*\$?([0-9,]+\.[0-9]{2})", + r"Grand\s+Total[:\s]*\$?([0-9,]+\.[0-9]{2})", + ] + for pattern in patterns: + match = re.search(pattern, body, re.IGNORECASE) + if match: + return _to_decimal(match.group(1)) + return Decimal("0") + + +def _extract_receipt_id(body: str) -> str | None: + """Extract receipt ID / transaction ID from HTML body. + + Strips HTML tags first so that whitespace between delimiters and values + (e.g. from `` KR-2026-0315-4829`` -> `` KR-2026-0315-4829``) + is normalized and the pattern can match cleanly. + """ + stripped = re.sub(r"<[^>]+>", "", body) + patterns = [ + r"Receipt\s*#[:\s]*([A-Z0-9-]+)", + r"Transaction\s*#[:\s]*([A-Z0-9-]+)", + r"Order\s*#[:\s]*([A-Z0-9-]+)", + r"Confirmation\s*#[:\s]*([A-Z0-9-]+)", + ] + for pattern in patterns: + match = re.search(pattern, stripped, re.IGNORECASE) + if match: + return match.group(1) + return None + + +def _extract_date(body: str) -> str: + """Extract purchase date from email body. Returns ISO date string or empty string.""" + patterns = [ + r"(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})", + r"([A-Z][a-z]{2}\s+\d{1,2},?\s+\d{4})", + ] + for pattern in patterns: + match = re.search(pattern, body) + if match: + raw = match.group(1) + try: + dt = datetime.strptime(raw.replace(",", ""), "%b %d %Y") + return dt.strftime("%Y-%m-%d") + except ValueError: + pass + try: + for fmt in ("%m/%d/%Y", "%m/%d/%y", "%d/%m/%Y", "%d/%m/%y"): + try: + dt = datetime.strptime(raw, fmt) + return dt.strftime("%Y-%m-%d") + except ValueError: + continue + except Exception: + pass + return "" + + +def _extract_items_soup(body: str) -> list[dict]: + """Extract line items from HTML email body using BeautifulSoup.""" + items = [] + try: + soup = BeautifulSoup(body, "html.parser") + text = soup.get_text(separator="\n", strip=True) + # Strip HTML tags from raw body to normalize whitespace + stripped = re.sub(r"<[^>]+>", " ", body) + stripped = re.sub(r"\s+", " ", stripped) + skip_prefixes = ( + "Subtotal", + "Tax", + "Total", + "Kroger", + "Target", + "Date", + "Receipt", + "Order", + "Transaction", + "Confirmation", + "Thank", + "Questions", + "Keep", + "Receipt", + ) + for line in text.split("\n"): + line = line.strip() + if not line or line.startswith(skip_prefixes): + continue + # Match lines like "Product Name $9.99" + match = re.match(r"(.+?)\s+\$([0-9]+\.[0-9]{2})\s*$", line) + if match: + name = match.group(1).strip() + price = _to_decimal(match.group(2)) + if len(name) > 2 and price > 0: + items.append( + { + "product_name_raw": name, + "quantity": Decimal("1"), + "unit_price": price, + "extended_price": price, + } + ) + except Exception: + pass + return items[:20] + + +class KrogerEmailParser(BaseEmailParser): + """Parse Kroger email receipts (digital receipts via kroger.com).""" + + KROGER_KEYWORDS = ("kroger", "kroger.com", "plus") + + def can_parse(self, email: EmailReceipt) -> bool: + sender = (email.sender or "").lower() + body = (email.body_html or email.body_plain or "").lower() + return any(kw in sender or kw in body for kw in self.KROGER_KEYWORDS) + + def parse(self, email: EmailReceipt) -> dict: + body = (email.body_html or email.body_plain or "").strip() + total = _extract_total(body) + receipt_id = _extract_receipt_id(body) or "" + purchase_date = _extract_date(body) + items = _extract_items_soup(body) + + return { + "receipt_id": receipt_id, + "purchase_date": purchase_date, + "total": total, + "items": items, + } diff --git a/receiptwitness/src/receiptwitness/parsers/email/meijer.py b/receiptwitness/src/receiptwitness/parsers/email/meijer.py new file mode 100644 index 0000000..598acb7 --- /dev/null +++ b/receiptwitness/src/receiptwitness/parsers/email/meijer.py @@ -0,0 +1,259 @@ +"""Parse Meijer digital receipt emails into structured purchase data.""" + +import re +from decimal import Decimal, InvalidOperation + +from bs4 import BeautifulSoup +from bs4.element import Tag + +from receiptwitness.parsers.email.base import BaseEmailParser, EmailReceipt + + +def _to_decimal(value, default: str = "0") -> Decimal: + """Safely convert a value to Decimal.""" + if value is None: + return Decimal(default) + try: + return Decimal(str(value).replace("$", "").replace(",", "").strip()) + except (InvalidOperation, ValueError, TypeError): + return Decimal(default) + + +def _extract_receipt_id(soup: BeautifulSoup, subject: str | None) -> str | None: + """Extract receipt/transaction ID from subject or body.""" + if subject: + match = re.search(r"TXN[-\s]\d{4}[-\s]\d{4}[-\s]\d+", subject) + if match: + return match.group(0).replace(" ", "-") + # Fallback: look in body + text = soup.get_text() + match = re.search(r"TXN[-\s]\d{4}[-\s]\d{4}[-\s]\d+", text) + if match: + return match.group(0).replace(" ", "-") + return None + + +def _extract_purchase_date(soup: BeautifulSoup, subject: str | None) -> str | None: + """Extract purchase date from subject or body.""" + text = soup.get_text() + + # Try ISO format first: YYYY-MM-DD + match = re.search(r"(\d{4})-(\d{2})-(\d{2})", text) + if match: + return f"{match.group(1)}-{match.group(2)}-{match.group(3)}" + + # Try written format: March 15, 2026 + match = re.search(r"([A-Za-z]+)\s+(\d{1,2}),?\s+(\d{4})", text) + if match: + month_str = match.group(1).lower() + day = match.group(2) + year = match.group(3) + month_map = { + "january": "01", + "february": "02", + "march": "03", + "april": "04", + "may": "05", + "june": "06", + "july": "07", + "august": "08", + "september": "09", + "october": "10", + "november": "11", + "december": "12", + } + month = month_map.get(month_str) + if month: + return f"{year}-{month}-{day.zfill(2)}" + + # MM/DD/YYYY + match = re.search(r"(\d{1,2})/(\d{1,2})/(\d{4})", text) + if match: + return f"{match.group(3)}-{match.group(1).zfill(2)}-{match.group(2).zfill(2)}" + + return None + + +def _extract_store_info(soup: BeautifulSoup) -> dict: + """Extract store name and number from the email body.""" + store_info: dict = {} + + # Look for store number in header + store_num_match = re.search(r"Meijer\s+Store\s+#?(\d+)", soup.get_text(), re.IGNORECASE) + if store_num_match: + store_info["store_number"] = store_num_match.group(1) + + return store_info + + +def _extract_items(table: Tag | None) -> list[dict]: + """Extract line items from the items table.""" + items: list[dict] = [] + if not table: + return items + + rows = table.find_all("tr") + for row in rows: + cells = row.find_all("td") + if len(cells) < 3: + continue + + name_cell = cells[0].get_text(strip=True) + qty_cell = cells[1].get_text(strip=True) + price_cell = cells[2].get_text(strip=True) + + if not name_cell or name_cell.lower() in ("item", "description"): + continue + + # Skip subtotal/tax/total/savings rows + if any( + label in name_cell.lower() + for label in ("subtotal", "tax", "total", "savings", "grand total") + ): + continue + + try: + quantity = Decimal(qty_cell) + except (InvalidOperation, ValueError, TypeError): + quantity = Decimal("1") + + price_str = price_cell.replace("$", "").replace(",", "").strip() + try: + unit_price = Decimal(price_str) + except (InvalidOperation, ValueError, TypeError): + unit_price = Decimal("0") + + extended_price = unit_price # Default to unit price; no qty column in fixture + + items.append( + { + "product_name_raw": name_cell, + "quantity": quantity, + "unit_price": unit_price, + "extended_price": extended_price, + } + ) + + return items + + +def _extract_totals_plain(text: str) -> dict: + """Extract totals from plain text (no HTML).""" + totals: dict = { + "subtotal": None, + "tax": None, + "total": None, + "savings_total": None, + } + + match = re.search(r"\bSubtotal\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE) + if match: + totals["subtotal"] = _to_decimal(match.group(1)) + + match = re.search(r"\bTax\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE) + if match: + totals["tax"] = _to_decimal(match.group(1)) + + grand_total_match = re.search(r"Grand\s+Total\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE) + if grand_total_match: + totals["total"] = _to_decimal(grand_total_match.group(1)) + + savings_match = re.search(r"\bSavings\b[:\s$\-]*([0-9,]+\.?\d*)", text, re.IGNORECASE) + if savings_match: + totals["savings_total"] = _to_decimal(savings_match.group(1)) + + if totals["total"] is None: + total_match = re.search(r"\bTotal\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE) + if total_match: + totals["total"] = _to_decimal(total_match.group(1)) + + return totals + + +def _extract_totals(soup: BeautifulSoup) -> dict: + """Extract subtotal, tax, total, and savings from the totals section.""" + text = soup.get_text() + + totals: dict = { + "subtotal": None, + "tax": None, + "total": None, + "savings_total": None, + } + + # Subtotal — use word boundary to avoid matching "Subtotal" with "Total" + match = re.search(r"\bSubtotal\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE) + if match: + totals["subtotal"] = _to_decimal(match.group(1)) + + # Tax + match = re.search(r"\bTax\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE) + if match: + totals["tax"] = _to_decimal(match.group(1)) + + # Grand Total (before plain "Total" to avoid matching "Subtotal") + grand_total_match = re.search(r"Grand\s+Total\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE) + if grand_total_match: + totals["total"] = _to_decimal(grand_total_match.group(1)) + + # Savings — allow any combination of whitespace/$- around the number + savings_match = re.search(r"\bSavings\b[:\s$\-]*([0-9,]+\.?\d*)", text, re.IGNORECASE) + if savings_match: + totals["savings_total"] = _to_decimal(savings_match.group(1)) + + # Plain "Total" only if Grand Total wasn't found + if totals["total"] is None: + total_match = re.search(r"\bTotal\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE) + if total_match: + totals["total"] = _to_decimal(total_match.group(1)) + + return totals + + +class MeijerEmailParser(BaseEmailParser): + """Parse Meijer digital receipt emails forwarded by users.""" + + def can_parse(self, email: EmailReceipt) -> bool: + sender = email.sender.lower().strip() + # Extract email from "Name " format + match = re.search(r"<([^>]+)>", sender) + if match: + sender = match.group(1) + return "meijer" in sender + + def parse(self, email: EmailReceipt) -> dict: + body_html = email.body_html + body_plain = email.body_plain or "" + body = body_html or body_plain + soup = BeautifulSoup(body, "html.parser") + + receipt_id = _extract_receipt_id(soup, email.subject) + purchase_date = _extract_purchase_date(soup, email.subject) + _ = _extract_store_info(soup) + + # Find the items table — look for one with Item/Qty/Price headers + table = None + for tbl in soup.find_all("table"): + headers = tbl.find_all("th") + header_texts = [h.get_text(strip=True).lower() for h in headers] + if any("item" in h or "qty" in h or "price" in h for h in header_texts): + table = tbl + break + + items = _extract_items(table) + + # Extract totals from HTML; fall back to plain text if no HTML + if body_html: + totals = _extract_totals(soup) + else: + totals = _extract_totals_plain(body_plain) + + return { + "receipt_id": receipt_id or "", + "purchase_date": purchase_date or "", + "total": totals["total"] or Decimal("0"), + "subtotal": totals["subtotal"], + "tax": totals["tax"], + "savings_total": totals["savings_total"], + "items": items, + } diff --git a/receiptwitness/src/receiptwitness/parsers/email/target.py b/receiptwitness/src/receiptwitness/parsers/email/target.py new file mode 100644 index 0000000..c7e58d3 --- /dev/null +++ b/receiptwitness/src/receiptwitness/parsers/email/target.py @@ -0,0 +1,156 @@ +"""Target email receipt parser.""" + +import logging +import re +from datetime import datetime +from decimal import Decimal, InvalidOperation + +from bs4 import BeautifulSoup + +from receiptwitness.parsers.email.base import BaseEmailParser, EmailReceipt + +logger = logging.getLogger(__name__) + + +def _to_decimal(value: str | float | int | None, default: Decimal = Decimal("0")) -> Decimal: + """Safely convert a value to Decimal.""" + if value is None: + return default + try: + return Decimal(str(value).replace("$", "").replace(",", "").strip()) + except (InvalidOperation, ValueError): + return default + + +def _extract_total(body: str) -> Decimal: + """Extract the transaction total from email body.""" + patterns = [ + r"Total[:\s]*\$?([0-9,]+\.[0-9]{2})", + r"Amount[:\s]*\$?([0-9,]+\.[0-9]{2})", + r"Grand\s+Total[:\s]*\$?([0-9,]+\.[0-9]{2})", + ] + for pattern in patterns: + match = re.search(pattern, body, re.IGNORECASE) + if match: + return _to_decimal(match.group(1)) + return Decimal("0") + + +def _extract_receipt_id(body: str) -> str | None: + """Extract receipt ID / transaction ID from HTML body. + + Strips HTML tags first so that whitespace between delimiters and values + (e.g. from `` TGT-2026-0318-9124`` -> `` TGT-2026-0318-9124``) + is normalized and the pattern can match cleanly. + """ + stripped = re.sub(r"<[^>]+>", "", body) + patterns = [ + r"Receipt\s*#[:\s]*([A-Z0-9-]+)", + r"Order\s*#[:\s]*([A-Z0-9-]+)", + r"Confirmation\s*#[:\s]*([A-Z0-9-]+)", + r"Target\s+Order\s*#[:\s]*([A-Z0-9-]+)", + ] + for pattern in patterns: + match = re.search(pattern, stripped, re.IGNORECASE) + if match: + return match.group(1) + return None + + +def _extract_date(body: str) -> str: + """Extract purchase date from email body. Returns ISO date string or empty string.""" + patterns = [ + r"(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})", + r"([A-Z][a-z]{2}\s+\d{1,2},?\s+\d{4})", + ] + for pattern in patterns: + match = re.search(pattern, body) + if match: + raw = match.group(1) + try: + dt = datetime.strptime(raw.replace(",", ""), "%b %d %Y") + return dt.strftime("%Y-%m-%d") + except ValueError: + pass + try: + for fmt in ("%m/%d/%Y", "%m/%d/%y", "%d/%m/%Y", "%d/%m/%y"): + try: + dt = datetime.strptime(raw, fmt) + return dt.strftime("%Y-%m-%d") + except ValueError: + continue + except Exception: + pass + return "" + + +def _extract_items_soup(body: str) -> list[dict]: + """Extract line items from HTML email body using BeautifulSoup.""" + items = [] + try: + soup = BeautifulSoup(body, "html.parser") + text = soup.get_text(separator="\n", strip=True) + for line in text.split("\n"): + line = line.strip() + if not line or line.startswith( + ( + "Subtotal", + "Tax", + "Total", + "Target", + "Kroger", + "Date", + "Receipt", + "Order", + "Transaction", + "Confirmation", + "Thank", + "Questions", + "Keep", + "Receipt", + "Store", + ) + ): + continue + # Match lines like "Product Name $9.99" + match = re.match(r"(.+?)\s+\$([0-9]+\.[0-9]{2})\s*$", line) + if match: + name = match.group(1).strip() + price = _to_decimal(match.group(2)) + if len(name) > 2 and price > 0: + items.append( + { + "product_name_raw": name, + "quantity": Decimal("1"), + "unit_price": price, + "extended_price": price, + } + ) + except Exception: + pass + return items[:20] + + +class TargetEmailParser(BaseEmailParser): + """Parse Target email receipts (Circle order confirmations).""" + + TARGET_KEYWORDS = ("target.com", "targetnow", "circle", "target") + + def can_parse(self, email: EmailReceipt) -> bool: + sender = (email.sender or "").lower() + body = (email.body_html or email.body_plain or "").lower() + return any(kw in sender or kw in body for kw in self.TARGET_KEYWORDS) + + def parse(self, email: EmailReceipt) -> dict: + body = (email.body_html or email.body_plain or "").strip() + total = _extract_total(body) + receipt_id = _extract_receipt_id(body) or "" + purchase_date = _extract_date(body) + items = _extract_items_soup(body) + + return { + "receipt_id": receipt_id, + "purchase_date": purchase_date, + "total": total, + "items": items, + } diff --git a/receiptwitness/src/receiptwitness/queue/__init__.py b/receiptwitness/src/receiptwitness/queue/__init__.py new file mode 100644 index 0000000..3f9a31f --- /dev/null +++ b/receiptwitness/src/receiptwitness/queue/__init__.py @@ -0,0 +1 @@ +"""DragonflyDB Streams queue for email receipt processing.""" diff --git a/receiptwitness/src/receiptwitness/queue/email.py b/receiptwitness/src/receiptwitness/queue/email.py new file mode 100644 index 0000000..c76148e --- /dev/null +++ b/receiptwitness/src/receiptwitness/queue/email.py @@ -0,0 +1,77 @@ +"""DragonflyDB Streams queue for email receipt processing.""" + +from __future__ import annotations + +import json +import logging +from dataclasses import asdict, dataclass +from typing import cast + +import redis.asyncio as aioredis + +from receiptwitness.config import settings + +logger = logging.getLogger(__name__) + +STREAM_KEY = "email:receipts" +CONSUMER_GROUP = "email-workers" + + +@dataclass +class EmailJob: + """Payload for an email receipt processing job.""" + + user_id: str + sender: str + recipient: str + subject: str + body_html: str | None + body_plain: str | None + received_at: str + message_id: str # from email provider, for dedup + + +async def get_redis() -> aioredis.Redis: + """Get async Redis/DragonflyDB client.""" + return cast(aioredis.Redis, aioredis.from_url(settings.redis_url, decode_responses=True)) + + +async def ensure_consumer_group(client: aioredis.Redis) -> None: + """Create consumer group if it does not exist.""" + try: + await client.xgroup_create(STREAM_KEY, CONSUMER_GROUP, id="0", mkstream=True) + except aioredis.ResponseError as e: + if "BUSYGROUP" not in str(e): + raise + + +async def enqueue_email(client: aioredis.Redis, job: EmailJob) -> str: + """Add email job to the stream. Returns the stream message ID.""" + payload: dict[str, str | bytes | int | float] = {"data": json.dumps(asdict(job))} + msg_id: str = cast(str, await client.xadd(STREAM_KEY, payload)) # type: ignore[arg-type] # redis-py StreamCommands.xadd expects broader FieldT union; runtime behavior is correct + logger.info("Enqueued email job %s for user %s", msg_id, job.user_id) + return msg_id + + +async def consume_emails( + client: aioredis.Redis, + consumer_name: str, + count: int = 1, + block_ms: int = 5000, +) -> list[tuple[str, EmailJob]]: + """Read pending messages from the stream. Returns list of (msg_id, EmailJob).""" + await ensure_consumer_group(client) + messages = await client.xreadgroup( + CONSUMER_GROUP, consumer_name, {STREAM_KEY: ">"}, count=count, block=block_ms + ) + results = [] + for _stream, entries in messages: + for msg_id, fields in entries: + job = EmailJob(**json.loads(fields["data"])) + results.append((msg_id, job)) + return results + + +async def ack_email(client: aioredis.Redis, msg_id: str) -> None: + """Acknowledge a processed message.""" + await client.xack(STREAM_KEY, CONSUMER_GROUP, msg_id) diff --git a/receiptwitness/src/receiptwitness/worker/__init__.py b/receiptwitness/src/receiptwitness/worker/__init__.py new file mode 100644 index 0000000..e32899a --- /dev/null +++ b/receiptwitness/src/receiptwitness/worker/__init__.py @@ -0,0 +1 @@ +"""Async email receipt worker consuming from DragonflyDB Streams.""" diff --git a/receiptwitness/src/receiptwitness/worker/email_worker.py b/receiptwitness/src/receiptwitness/worker/email_worker.py new file mode 100644 index 0000000..52a5dc0 --- /dev/null +++ b/receiptwitness/src/receiptwitness/worker/email_worker.py @@ -0,0 +1,104 @@ +"""Async worker that consumes email receipt jobs from DragonflyDB Streams.""" + +import asyncio +import logging + +from cartsnitch_common.database import get_async_session_factory +from cartsnitch_common.models.user import User +from sqlalchemy import select + +from receiptwitness.config import settings +from receiptwitness.events import publish_receipt_ingested +from receiptwitness.parsers.email.base import BaseEmailParser, EmailReceipt +from receiptwitness.parsers.email.detector import detect_retailer +from receiptwitness.parsers.email.kroger import KrogerEmailParser +from receiptwitness.parsers.email.meijer import MeijerEmailParser +from receiptwitness.parsers.email.target import TargetEmailParser +from receiptwitness.queue.email import ack_email, consume_emails, get_redis + +logger = logging.getLogger(__name__) + +CONSUMER_NAME = "worker-1" + +# Registry of available email parsers +PARSERS: dict[str, BaseEmailParser] = { + "meijer": MeijerEmailParser(), + "kroger": KrogerEmailParser(), + "target": TargetEmailParser(), +} + + +async def resolve_user(token: str) -> str | None: + """Look up user_id from email_inbound_token.""" + session_factory = get_async_session_factory(settings.database_url) + async with session_factory() as session: + result = await session.execute(select(User.id).where(User.email_inbound_token == token)) + row = result.scalar_one_or_none() + return str(row) if row else None + + +async def process_job(msg_id: str, job) -> bool: + """Process a single email job. Returns True on success.""" + # 1. Resolve user from token + user_id = await resolve_user(job.user_id) # user_id field holds token + if not user_id: + logger.warning("Unknown token %s, dropping message %s", job.user_id, msg_id) + return True # ack to avoid infinite retry + + # 2. Build EmailReceipt + email = EmailReceipt( + sender=job.sender, + recipient=job.recipient, + subject=job.subject, + body_html=job.body_html, + body_plain=job.body_plain, + received_at=job.received_at, + ) + + # 3. Detect retailer + retailer = detect_retailer(email) + if not retailer or retailer not in PARSERS: + logger.warning( + "Unrecognized retailer from %s, archiving msg %s", + job.sender, + msg_id, + ) + return True # ack — no parser available + + # 4. Parse + parser = PARSERS[retailer] + parsed = parser.parse(email) + + # 5. Publish event + await publish_receipt_ingested( + user_id=user_id, + store_slug=retailer, + purchase_id=parsed.get("receipt_id", msg_id), + purchase_date=parsed.get("purchase_date", ""), + item_count=len(parsed.get("items", [])), + total=parsed.get("total", 0), + ) + return True + + +async def run_worker() -> None: + """Main worker loop — consume and process email jobs.""" + client = await get_redis() + logger.info("Email worker started, consuming from email:receipts") + while True: + try: + jobs = await consume_emails(client, CONSUMER_NAME, count=5, block_ms=5000) + for msg_id, job in jobs: + try: + success = await process_job(msg_id, job) + if success: + await ack_email(client, msg_id) + except Exception: + logger.exception("Failed to process email job %s", msg_id) + except Exception: + logger.exception("Worker loop error, retrying in 5s") + await asyncio.sleep(5) + + +if __name__ == "__main__": + asyncio.run(run_worker()) diff --git a/receiptwitness/tests/fixtures/kroger_email_receipt.html b/receiptwitness/tests/fixtures/kroger_email_receipt.html new file mode 100644 index 0000000..9cb33f8 --- /dev/null +++ b/receiptwitness/tests/fixtures/kroger_email_receipt.html @@ -0,0 +1,45 @@ + + + + + Kroger Digital Receipt + + +
+ Kroger +

Your Digital Receipt

+

Kroger Plus Member

+
+ +
+

Kroger #882 - Downtown

+

123 Main Street
Anytown, OH 45202

+

Date: 03/15/2026

+

Receipt #: KR-2026-0315-4829

+

Transaction #: TXN-789123456

+
+ +
+

Items Purchased

+

Whole Milk 1 Gallon $3.99

+

Sourdough Bread $4.49

+

Free Range Eggs 12ct $5.99

+

Baby Spinach 5oz $4.29

+
+ +
+

Subtotal: $18.76

+

Tax: $1.24

+

Total: $20.00

+
+ +
+

Kroger Plus Savings: $3.25 saved on this order.

+
+ +
+

Thank you for shopping at Kroger!

+

Keep your receipt for returns within 90 days.

+
+ + \ No newline at end of file diff --git a/receiptwitness/tests/fixtures/meijer_email_receipt.html b/receiptwitness/tests/fixtures/meijer_email_receipt.html new file mode 100644 index 0000000..f61deb3 --- /dev/null +++ b/receiptwitness/tests/fixtures/meijer_email_receipt.html @@ -0,0 +1,127 @@ + + + + + + Meijer Digital Receipt + + + +
+
+

MEIJER

+

Digital Receipt

+
+ +
+

Meijer Store #42

+

1555 Lake Drive SE, Grand Rapids, MI 49506

+
+ +
+
+ Date: March 15, 2026
+ Time: 2:34 PM +
+
+ Transaction #
+ TXN-2026-0315-0042 +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ItemQtyPrice
ORGANIC BANANAS1$0.69
WHOLE MILK 1 GAL1$4.29
MEIJER WHOLE GRAIN OAT CEREAL 18OZ1$4.99
FRESH BROCCOLI CROWN1$2.49
GROUND BEEF 85/15 1LB1$6.99
SOURDOUGH BREAD1$3.99
MEIJER BABY SPINACH 5OZ1$4.49
LARGE EGGS DOZEN1$3.29
+ +
+
+ Subtotal + $31.22 +
+
+ Tax + $2.19 +
+
+ Total Savings + -$3.40 +
+
+ Total + $33.41 +
+
+ + +
+ + diff --git a/receiptwitness/tests/fixtures/target_email_receipt.html b/receiptwitness/tests/fixtures/target_email_receipt.html new file mode 100644 index 0000000..70f0720 --- /dev/null +++ b/receiptwitness/tests/fixtures/target_email_receipt.html @@ -0,0 +1,44 @@ + + + + + Target Order Confirmation + + +
+ Target +

Order Confirmation

+

Thanks for shopping Target Circle!

+
+ +
+

Target Store #1247 - Riverside

+

4500 River Road
Columbus, OH 43220

+

Date: 03/18/2026

+

Order #: TGT-2026-0318-9124

+

Confirmation #: CNF-44772819

+
+ +
+

Items Purchased

+

Good & Gather Whole Milk 1 Gal $3.89

+

Arborio Rice 2lb bag $6.49

+

Parmesan Wedge 8oz $7.99

+
+ +
+

Subtotal: $18.37

+

Tax: $1.45

+

Total: $19.82

+
+ +
+

Target Circle offer saved you $0.30 on this order.

+
+ +
+

Questions? Call Target Guest Services at 1-800-591-3869.

+

Receipt valid for returns within 30 days.

+
+ + \ No newline at end of file diff --git a/receiptwitness/tests/test_api/__init__.py b/receiptwitness/tests/test_api/__init__.py new file mode 100644 index 0000000..598c2e0 --- /dev/null +++ b/receiptwitness/tests/test_api/__init__.py @@ -0,0 +1 @@ +"""Tests for the ReceiptWitness API routes.""" diff --git a/receiptwitness/tests/test_api/test_webhook.py b/receiptwitness/tests/test_api/test_webhook.py new file mode 100644 index 0000000..2b208de --- /dev/null +++ b/receiptwitness/tests/test_api/test_webhook.py @@ -0,0 +1,101 @@ +"""Tests for the /inbound/email webhook endpoint.""" + +import hashlib +import hmac +import time +from unittest.mock import AsyncMock, patch + +import pytest +from fastapi.testclient import TestClient + +from receiptwitness.main import app + + +@pytest.fixture +def client(): + return TestClient(app) + + +@pytest.fixture +def mock_redis(): + redis_mock = AsyncMock() + with patch("receiptwitness.api.routes.get_redis", return_value=redis_mock): + enqueue_patcher = patch("receiptwitness.api.routes.enqueue_email", new_callable=AsyncMock) + with enqueue_patcher as mock_enqueue: + yield {"redis": redis_mock, "enqueue": mock_enqueue} + + +def make_signature(signing_key: str, token: str, timestamp: str) -> str: + return hmac.new( + signing_key.encode(), + f"{timestamp}{token}".encode(), + hashlib.sha256, + ).hexdigest() + + +def valid_form(signing_key: str = "test-secret"): + ts = str(int(time.time())) + token = "test-token" + sig = make_signature(signing_key, token, ts) + return { + "token": token, + "timestamp": ts, + "signature": sig, + "sender": "sender@example.com", + "recipient": "receipts+user123@example.com", + "subject": "Your Meijer Receipt", + "body-html": "

Thank you for shopping at Meijer

", + "body-plain": "Thank you for shopping at Meijer", + "Message-Id": "", + } + + +def test_valid_webhook(client, mock_redis): + with patch("receiptwitness.api.routes.settings") as mock_settings: + mock_settings.mailgun_webhook_signing_key = "test-secret" + response = client.post("/inbound/email", data=valid_form()) + assert response.status_code == 200 + assert response.json() == {"status": "queued"} + mock_redis["enqueue"].assert_awaited_once() + + +def test_invalid_signature(client, mock_redis): + with patch("receiptwitness.api.routes.settings") as mock_settings: + mock_settings.mailgun_webhook_signing_key = "test-secret" + form = valid_form() + form["signature"] = "wrong-signature" + response = client.post("/inbound/email", data=form) + assert response.status_code == 406 + assert response.json()["detail"] == "Invalid signature" + mock_redis["enqueue"].assert_not_awaited() + + +def test_invalid_recipient_no_plus(client, mock_redis): + with patch("receiptwitness.api.routes.settings") as mock_settings: + mock_settings.mailgun_webhook_signing_key = "test-secret" + form = valid_form() + form["recipient"] = "receipts@example.com" # no plus-address + response = client.post("/inbound/email", data=form) + assert response.status_code == 406 + assert response.json()["detail"] == "Invalid recipient" + mock_redis["enqueue"].assert_not_awaited() + + +def test_stale_timestamp(client, mock_redis): + with patch("receiptwitness.api.routes.settings") as mock_settings: + mock_settings.mailgun_webhook_signing_key = "test-secret" + ts = str(int(time.time()) - 600) # 10 min old + token = "test-token" + sig = make_signature("test-secret", token, ts) + form = { + "token": token, + "timestamp": ts, + "signature": sig, + "sender": "sender@example.com", + "recipient": "receipts+user123@example.com", + "subject": "Receipt", + } + response = client.post("/inbound/email", data=form) + assert response.status_code == 406 + assert response.json()["detail"] == "Invalid signature" + mock_redis["enqueue"].assert_not_awaited() diff --git a/receiptwitness/tests/test_notifications/__init__.py b/receiptwitness/tests/test_notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/receiptwitness/tests/test_notifications/test_email.py b/receiptwitness/tests/test_notifications/test_email.py new file mode 100644 index 0000000..e8970e9 --- /dev/null +++ b/receiptwitness/tests/test_notifications/test_email.py @@ -0,0 +1,84 @@ +"""Tests for email notifications.""" + +from unittest.mock import patch + +import pytest + + +class TestSendReceiptNotification: + @pytest.fixture + def mock_resend(self): + with patch("receiptwitness.notifications.email.resend") as mock: + yield mock + + @pytest.mark.asyncio + async def test_sends_email_with_correct_params(self, mock_resend): + from receiptwitness.notifications.email import send_receipt_notification + + with ( + patch("receiptwitness.notifications.email.settings") as mock_settings, + patch( + "receiptwitness.notifications.email.asyncio.to_thread", + new=lambda fn, *args, **kwargs: fn(*args, **kwargs), + ), + ): + mock_settings.notifications_enabled = True + mock_settings.resend_api_key = "re_testkey_123" + mock_settings.notification_email_from = "noreply@test.com" + + await send_receipt_notification( + user_email="user@example.com", + store_name="Meijer", + item_count=5, + total=42.99, + purchase_date="2026-03-28", + ) + + mock_resend.Emails.send.assert_called_once_with( + { + "from": "noreply@test.com", + "to": ["user@example.com"], + "subject": "Receipt processed: Meijer - $42.99", + "html": ( + "

Your receipt from Meijer on " + "2026-03-28 has been processed.

" + "

5 items, total: $42.99

" + ), + } + ) + + @pytest.mark.asyncio + async def test_skips_when_disabled(self, mock_resend): + from receiptwitness.notifications.email import send_receipt_notification + + with patch("receiptwitness.notifications.email.settings") as mock_settings: + mock_settings.notifications_enabled = False + mock_settings.resend_api_key = "re_testkey_123" + + await send_receipt_notification( + user_email="user@example.com", + store_name="Meijer", + item_count=5, + total=42.99, + purchase_date="2026-03-28", + ) + + mock_resend.Emails.send.assert_not_called() + + @pytest.mark.asyncio + async def test_skips_when_api_key_empty(self, mock_resend): + from receiptwitness.notifications.email import send_receipt_notification + + with patch("receiptwitness.notifications.email.settings") as mock_settings: + mock_settings.notifications_enabled = True + mock_settings.resend_api_key = "" + + await send_receipt_notification( + user_email="user@example.com", + store_name="Meijer", + item_count=5, + total=42.99, + purchase_date="2026-03-28", + ) + + mock_resend.Emails.send.assert_not_called() diff --git a/receiptwitness/tests/test_parsers/test_email/__init__.py b/receiptwitness/tests/test_parsers/test_email/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/receiptwitness/tests/test_parsers/test_email/test_detector.py b/receiptwitness/tests/test_parsers/test_email/test_detector.py new file mode 100644 index 0000000..87a5aac --- /dev/null +++ b/receiptwitness/tests/test_parsers/test_email/test_detector.py @@ -0,0 +1,49 @@ +"""Tests for retailer detector.""" + +from receiptwitness.parsers.email.base import EmailReceipt +from receiptwitness.parsers.email.detector import detect_retailer + + +def test_detect_meijer(): + email = EmailReceipt( + sender="receipts@meijer.com", + recipient="user@example.com", + subject="Your Receipt", + ) + assert detect_retailer(email) == "meijer" + + +def test_detect_kroger(): + email = EmailReceipt( + sender="noreply@email.kroger.com", + recipient="user@example.com", + subject="Your Receipt", + ) + assert detect_retailer(email) == "kroger" + + +def test_detect_target(): + email = EmailReceipt( + sender="Target ", + recipient="user@example.com", + subject="Your Receipt", + ) + assert detect_retailer(email) == "target" + + +def test_detect_unknown(): + email = EmailReceipt( + sender="noreply@walmart.com", + recipient="user@example.com", + subject="Your Receipt", + ) + assert detect_retailer(email) is None + + +def test_detect_case_insensitive(): + email = EmailReceipt( + sender="Receipts@MEIJER.COM", + recipient="user@example.com", + subject="Your Receipt", + ) + assert detect_retailer(email) == "meijer" diff --git a/receiptwitness/tests/test_parsers/test_email/test_kroger_email_parser.py b/receiptwitness/tests/test_parsers/test_email/test_kroger_email_parser.py new file mode 100644 index 0000000..ab30257 --- /dev/null +++ b/receiptwitness/tests/test_parsers/test_email/test_kroger_email_parser.py @@ -0,0 +1,93 @@ +"""Tests for KrogerEmailParser.""" + +from pathlib import Path + +from receiptwitness.parsers.email.base import EmailReceipt +from receiptwitness.parsers.email.kroger import KrogerEmailParser + +FIXTURE_PATH = Path(__file__).parent.parent.parent / "fixtures" / "kroger_email_receipt.html" + + +class TestKrogerEmailParser: + """Tests for KrogerEmailParser.""" + + def setup_method(self) -> None: + self.parser = KrogerEmailParser() + self.fixture_html = FIXTURE_PATH.read_text() + + def test_can_parse_kroger_sender(self) -> None: + email = EmailReceipt( + sender="noreply@email.kroger.com", + recipient="user@example.com", + subject="Your Kroger Receipt", + body_html=self.fixture_html, + ) + assert self.parser.can_parse(email) is True + + def test_can_parse_kroger_in_body(self) -> None: + email = EmailReceipt( + sender="someone@unknown.com", + recipient="user@example.com", + subject="Your Receipt", + body_html="Kroger digital receipt", + ) + assert self.parser.can_parse(email) is True + + def test_cannot_parse_unrelated(self) -> None: + email = EmailReceipt( + sender="noreply@walmart.com", + recipient="user@example.com", + subject="Your Receipt", + body_html="Walmart receipt", + ) + assert self.parser.can_parse(email) is False + + def test_parse_items(self) -> None: + email = EmailReceipt( + sender="noreply@kroger.com", + recipient="user@example.com", + subject="Your Kroger Receipt", + body_html=self.fixture_html, + ) + result = self.parser.parse(email) + items = result.get("items", []) + assert len(items) >= 3 + product_names = [item["product_name_raw"] for item in items] + assert any("Whole Milk" in name for name in product_names) + assert any("Sourdough" in name for name in product_names) + for item in items: + assert "unit_price" in item + assert "extended_price" in item + + def test_parse_totals(self) -> None: + email = EmailReceipt( + sender="noreply@kroger.com", + recipient="user@example.com", + subject="Your Kroger Receipt", + body_html=self.fixture_html, + ) + result = self.parser.parse(email) + total = result.get("total", 0) + assert total > 0 + + def test_parse_receipt_id(self) -> None: + email = EmailReceipt( + sender="noreply@kroger.com", + recipient="user@example.com", + subject="Your Kroger Receipt", + body_html=self.fixture_html, + ) + result = self.parser.parse(email) + receipt_id = result.get("receipt_id", "") + assert "KR-2026" in receipt_id or "TXN" in receipt_id + + def test_parse_date(self) -> None: + email = EmailReceipt( + sender="noreply@kroger.com", + recipient="user@example.com", + subject="Your Kroger Receipt", + body_html=self.fixture_html, + ) + result = self.parser.parse(email) + purchase_date = result.get("purchase_date", "") + assert purchase_date == "2026-03-15" diff --git a/receiptwitness/tests/test_parsers/test_email/test_meijer_parser.py b/receiptwitness/tests/test_parsers/test_email/test_meijer_parser.py new file mode 100644 index 0000000..3c33976 --- /dev/null +++ b/receiptwitness/tests/test_parsers/test_email/test_meijer_parser.py @@ -0,0 +1,182 @@ +"""Tests for the Meijer email receipt parser.""" + +import os +from decimal import Decimal + +import pytest + +from receiptwitness.parsers.email.base import EmailReceipt +from receiptwitness.parsers.email.meijer import MeijerEmailParser + +FIXTURE_PATH = os.path.join( + os.path.dirname(__file__), "..", "..", "fixtures", "meijer_email_receipt.html" +) + + +def load_fixture() -> str: + with open(FIXTURE_PATH) as f: + return f.read() + + +@pytest.fixture +def meijer_email() -> EmailReceipt: + html = load_fixture() + return EmailReceipt( + sender="Meijer Receipts ", + recipient="shopper@example.com", + subject="Your Meijer Receipt — Transaction #TXN-2026-0315-0042", + body_html=html, + body_plain=None, + received_at="2026-03-15T14:34:00Z", + ) + + +@pytest.fixture +def kroger_email() -> EmailReceipt: + return EmailReceipt( + sender="Kroger ", + recipient="shopper@example.com", + subject="Your Kroger Receipt", + body_html="Kroger receipt", + ) + + +class TestCanParse: + def test_can_parse_meijer(self, meijer_email: EmailReceipt): + parser = MeijerEmailParser() + assert parser.can_parse(meijer_email) is True + + def test_cannot_parse_kroger(self, kroger_email: EmailReceipt): + parser = MeijerEmailParser() + assert parser.can_parse(kroger_email) is False + + def test_can_parse_meijer_plain_sender(self): + email = EmailReceipt( + sender="receipts@meijer.com", + recipient="shopper@example.com", + subject="Receipt", + body_html="", + ) + parser = MeijerEmailParser() + assert parser.can_parse(email) is True + + def test_cannot_parse_non_meijer(self): + email = EmailReceipt( + sender=" Target ", + recipient="shopper@example.com", + subject="Target Receipt", + body_html="", + ) + parser = MeijerEmailParser() + assert parser.can_parse(email) is False + + +class TestParseMeijerReceipt: + def test_receipt_id_extracted(self, meijer_email: EmailReceipt): + parser = MeijerEmailParser() + result = parser.parse(meijer_email) + assert result["receipt_id"] == "TXN-2026-0315-0042" + + def test_purchase_date_extracted(self, meijer_email: EmailReceipt): + parser = MeijerEmailParser() + result = parser.parse(meijer_email) + assert result["purchase_date"] == "2026-03-15" + + def test_items_extracted(self, meijer_email: EmailReceipt): + parser = MeijerEmailParser() + result = parser.parse(meijer_email) + items = result["items"] + assert len(items) == 8 + + names = [item["product_name_raw"] for item in items] + assert "ORGANIC BANANAS" in names + assert "WHOLE MILK 1 GAL" in names + assert "GROUND BEEF 85/15 1LB" in names + + def test_item_quantities(self, meijer_email: EmailReceipt): + parser = MeijerEmailParser() + result = parser.parse(meijer_email) + # Find ORGANIC BANANAS + bananas = next(i for i in result["items"] if "BANANAS" in i["product_name_raw"]) + assert bananas["quantity"] == Decimal("1") + + def test_item_prices(self, meijer_email: EmailReceipt): + parser = MeijerEmailParser() + result = parser.parse(meijer_email) + # Find ORGANIC BANANAS + bananas = next(i for i in result["items"] if "BANANAS" in i["product_name_raw"]) + assert bananas["unit_price"] == Decimal("0.69") + assert bananas["extended_price"] == Decimal("0.69") + + def test_totals(self, meijer_email: EmailReceipt): + parser = MeijerEmailParser() + result = parser.parse(meijer_email) + assert result["total"] == Decimal("33.41") + assert result["subtotal"] == Decimal("31.22") + assert result["tax"] == Decimal("2.19") + assert result["savings_total"] == Decimal("3.40") + + +class TestParseHandlesMissingFields: + def test_missing_body_html_falls_back_to_plain(self): + email = EmailReceipt( + sender="receipts@email.meijer.com", + recipient="shopper@example.com", + subject="Your Meijer Receipt", + body_html=None, + body_plain="TXN-1234 | March 15, 2026 | Total: $10.00", + ) + parser = MeijerEmailParser() + result = parser.parse(email) + # Should not raise, returns minimal result + assert result["receipt_id"] == "" + assert result["purchase_date"] == "2026-03-15" + assert result["total"] == Decimal("10.00") + + def test_empty_email(self): + email = EmailReceipt( + sender="receipts@email.meijer.com", + recipient="shopper@example.com", + subject="Receipt", + body_html="", + body_plain="", + ) + parser = MeijerEmailParser() + result = parser.parse(email) + assert result["receipt_id"] == "" + assert result["purchase_date"] == "" + assert result["total"] == Decimal("0") + assert result["items"] == [] + + def test_missing_subject_date_from_body(self): + html = """ + + +

Thank you for shopping on April 1, 2026

+

Total: $15.00

+ + + """ + email = EmailReceipt( + sender="receipts@email.meijer.com", + recipient="shopper@example.com", + subject=None, + body_html=html, + ) + parser = MeijerEmailParser() + result = parser.parse(email) + assert result["purchase_date"] == "2026-04-01" + + def test_missing_totals_defaults_to_zero(self): + html = "

Just an email with no totals

" + email = EmailReceipt( + sender="receipts@email.meijer.com", + recipient="shopper@example.com", + subject="Receipt", + body_html=html, + ) + parser = MeijerEmailParser() + result = parser.parse(email) + assert result["total"] == Decimal("0") + assert result["subtotal"] is None + assert result["tax"] is None diff --git a/receiptwitness/tests/test_parsers/test_email/test_target_email_parser.py b/receiptwitness/tests/test_parsers/test_email/test_target_email_parser.py new file mode 100644 index 0000000..ffa33db --- /dev/null +++ b/receiptwitness/tests/test_parsers/test_email/test_target_email_parser.py @@ -0,0 +1,93 @@ +"""Tests for TargetEmailParser.""" + +from pathlib import Path + +from receiptwitness.parsers.email.base import EmailReceipt +from receiptwitness.parsers.email.target import TargetEmailParser + +FIXTURE_PATH = Path(__file__).parent.parent.parent / "fixtures" / "target_email_receipt.html" + + +class TestTargetEmailParser: + """Tests for TargetEmailParser.""" + + def setup_method(self) -> None: + self.parser = TargetEmailParser() + self.fixture_html = FIXTURE_PATH.read_text() + + def test_can_parse_target_sender(self) -> None: + email = EmailReceipt( + sender="receipts@target.com", + recipient="user@example.com", + subject="Your Target Order Confirmation", + body_html=self.fixture_html, + ) + assert self.parser.can_parse(email) is True + + def test_can_parse_circle_in_body(self) -> None: + email = EmailReceipt( + sender="someone@unknown.com", + recipient="user@example.com", + subject="Your Receipt", + body_html="Target Circle savings offer", + ) + assert self.parser.can_parse(email) is True + + def test_cannot_parse_unrelated(self) -> None: + email = EmailReceipt( + sender="noreply@walmart.com", + recipient="user@example.com", + subject="Your Receipt", + body_html="Walmart receipt", + ) + assert self.parser.can_parse(email) is False + + def test_parse_items(self) -> None: + email = EmailReceipt( + sender="orders@target.com", + recipient="user@example.com", + subject="Your Target Order", + body_html=self.fixture_html, + ) + result = self.parser.parse(email) + items = result.get("items", []) + assert len(items) >= 3 + product_names = [item["product_name_raw"] for item in items] + assert any("Whole Milk" in name for name in product_names) + assert any("Arborio" in name for name in product_names) + for item in items: + assert "unit_price" in item + assert "extended_price" in item + + def test_parse_totals(self) -> None: + email = EmailReceipt( + sender="orders@target.com", + recipient="user@example.com", + subject="Your Target Order", + body_html=self.fixture_html, + ) + result = self.parser.parse(email) + total = result.get("total", 0) + assert total > 0 + + def test_parse_receipt_id(self) -> None: + email = EmailReceipt( + sender="orders@target.com", + recipient="user@example.com", + subject="Your Target Order", + body_html=self.fixture_html, + ) + result = self.parser.parse(email) + receipt_id = result.get("receipt_id", "") + assert "TGT-2026" in receipt_id or "CNF" in receipt_id + + def test_parse_date(self) -> None: + email = EmailReceipt( + sender="orders@target.com", + recipient="user@example.com", + subject="Your Target Order", + body_html=self.fixture_html, + ) + result = self.parser.parse(email) + purchase_date = result.get("purchase_date", "") + assert purchase_date == "2026-03-18" diff --git a/receiptwitness/tests/test_queue/__init__.py b/receiptwitness/tests/test_queue/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/receiptwitness/tests/test_queue/test_email_queue.py b/receiptwitness/tests/test_queue/test_email_queue.py new file mode 100644 index 0000000..05ffb51 --- /dev/null +++ b/receiptwitness/tests/test_queue/test_email_queue.py @@ -0,0 +1,79 @@ +"""Tests for email queue using DragonflyDB Streams.""" + +import pytest +from fakeredis import aioredis as fake_aioredis + +from receiptwitness.queue.email import ( + CONSUMER_GROUP, + STREAM_KEY, + EmailJob, + ack_email, + consume_emails, + enqueue_email, + ensure_consumer_group, +) + + +@pytest.fixture +async def fake_client(): + """Yield a fake async Redis client.""" + client = fake_aioredis.FakeRedis(decode_responses=True) + yield client + await client.aclose() + + +@pytest.fixture +def sample_job(): + """Sample EmailJob for testing.""" + return EmailJob( + user_id="user-123", + sender="no-reply@kroger.com", + recipient="user@example.com", + subject="Kroger Receipt", + body_html="Receipt", + body_plain="Receipt", + received_at="2026-04-01T12:00:00Z", + message_id="msg-abc-123", + ) + + +@pytest.mark.asyncio +async def test_enqueue_and_consume(fake_client, sample_job): + """Enqueue a job, consume it, verify fields match.""" + msg_id = await enqueue_email(fake_client, sample_job) + assert msg_id is not None + + consumed = await consume_emails(fake_client, "test-worker", count=1, block_ms=100) + assert len(consumed) == 1 + consumed_id, consumed_job = consumed[0] + assert consumed_id == msg_id + assert consumed_job.user_id == sample_job.user_id + assert consumed_job.sender == sample_job.sender + assert consumed_job.recipient == sample_job.recipient + assert consumed_job.subject == sample_job.subject + assert consumed_job.message_id == sample_job.message_id + + +@pytest.mark.asyncio +async def test_ack_removes_from_pending(fake_client, sample_job): + """After ack, message is no longer pending.""" + msg_id = await enqueue_email(fake_client, sample_job) + + # Consume the message (moves it to pending) + consumed = await consume_emails(fake_client, "test-worker", count=1, block_ms=100) + assert len(consumed) == 1 + + # Acknowledge it + await ack_email(fake_client, msg_id) + + # Check pending count for this consumer group + pending = await fake_client.xpending(STREAM_KEY, CONSUMER_GROUP) + assert pending is None or pending["pending"] == 0 + + +@pytest.mark.asyncio +async def test_ensure_consumer_group_idempotent(fake_client): + """Calling ensure_consumer_group twice does not error.""" + await ensure_consumer_group(fake_client) + # Calling again should not raise + await ensure_consumer_group(fake_client) diff --git a/receiptwitness/tests/test_worker/__init__.py b/receiptwitness/tests/test_worker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/receiptwitness/tests/test_worker/test_email_worker.py b/receiptwitness/tests/test_worker/test_email_worker.py new file mode 100644 index 0000000..bc05724 --- /dev/null +++ b/receiptwitness/tests/test_worker/test_email_worker.py @@ -0,0 +1,188 @@ +"""Tests for email_worker.""" + +from decimal import Decimal +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from fakeredis import aioredis as fake_aioredis + +from receiptwitness.parsers.email.base import EmailReceipt +from receiptwitness.queue.email import ( + EmailJob, +) +from receiptwitness.worker.email_worker import ( + process_job, + resolve_user, +) + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +async def fake_redis(): + """Fake async Redis client for queue testing.""" + client = fake_aioredis.FakeRedis(decode_responses=True) + yield client + await client.aclose() + + +@pytest.fixture +def sample_email_job(): + """Sample EmailJob matching DragonflyDB queue schema.""" + return EmailJob( + user_id="token-abc-123", + sender="no-reply@meijer.com", + recipient="user@example.com", + subject="Your Meijer Receipt", + body_html="Total: $42.00", + body_plain="Total: $42.00", + received_at="2026-04-01T12:00:00Z", + message_id="msg-xyz-789", + ) + + +@pytest.fixture +def sample_email(): + """Sample EmailReceipt for parser testing.""" + return EmailReceipt( + sender="no-reply@meijer.com", + recipient="user@example.com", + subject="Your Meijer Receipt", + body_html="Total: $42.00
Receipt #12345", + body_plain="Total: $42.00", + received_at="2026-04-01T12:00:00Z", + ) + + +# --------------------------------------------------------------------------- +# resolve_user tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_resolve_user_valid_token(): + """Valid token returns user_id string.""" + mock_session = AsyncMock() + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = "user-uuid-42" + mock_session.execute.return_value = mock_result + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + factory = MagicMock(return_value=mock_session) + + with patch( + "receiptwitness.worker.email_worker.get_async_session_factory", + return_value=factory, + ): + user_id = await resolve_user("token-abc-123") + + assert user_id == "user-uuid-42" + factory.assert_called_once() + + +@pytest.mark.asyncio +async def test_resolve_user_invalid_token(): + """Invalid token returns None.""" + mock_session = AsyncMock() + mock_result = MagicMock() + mock_result.scalar_one_or_none.return_value = None + mock_session.execute.return_value = mock_result + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + + factory = MagicMock(return_value=mock_session) + + with patch( + "receiptwitness.worker.email_worker.get_async_session_factory", + return_value=factory, + ): + user_id = await resolve_user("bad-token") + + assert user_id is None + + +# --------------------------------------------------------------------------- +# process_job tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_process_job_unknown_retailer(sample_email_job): + """Unknown retailer logs warning and returns True (ack, no retry).""" + unknown_job = EmailJob( + user_id="token-abc-123", + sender="no-reply@unknownretailer.com", + recipient="user@example.com", + subject="Receipt", + body_html="", + body_plain="", + received_at="2026-04-01T12:00:00Z", + message_id="msg-xyz-789", + ) + + with ( + patch( + "receiptwitness.worker.email_worker.resolve_user", + return_value="user-uuid-42", + ), + patch( + "receiptwitness.worker.email_worker.publish_receipt_ingested", + new_callable=AsyncMock, + ) as mock_publish, + ): + result = await process_job("msg-id-1", unknown_job) + + assert result is True + mock_publish.assert_not_called() + + +@pytest.mark.asyncio +async def test_process_job_success(sample_email_job, sample_email): + """Known retailer: full pipeline runs — parse, normalize, publish event.""" + parsed_data = { + "receipt_id": "RCP-999", + "purchase_date": "2026-04-01", + "total": Decimal("42.00"), + "items": [ + { + "product_name_raw": "ORGANIC BANANAS", + "quantity": Decimal("1"), + "unit_price": Decimal("0.69"), + "extended_price": Decimal("0.69"), + }, + ], + } + + mock_parser = MagicMock() + mock_parser.parse.return_value = parsed_data + + with ( + patch( + "receiptwitness.worker.email_worker.resolve_user", + return_value="user-uuid-42", + ), + patch.dict( + "receiptwitness.worker.email_worker.PARSERS", + {"meijer": mock_parser}, + clear=False, + ), + patch( + "receiptwitness.worker.email_worker.publish_receipt_ingested", + new_callable=AsyncMock, + ) as mock_publish, + ): + result = await process_job("msg-id-1", sample_email_job) + + assert result is True + mock_parser.parse.assert_called_once() + mock_publish.assert_called_once_with( + user_id="user-uuid-42", + store_slug="meijer", + purchase_id="RCP-999", + purchase_date="2026-04-01", + item_count=1, + total=Decimal("42.00"), + ) diff --git a/scripts/seed-dev-job.yaml b/scripts/seed-dev-job.yaml new file mode 100644 index 0000000..2d5cc86 --- /dev/null +++ b/scripts/seed-dev-job.yaml @@ -0,0 +1,61 @@ +# seed-dev-job.yaml +# K8s Job to run the CartSnitch seed runner against the dev database. +# +# Usage: +# kubectl apply -f seed-dev-job.yaml -n cartsnitch-dev +# +# To view logs: +# kubectl logs -n cartsnitch-dev job/seed-dev -f +# +# To re-run after fixing issues: +# kubectl delete -f seed-dev-job.yaml -n cartsnitch-dev && kubectl apply -f seed-dev-job.yaml -n cartsnitch-dev +# +apiVersion: batch/v1 +kind: Job +metadata: + name: seed-dev + namespace: cartsnitch-dev + labels: + app: cartsnitch + component: seed + environment: dev + annotations: + description: "Runs cartsnitch-common seed runner to populate dev database with realistic test data." +spec: + # Prevent retries — a failed seed run should be investigated, not auto-repeated. + backoffLimit: 0 + # Do not run concurrently; sequential runs are safer for truncate+reseed. + concurrencyPolicy: Forbid + template: + metadata: + labels: + app: cartsnitch + component: seed + environment: dev + spec: + restartPolicy: Never + containers: + - name: seed + # Use slim Python image with the cartsnitch-common package installed from git. + # The common repo is public; no additional secret is needed for the pip install. + image: python:3.12-slim + command: + - sh + - -c + - | + pip install --no-cache-dir "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@main" && \ + python -m cartsnitch_common.seed --database-url "$${DATABASE_URL}" + env: + - name: DATABASE_URL + valueFrom: + secretKeyRef: + name: cartsnitch-secrets + key: database-url-pg + optional: false + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi diff --git a/scripts/seed-dev.sh b/scripts/seed-dev.sh new file mode 100755 index 0000000..a478015 --- /dev/null +++ b/scripts/seed-dev.sh @@ -0,0 +1,104 @@ +#!/usr/bin/env bash +# ============================================================================= +# seed-dev.sh — Run the CartSnitch seed runner against the dev database. +# +# Usage: +# ./seed-dev.sh Run full seed against dev +# ./seed-dev.sh --dry-run Show planned record counts without writing +# ./seed-dev.sh --help Show this help +# +# Prerequisites: +# - kubectl configured for the cartsnitch-dev cluster +# - Namespace cartsnitch-dev exists (CNPG Postgres must be running) +# +# What it does: +# 1. Starts a background port-forward to cartsnitch-pg-rw:5432 +# 2. Waits for the tunnel to be ready +# 3. Runs python -m cartsnitch_common.seed with --database-url pointing +# to localhost:/cartsnitch +# 4. Cleans up the port-forward on exit (normal, interrupt, or error) +# ============================================================================= + +set -euo pipefail + +# --- Config ------------------------------------------------------------------- +readonly NAMESPACE="cartsnitch-dev" +readonly SVC_NAME="cartsnitch-pg-rw" +readonly LOCAL_PORT="5433" # use a non-privileged port to avoid conflicts +readonly DB_NAME="cartsnitch" +readonly PG_USER="cartsnitch" +# Retrieve password from the CNPG credentials secret +readonly PG_PASSWORD="$( + kubectl get secret cartsnitch-pg-credentials \ + -n "$NAMESPACE" \ + -o jsonpath='{.data.password}' \ + | base64 -d +)" +readonly DB_URL="postgresql://${PG_USER}:${PG_PASSWORD}@localhost:${LOCAL_PORT}/${DB_NAME}" + +# --- Helpers ------------------------------------------------------------------ +log() { echo "[seed-dev] $*"; } +fail() { log "ERROR: $*" >&2; exit 1; } + +# Cleanup port-forward and exit. +cleanup() { + if [[ -n "${PF_PID:-}" ]]; then + log "Stopping port-forward (PID $PF_PID)..." + kill "$PF_PID" 2>/dev/null || true + wait "$PF_PID" 2>/dev/null || true + fi +} +trap cleanup EXIT + +# --- Args --------------------------------------------------------------------- +DRY_RUN="" +HELP_FLAG="" + +while [[ $# -gt 0 ]]; do + case "$1" in + --dry-run) DRY_RUN="--dry-run"; shift ;; + --help) HELP_FLAG="1"; shift ;; + *) fail "Unknown argument: $1";; + esac +done + +if [[ -n "$HELP_FLAG" ]]; then + sed -n '3,/^# ---/p' "$0" | head -n -1 | sed 's/^# //' + echo "" + echo "Additional arguments are passed through to the seed runner." + echo "Common seed-runner options:" + echo " --dry-run Show planned record counts without writing" + echo " --seed N Set random seed (default: 42)" + exit 0 +fi + +# --- Prerequisites ------------------------------------------------------------ +if ! command -v kubectl &>/dev/null; then + fail "kubectl not found — must be installed and configured." +fi + +# --- Port-forward ------------------------------------------------------------- +log "Starting port-forward ${SVC_NAME}:5432 -> localhost:${LOCAL_PORT} ..." +kubectl port-forward \ + -n "$NAMESPACE" \ + svc/"$SVC_NAME" \ + "${LOCAL_PORT}:5432" \ + &>/dev/null & +PF_PID=$! + +# Give the tunnel a moment to establish +sleep 2 + +# Verify the tunnel is up +if ! kill -0 "$PF_PID" 2>/dev/null; then + fail "Port-forward failed to start." +fi +log "Port-forward active (PID $PF_PID) on localhost:${LOCAL_PORT}" + +# --- Seed -------------------------------------------------------------------- +log "Running seed against dev database..." +set -x +python -m cartsnitch_common.seed --database-url "$DB_URL" $DRY_RUN +set +x + +log "Done." diff --git a/src/App.test.tsx b/src/App.test.tsx index 27e040d..4eeddd3 100644 --- a/src/App.test.tsx +++ b/src/App.test.tsx @@ -1,23 +1,17 @@ -import { render, screen } from '@testing-library/react' -import { describe, it, expect, vi } from 'vitest' -import App from './App.tsx' - -vi.mock('./lib/auth-client.ts', () => ({ - authClient: { - useSession: () => ({ data: null, isPending: false }), - }, -})) - -describe('App', () => { - it('renders the dashboard on the root route', () => { - render() - expect(screen.getByText('CartSnitch')).toBeInTheDocument() - }) - - it('renders the bottom navigation', () => { - render() - expect(screen.getByText('Home')).toBeInTheDocument() - expect(screen.getByText('Purchases')).toBeInTheDocument() - expect(screen.getByText('Products')).toBeInTheDocument() - }) -}) +import { render, screen } from '@testing-library/react' +import { describe, it, expect, vi } from 'vitest' +import App from './App.tsx' + +vi.mock('./lib/auth-client.ts', () => ({ + authClient: { + useSession: () => ({ data: null, isPending: false }), + }, +})) + +describe('App', () => { + it('redirects unauthenticated users to login', () => { + render() + expect(screen.getByText('CartSnitch')).toBeInTheDocument() + expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument() + }) +}) diff --git a/src/App.tsx b/src/App.tsx index bfd515a..ee4c2dc 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -31,8 +31,8 @@ export default function App() { }> - } /> }> + } /> } /> } /> } /> diff --git a/src/components/ProtectedRoute.tsx b/src/components/ProtectedRoute.tsx index 6c3df87..cf92831 100644 --- a/src/components/ProtectedRoute.tsx +++ b/src/components/ProtectedRoute.tsx @@ -4,12 +4,22 @@ import { authClient } from '../lib/auth-client.ts' import { useAuthStore } from '../stores/auth.ts' export function ProtectedRoute() { + const isMockAuth = import.meta.env.VITE_MOCK_AUTH === 'true' const { data: session, isPending } = authClient.useSession() + const isAuthenticated = useAuthStore((s) => s.isAuthenticated) const setAuthenticated = useAuthStore((s) => s.setAuthenticated) useEffect(() => { - setAuthenticated(!!session) - }, [session, setAuthenticated]) + if (!isMockAuth) { + setAuthenticated(!!session) + } + }, [session, setAuthenticated, isMockAuth]) + + // In mock auth mode, rely on Zustand store (set by Login/Register pages) + if (isMockAuth) { + if (!isAuthenticated) return + return + } if (isPending) { return ( diff --git a/src/hooks/__tests__/useApi.test.tsx b/src/hooks/__tests__/useApi.test.tsx new file mode 100644 index 0000000..6644d12 --- /dev/null +++ b/src/hooks/__tests__/useApi.test.tsx @@ -0,0 +1,45 @@ +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { usePurchases } from '../useApi' +import { http, HttpResponse } from 'msw' +import { server } from '../../test/mocks/server' + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }) + return function Wrapper({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) + } +} + +describe('useApi hooks', () => { + describe('usePurchases', () => { + it('fetches and returns purchases', async () => { + const { result } = renderHook(() => usePurchases(), { wrapper: createWrapper() }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data).toHaveLength(1) + expect(result.current.data![0]).toMatchObject({ + id: 'pur_1', + storeName: 'Kroger', + total: 42.5, + }) + }) + + it('returns an error when the endpoint fails', async () => { + server.use( + http.get('/api/v1/purchases', () => HttpResponse.error()), + ) + + const { result } = renderHook(() => usePurchases(), { wrapper: createWrapper() }) + + await waitFor(() => expect(result.current.isError).toBe(true)) + }) + }) +}) diff --git a/src/hooks/useApi.ts b/src/hooks/useApi.ts index 235b5f6..cccd77d 100644 --- a/src/hooks/useApi.ts +++ b/src/hooks/useApi.ts @@ -35,7 +35,7 @@ export function useProduct(id: string) { export function usePriceHistory(productId: string) { return useQuery({ queryKey: ['priceHistory', productId], - queryFn: () => api.get(`/products/${productId}/price-history`), + queryFn: () => api.get(`/products/${productId}/prices`), enabled: !!productId, }) } @@ -50,6 +50,6 @@ export function useCoupons() { export function usePriceAlerts() { return useQuery({ queryKey: ['priceAlerts'], - queryFn: () => api.get('/price-alerts'), + queryFn: () => api.get('/alerts'), }) } diff --git a/src/lib/api.ts b/src/lib/api.ts index 3907dde..1c2b0f8 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -15,7 +15,7 @@ const mockRoutes: Record unknown> = { '/purchases': () => mockPurchases, '/products': () => mockProducts, '/coupons': () => mockCoupons, - '/price-alerts': () => mockAlerts, + '/alerts': () => mockAlerts, } function matchMockRoute(path: string): T | null { @@ -30,7 +30,7 @@ function matchMockRoute(path: string): T | null { } // /products/:id/price-history - const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/) + const priceHistoryMatch = path.match(/^\/products\/(.+)\/prices$/) if (priceHistoryMatch) { return getMockPriceHistory(priceHistoryMatch[1]) as T } diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index a6fe18f..70d7a61 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -1,8 +1,36 @@ 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 ?? "http://localhost:3001", + baseURL: import.meta.env.VITE_AUTH_URL || "", basePath: "/auth", + fetchPlugins: [displayNameMapper], }) export const { useSession, signIn, signUp, signOut } = authClient diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index d1e885f..c3f428a 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,13 +1,8 @@ -import React, { Suspense } from 'react' import { Link } from 'react-router-dom' import { authClient } from '../lib/auth-client.ts' -import { usePurchases, usePriceAlerts, usePriceHistory } from '../hooks/useApi.ts' +import { usePurchases, usePriceAlerts } from '../hooks/useApi.ts' import { StoreIcon } from '../components/StoreIcon.tsx' -const LazySparklineCard = React.lazy(() => - import('../components/SparklineChart.tsx').then((mod) => ({ default: mod.SparklineCard })) -) - export function Dashboard() { const { data: session, isPending } = authClient.useSession() @@ -44,19 +39,11 @@ export function Dashboard() { function AuthenticatedDashboard({ userName }: { userName: string }) { const { data: purchases = [], isLoading: purchasesLoading } = usePurchases() const { data: alerts = [], isLoading: alertsLoading } = usePriceAlerts() - const { data: eggHistory = [] } = usePriceHistory('prod10') - const { data: milkHistory = [] } = usePriceHistory('prod1') const triggeredAlerts = alerts.filter((a) => a.triggered) const watchingAlerts = alerts.filter((a) => !a.triggered) const recentPurchases = purchases.slice(0, 3) - const sparklineData = eggHistory.filter((p) => p.storeId === 'meijer').slice(-8) - const milkSparkline = milkHistory.filter((p) => p.storeId === 'kroger').slice(-8) - - const eggCurrent = sparklineData.length > 0 ? `$${sparklineData[sparklineData.length - 1].price.toFixed(2)}` : '—' - const milkCurrent = milkSparkline.length > 0 ? `$${milkSparkline[milkSparkline.length - 1].price.toFixed(2)}` : '—' - if (purchasesLoading || alertsLoading) { return } @@ -106,11 +93,8 @@ function AuthenticatedDashboard({ userName }: { userName: string }) { {/* Price trend sparklines */}

Price Trends

-
- }> - - - +
+ Connect a store to see price trends
@@ -173,6 +157,7 @@ function AuthenticatedDashboard({ userName }: { userName: string }) { function DashboardSkeleton() { return (
+

Loading CartSnitch…

@@ -186,15 +171,3 @@ function DashboardSkeleton() {
) } - -function SparklinePlaceholder() { - return ( -
-
-
-
-
-
-
- ) -} diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx index 214dcd4..ae7fc0c 100644 --- a/src/pages/Login.tsx +++ b/src/pages/Login.tsx @@ -31,8 +31,14 @@ export function Login() { throw new Error(authError.message ?? 'Sign in failed') } - setAuthenticated(true) - navigate('/') + // After successful signIn, force a session fetch to confirm the cookie is set + // before navigating to the protected route + const sessionResult = await authClient.getSession() + if (sessionResult.data) { + navigate('/') + } else { + setError('Sign in failed. Please try again.') + } } catch { if (import.meta.env.VITE_MOCK_AUTH === 'true') { setAuthenticated(true) @@ -46,7 +52,7 @@ export function Login() { } return ( -
+

CartSnitch

Track prices. Save money.

@@ -88,10 +94,10 @@ export function Login() {

Don't have an account?{' '} - + Sign up

-
+ ) } diff --git a/src/pages/Register.tsx b/src/pages/Register.tsx index a65e7b6..c75e2d6 100644 --- a/src/pages/Register.tsx +++ b/src/pages/Register.tsx @@ -38,8 +38,15 @@ export function Register() { throw new Error(authError.message ?? 'Registration failed') } - setAuthenticated(true) - navigate('/') + // After successful signUp, force a session fetch to confirm the cookie is set + // before navigating to the protected route + const sessionResult = await authClient.getSession() + if (sessionResult.data) { + navigate('/') + } else { + // Session not established — show success message and link to login + setError('Account created! Please sign in.') + } } catch { if (import.meta.env.VITE_MOCK_AUTH === 'true') { setAuthenticated(true) diff --git a/src/test/mocks/handlers.ts b/src/test/mocks/handlers.ts new file mode 100644 index 0000000..4282004 --- /dev/null +++ b/src/test/mocks/handlers.ts @@ -0,0 +1,65 @@ +import { http, HttpResponse } from 'msw' +import type { Purchase, Product, Coupon, PriceAlert } from '../../types/api.ts' + +const mockPurchases: Purchase[] = [ + { + id: 'pur_1', + storeId: 'store_1', + storeName: 'Kroger', + date: '2024-01-15', + total: 42.5, + items: [ + { id: 'item_1', productId: 'prod_1', name: 'Milk', quantity: 1, price: 3.99, unitPrice: 3.99 }, + { id: 'item_2', productId: 'prod_2', name: 'Bread', quantity: 2, price: 5.98, unitPrice: 2.99 }, + ], + }, +] + +const mockProducts: Product[] = [ + { + id: 'prod_1', + name: 'Whole Milk', + brand: 'Kroger', + category: 'Dairy', + prices: [{ storeId: 'store_1', storeName: 'Kroger', price: 3.99, lastUpdated: '2024-01-15' }], + }, + { + id: 'prod_2', + name: 'Whole Wheat Bread', + brand: 'Nature\'s Own', + category: 'Bakery', + prices: [{ storeId: 'store_1', storeName: 'Kroger', price: 2.99, lastUpdated: '2024-01-15' }], + }, +] + +const mockCoupons: Coupon[] = [ + { + id: 'coupon_1', + productId: 'prod_1', + storeName: 'Kroger', + description: '$1 off milk', + discount: '$1.00', + expiresAt: '2024-12-31', + code: 'MILK1', + }, +] + +const mockAlerts: PriceAlert[] = [ + { + id: 'alert_1', + productId: 'prod_1', + productName: 'Whole Milk', + targetPrice: 2.99, + currentPrice: 3.99, + triggered: false, + }, +] + +export const handlers = [ + http.get('/api/v1/health', () => HttpResponse.json({ status: 'ok' })), + http.get('/api/v1/purchases', () => HttpResponse.json(mockPurchases)), + http.get('/api/v1/products', () => HttpResponse.json(mockProducts)), + http.get('/api/v1/products/prod_1', () => HttpResponse.json(mockProducts[0])), + http.get('/api/v1/coupons', () => HttpResponse.json(mockCoupons)), + http.get('/api/v1/alerts', () => HttpResponse.json(mockAlerts)), +] diff --git a/src/test/mocks/server.ts b/src/test/mocks/server.ts new file mode 100644 index 0000000..86f7d61 --- /dev/null +++ b/src/test/mocks/server.ts @@ -0,0 +1,4 @@ +import { setupServer } from 'msw/node' +import { handlers } from './handlers' + +export const server = setupServer(...handlers) diff --git a/src/test/setup.ts b/src/test/setup.ts index d0618e8..c6d0755 100644 --- a/src/test/setup.ts +++ b/src/test/setup.ts @@ -1 +1,6 @@ import '@testing-library/jest-dom/vitest' +import { server } from './mocks/server' + +beforeAll(() => server.listen()) +afterEach(() => server.resetHandlers()) +afterAll(() => server.close()) diff --git a/src/utils/__tests__/formatCurrency.test.ts b/src/utils/__tests__/formatCurrency.test.ts new file mode 100644 index 0000000..cb7cbfa --- /dev/null +++ b/src/utils/__tests__/formatCurrency.test.ts @@ -0,0 +1,33 @@ +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'); + }); +}); diff --git a/src/utils/__tests__/formatDate.test.ts b/src/utils/__tests__/formatDate.test.ts new file mode 100644 index 0000000..8e291d4 --- /dev/null +++ b/src/utils/__tests__/formatDate.test.ts @@ -0,0 +1,62 @@ +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'); + }); + }); +}); diff --git a/src/utils/__tests__/storeSlugs.test.ts b/src/utils/__tests__/storeSlugs.test.ts new file mode 100644 index 0000000..9a06429 --- /dev/null +++ b/src/utils/__tests__/storeSlugs.test.ts @@ -0,0 +1,46 @@ +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'); + }); + }); +}); diff --git a/src/utils/formatCurrency.ts b/src/utils/formatCurrency.ts new file mode 100644 index 0000000..22d63db --- /dev/null +++ b/src/utils/formatCurrency.ts @@ -0,0 +1,10 @@ +export function formatCurrency( + cents: number, + locale = 'en-US', + currency = 'USD' +): string { + return new Intl.NumberFormat(locale, { + style: 'currency', + currency, + }).format(cents / 100); +} diff --git a/src/utils/formatDate.ts b/src/utils/formatDate.ts new file mode 100644 index 0000000..f726cee --- /dev/null +++ b/src/utils/formatDate.ts @@ -0,0 +1,34 @@ +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`; +} diff --git a/src/utils/storeSlugs.ts b/src/utils/storeSlugs.ts new file mode 100644 index 0000000..4fef82f --- /dev/null +++ b/src/utils/storeSlugs.ts @@ -0,0 +1,13 @@ +export const STORE_SLUGS: Record = { + 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; +} diff --git a/vitest.config.ts b/vitest.config.ts index 1dec76f..30c96d0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,5 +7,6 @@ export default defineConfig({ environment: 'jsdom', globals: true, setupFiles: ['./src/test/setup.ts'], + exclude: ['e2e/**', 'node_modules/**'], }, })