CartSnitch
Track prices. Save money.
@@ -88,10 +94,10 @@ export function Login() {Don't have an account?{' '} - + Sign up
-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
+ Kroger Plus Member
+123 Main Street
Anytown, OH 45202
Date: 03/15/2026
+Receipt #: KR-2026-0315-4829
+Transaction #: TXN-789123456
+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.
+Digital Receipt
+1555 Lake Drive SE, Grand Rapids, MI 49506
+| Item | +Qty | +Price | +
|---|---|---|
| ORGANIC BANANAS | +1 | +$0.69 | +
| WHOLE MILK 1 GAL | +1 | +$4.29 | +
| MEIJER WHOLE GRAIN OAT CEREAL 18OZ | +1 | +$4.99 | +
| FRESH BROCCOLI CROWN | +1 | +$2.49 | +
| GROUND BEEF 85/15 1LB | +1 | +$6.99 | +
| SOURDOUGH BREAD | +1 | +$3.99 | +
| MEIJER BABY SPINACH 5OZ | +1 | +$4.49 | +
| LARGE EGGS DOZEN | +1 | +$3.29 | +
+ Thanks for shopping Target Circle!
+4500 River Road
Columbus, OH 43220
Date: 03/18/2026
+Order #: TGT-2026-0318-9124
+Confirmation #: CNF-44772819
+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.
+Thank you for shopping at Meijer
", + "body-plain": "Thank you for shopping at Meijer", + "Message-Id": "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="TargetThank 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.00Track prices. Save money.
@@ -88,10 +94,10 @@ export function Login() {Don't have an account?{' '} - + Sign up
-