Compare commits

..

11 Commits

Author SHA1 Message Date
cartsnitch-ci[bot] d41e8159d0 fix(api): use repo-root-relative paths in Dockerfile for context: .
The CI build-and-push-api job uses context: . (repo root), so all
COPY paths must be relative to the repo root, not the api/ directory.

Also installs ./common/ alongside the api package, matching the
receiptwitness Dockerfile pattern.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 00:46:59 +00:00
Barcode Betty f23e2786e8 feat(ci): add build-and-push-api job for ghcr.io/cartsnitch/api
- Add API_IMAGE_NAME env var
- Add build-and-push-api job: context=. file=./api/Dockerfile
- Add to deploy-dev needs; update kustomize image edit and commit message

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 00:46:59 +00:00
Barcode Betty ae3592b22f fix: update stale package-lock.json to resolve npm ci failure
package.json references packages (better-auth@1.5.6, etc.) not present
in the lock file, causing npm ci to fail on CI. Regenerate the lock file
so CI can install dependencies correctly.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 00:46:59 +00:00
Barcode Betty f9d0bd5b79 feat: add MSW for integration test mocking
Install Mock Service Worker (MSW) and configure it for vitest.
Write one integration test for usePurchases hook using MSW.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 00:46:59 +00:00
Barcode Betty a8fd9c0c3f chore: remove polyrepo CI workflow leftovers
Delete nested .github/workflows/ci.yml files from api/ and receiptwitness/
directories. These workflows were from the polyrepo era and reference the
deleted cartsnitch/common repo. They do not execute as GitHub Actions (not
at repo root) and are confusing.

No functional change — the monorepo CI is defined at .github/workflows/.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 00:46:59 +00:00
Barcode Betty 97a3ec6de6 feat(ci): add receiptwitness build job to monorepo CI 2026-03-31 00:46:59 +00:00
Barcode Betty 0dd0c49f4d feat(ci): add receiptwitness build job to monorepo CI 2026-03-31 00:46:59 +00:00
cartsnitch-engineer[bot] 2ccdef5c90 docs: add UAT runbook v1
Merges docs/uat-runbook into main. UAT Runbook v1 authored by Savannah Savings (CTO). QA and CTO approved.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 00:46:59 +00:00
Barcode Betty 04f0a34908 feat: add utility functions with unit tests
Add formatCurrency, formatDate, and storeSlugs utilities in src/utils/
with 21 vitest unit tests covering standard and edge cases.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 00:46:59 +00:00
Barcode Betty 2b50aad682 fix(deploy): include alembic in API Docker image
Adds alembic.ini and alembic/ directory to the production API image so
alembic upgrade head can run in-cluster as an init container.

Also carries migration 003 (make hashed_password nullable) from PR #66.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 00:46:59 +00:00
Barcode Betty 2c64ab9e98 fix: use same-origin default for auth URL instead of localhost
Avoids ERR_CONNECTION_REFUSED in deployed environments where
VITE_AUTH_URL is not set at build time. Empty-string fallback
routes auth requests to same origin, which the HTTPRoute forwards
to the auth service.

cc @cpfarhood

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-03-31 00:46:59 +00:00
26 changed files with 148 additions and 630 deletions
+4 -101
View File
@@ -48,59 +48,9 @@ jobs:
- name: Run tests - name: Run tests
run: npx vitest run 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: build-and-push:
runs-on: runners-cartsnitch runs-on: runners-cartsnitch
if: github.event_name == 'push' && github.ref == 'refs/heads/main' needs: [lint, test]
needs: [lint, test, e2e]
outputs: outputs:
calver_tag: ${{ steps.calver.outputs.version }} calver_tag: ${{ steps.calver.outputs.version }}
steps: steps:
@@ -125,13 +75,6 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT" echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "CalVer tag: $VERSION" 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 - name: Log in to GHCR
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -169,8 +112,7 @@ jobs:
build-and-push-auth: build-and-push-auth:
runs-on: runners-cartsnitch runs-on: runners-cartsnitch
if: github.event_name == 'push' && github.ref == 'refs/heads/main' needs: [lint, test]
needs: [lint, test, e2e]
outputs: outputs:
calver_tag: ${{ steps.calver.outputs.version }} calver_tag: ${{ steps.calver.outputs.version }}
steps: steps:
@@ -194,13 +136,6 @@ jobs:
fi fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT" 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 - name: Log in to GHCR
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -230,7 +165,6 @@ jobs:
build-and-push-receiptwitness: build-and-push-receiptwitness:
runs-on: runners-cartsnitch runs-on: runners-cartsnitch
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [lint, test] needs: [lint, test]
outputs: outputs:
calver_tag: ${{ steps.calver.outputs.version }} calver_tag: ${{ steps.calver.outputs.version }}
@@ -250,13 +184,6 @@ jobs:
else BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//"); VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"; fi else BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//"); VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"; fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT" 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 - name: Log in to GHCR
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -286,7 +213,6 @@ jobs:
build-and-push-api: build-and-push-api:
runs-on: runners-cartsnitch runs-on: runners-cartsnitch
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs: [lint, test] needs: [lint, test]
outputs: outputs:
calver_tag: ${{ steps.calver.outputs.version }} calver_tag: ${{ steps.calver.outputs.version }}
@@ -306,13 +232,6 @@ jobs:
else BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//"); VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"; fi else BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//"); VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"; fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT" 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 - name: Log in to GHCR
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3 uses: docker/login-action@v3
@@ -343,7 +262,7 @@ jobs:
deploy-dev: deploy-dev:
runs-on: runners-cartsnitch runs-on: runners-cartsnitch
needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api] 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' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps: steps:
- name: Generate GitHub App token - name: Generate GitHub App token
id: app-token id: app-token
@@ -368,28 +287,12 @@ jobs:
- name: Install kustomize - name: Install kustomize
uses: imranismail/setup-kustomize@v2 uses: imranismail/setup-kustomize@v2
- name: Update frontend image tag - name: Update dev overlay image tags
if: needs.build-and-push.result == 'success'
run: | run: |
cd infra/apps/overlays/dev cd infra/apps/overlays/dev
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }} kustomize edit set image ghcr.io/cartsnitch/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 }} 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 }} 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 }} kustomize edit set image ghcr.io/cartsnitch/api:${{ needs.build-and-push-api.outputs.calver_tag }}
- name: Commit and push to infra - name: Commit and push to infra
+4 -7
View File
@@ -1,5 +1,3 @@
# Stage 1: Build dependencies
# Build context is the repo root. Paths below are relative to the root.
FROM python:3.12-slim AS build FROM python:3.12-slim AS build
RUN apt-get update && apt-get install -y --no-install-recommends \ RUN apt-get update && apt-get install -y --no-install-recommends \
@@ -7,16 +5,15 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \ build-essential \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Build context is the repo root. These paths are relative to the root.
WORKDIR /app WORKDIR /app
COPY api/pyproject.toml ./ COPY api/pyproject.toml ./
COPY api/src/ ./src/ COPY api/src/ ./src/
RUN pip install --no-cache-dir --prefix=/install . COPY common/ ./common/
RUN pip install --no-cache-dir --prefix=/install ./common/ .
# Stage 2: Production image
FROM python:3.12-slim AS prod FROM python:3.12-slim AS prod
RUN apt-get update && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
WORKDIR /app WORKDIR /app
RUN adduser --system --group --uid 1000 app RUN adduser --system --group --uid 1000 app
COPY --from=build /install /usr/local COPY --from=build /install /usr/local
@@ -30,4 +27,4 @@ EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s \ HEALTHCHECK --interval=30s --timeout=3s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')" CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn cartsnitch_api.main:app --host 0.0.0.0 --port 8000"] CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
@@ -1,122 +0,0 @@
"""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"
)
)
+1 -5
View File
@@ -5,7 +5,6 @@ Sessions are verified by querying the shared sessions table directly.
""" """
from datetime import UTC, datetime from datetime import UTC, datetime
from hashlib import sha256
from uuid import UUID from uuid import UUID
from fastapi import Cookie, Depends, Header, HTTPException, Request, status from fastapi import Cookie, Depends, Header, HTTPException, Request, status
@@ -28,13 +27,10 @@ async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
"""Validate a Better-Auth session token against the sessions table. """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 UUID) if the session is valid and not expired.
Better-Auth v1.5.6+ stores tokens as SHA-256 hashes, so we hash the
incoming raw token before querying.
""" """
hashed_token = sha256(token.encode("utf-8")).hexdigest()
result = await db.execute( result = await db.execute(
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"), text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
{"token": hashed_token}, {"token": token},
) )
row = result.first() row = result.first()
+10 -14
View File
@@ -2,7 +2,7 @@
from contextlib import asynccontextmanager from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI from fastapi import FastAPI
from cartsnitch_api.auth.routes import router as auth_router from cartsnitch_api.auth.routes import router as auth_router
from cartsnitch_api.middleware.cors import add_cors_middleware from cartsnitch_api.middleware.cors import add_cors_middleware
@@ -46,19 +46,15 @@ def create_app() -> FastAPI:
# Routers # Routers
app.include_router(health_router) app.include_router(health_router)
app.include_router(auth_router) app.include_router(auth_router)
app.include_router(stores_router)
# Data endpoints mounted under /api/v1 app.include_router(purchases_router)
v1_router = APIRouter(prefix="/api/v1") app.include_router(products_router)
v1_router.include_router(stores_router) app.include_router(prices_router)
v1_router.include_router(purchases_router) app.include_router(coupons_router)
v1_router.include_router(products_router) app.include_router(shopping_router)
v1_router.include_router(prices_router) app.include_router(alerts_router)
v1_router.include_router(coupons_router) app.include_router(scraping_router)
v1_router.include_router(shopping_router) app.include_router(public_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 return app
+1 -1
View File
@@ -32,7 +32,7 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "purchases" __tablename__ = "purchases"
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False) store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id")) store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id"))
receipt_id: Mapped[str] = mapped_column(String(200), nullable=False) receipt_id: Mapped[str] = mapped_column(String(200), nullable=False)
+3 -4
View File
@@ -4,7 +4,7 @@ import uuid
from datetime import datetime from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import DateTime, ForeignKey, String, Text, UniqueConstraint from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import AccountStatus from cartsnitch_api.constants import AccountStatus
@@ -16,12 +16,11 @@ if TYPE_CHECKING:
from cartsnitch_api.models.store import Store from cartsnitch_api.models.store import Store
class User(TimestampMixin, Base): class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Application user.""" """Application user."""
__tablename__ = "users" __tablename__ = "users"
id: Mapped[str] = mapped_column(Text, primary_key=True)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True) 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] = mapped_column(String(255), nullable=False)
display_name: Mapped[str | None] = mapped_column(String(100)) display_name: Mapped[str | None] = mapped_column(String(100))
@@ -37,7 +36,7 @@ class UserStoreAccount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "user_store_accounts" __tablename__ = "user_store_accounts"
__table_args__ = (UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),) __table_args__ = (UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False) user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False) store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
session_data: Mapped[dict | None] = mapped_column(EncryptedJSON) session_data: Mapped[dict | None] = mapped_column(EncryptedJSON)
session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True)) session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
+1 -1
View File
@@ -16,7 +16,7 @@ class UpdateUserRequest(BaseModel):
class UserResponse(BaseModel): class UserResponse(BaseModel):
id: str id: UUID
email: str email: str
display_name: str display_name: str
created_at: datetime created_at: datetime
-9
View File
@@ -36,15 +36,6 @@ export const auth = betterAuth({
}, },
session: { 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 expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // refresh after 1 day updateAge: 60 * 60 * 24, // refresh after 1 day
cookieCache: { cookieCache: {
-12
View File
@@ -1,12 +0,0 @@
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";
@@ -1,56 +0,0 @@
import { test, expect } from '@playwright/test';
const uniqueEmail = () => `betty+e2e-${Date.now()}@cartsnitch.test`;
test.describe('J1: Registration and Login', () => {
test('can register a new account and lands on dashboard', async ({ page }) => {
await page.goto('/register');
await page.fill('[placeholder="Full Name"]', 'Betty Tester');
await page.fill('[placeholder="Email"]', uniqueEmail());
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
await page.click('button[type="submit"]');
// With VITE_MOCK_AUTH=true the app navigates to "/" on success
await expect(page).toHaveURL('http://localhost:5173/');
await expect(page.getByRole('heading', { name: /cart/i })).toBeVisible();
});
test('shows validation error when registration fields are empty', async ({ page }) => {
await page.goto('/register');
await page.click('button[type="submit"]');
await expect(page.locator('.bg-red-50')).toContainText('Please fill in all fields');
});
test('can navigate from register to login', async ({ page }) => {
await page.goto('/register');
await page.getByRole('link', { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
});
test('can sign in with credentials and land on dashboard', async ({ page }) => {
// Register first so we have a real account
const email = uniqueEmail();
await page.goto('/register');
await page.fill('[placeholder="Full Name"]', 'Login Betty');
await page.fill('[placeholder="Email"]', email);
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('http://localhost:5173/');
// Sign out by clearing the mock session (reload with no session)
await page.goto('/');
await page.reload();
// Now sign in
await page.goto('/login');
await page.fill('[placeholder="Email"]', email);
await page.fill('[placeholder="Password"]', 'TestPass123!');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('http://localhost:5173/');
});
});
-49
View File
@@ -1,49 +0,0 @@
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 });
});
});
-8
View File
@@ -1,8 +0,0 @@
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();
});
-24
View File
@@ -1,24 +0,0 @@
{
"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"
}
}
}
+89 -102
View File
@@ -10,7 +10,6 @@
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"better-auth": "^1.2.0", "better-auth": "^1.2.0",
"picomatch": "4.0.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^7.0.0", "react-router-dom": "^7.0.0",
@@ -18,23 +17,20 @@
"zustand": "^5.0.0" "zustand": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.10.0",
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@playwright/test": "^1.58.2",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"@types/react": "^18.3.28", "@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.5.2",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0", "globals": "^17.4.0",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"msw": "^2.12.14", "msw": "^2.12.14",
"playwright": "^1.58.2",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"typescript-eslint": "^8.56.1", "typescript-eslint": "^8.56.1",
@@ -71,19 +67,6 @@
"devOptional": true, "devOptional": true,
"license": "ISC" "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": { "node_modules/@babel/code-frame": {
"version": "7.29.0", "version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
@@ -2737,22 +2720,6 @@
"node": ">=14" "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": { "node_modules/@reduxjs/toolkit": {
"version": "2.11.2", "version": "2.11.2",
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
@@ -4508,16 +4475,6 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/babel-plugin-polyfill-corejs2": {
"version": "0.4.16", "version": "0.4.16",
"resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.16.tgz", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.16.tgz",
@@ -6078,9 +6035,9 @@
} }
}, },
"node_modules/flatted": { "node_modules/flatted": {
"version": "3.4.2", "version": "3.4.1",
"resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.1.tgz",
"integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "integrity": "sha512-IxfVbRFVlV8V/yRaGzk0UVIcsKKHMSfYw66T/u4nTwlWteQePsxe//LjudR1AMX4tZW3WFCh3Zqa/sjlqpbURQ==",
"dev": true, "dev": true,
"license": "ISC" "license": "ISC"
}, },
@@ -8203,9 +8160,10 @@
"license": "ISC" "license": "ISC"
}, },
"node_modules/picomatch": { "node_modules/picomatch": {
"version": "4.0.4", "version": "4.0.3",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"devOptional": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=12" "node": ">=12"
@@ -8214,53 +8172,6 @@
"url": "https://github.com/sponsors/jonschlinkert" "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": { "node_modules/possible-typed-array-names": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz",
@@ -8363,6 +8274,16 @@
"node": ">=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": { "node_modules/react": {
"version": "18.3.1", "version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
@@ -8769,6 +8690,27 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/safe-push-apply": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz",
@@ -8844,13 +8786,13 @@
} }
}, },
"node_modules/serialize-javascript": { "node_modules/serialize-javascript": {
"version": "7.0.5", "version": "6.0.2",
"resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz",
"integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==",
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"engines": { "dependencies": {
"node": ">=20.0.0" "randombytes": "^2.1.0"
} }
}, },
"node_modules/set-cookie-parser": { "node_modules/set-cookie-parser": {
@@ -10440,6 +10382,31 @@
"rollup": "^1.20.0 || ^2.0.0" "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": { "node_modules/workbox-build/node_modules/ajv": {
"version": "8.18.0", "version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
@@ -10457,6 +10424,13 @@
"url": "https://github.com/sponsors/epoberezkin" "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": { "node_modules/workbox-build/node_modules/json-schema-traverse": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
@@ -10474,6 +10448,19 @@
"sourcemap-codec": "^1.4.8" "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": { "node_modules/workbox-build/node_modules/pretty-bytes": {
"version": "5.6.0", "version": "5.6.0",
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz", "resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-5.6.0.tgz",
+2 -12
View File
@@ -9,13 +9,11 @@
"lint": "eslint .", "lint": "eslint .",
"preview": "vite preview", "preview": "vite preview",
"test": "NODE_ENV=test vitest run", "test": "NODE_ENV=test vitest run",
"test:watch": "NODE_ENV=test vitest", "test:watch": "NODE_ENV=test vitest"
"test:e2e": "npx playwright test"
}, },
"dependencies": { "dependencies": {
"@tanstack/react-query": "^5.0.0", "@tanstack/react-query": "^5.0.0",
"better-auth": "^1.2.0", "better-auth": "^1.2.0",
"picomatch": "4.0.4",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-router-dom": "^7.0.0", "react-router-dom": "^7.0.0",
@@ -23,33 +21,25 @@
"zustand": "^5.0.0" "zustand": "^5.0.0"
}, },
"devDependencies": { "devDependencies": {
"@axe-core/playwright": "^4.10.0",
"@eslint/js": "^9.39.4", "@eslint/js": "^9.39.4",
"@playwright/test": "^1.58.2",
"@tailwindcss/vite": "^4.0.0", "@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.2", "@testing-library/react": "^16.3.2",
"@types/node": "^24.12.0", "@types/node": "^24.12.0",
"@types/react": "^18.3.28", "@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7", "@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0", "@vitejs/plugin-react": "^4.5.2",
"eslint": "^9.39.4", "eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2", "eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0", "globals": "^17.4.0",
"jsdom": "^25.0.1", "jsdom": "^25.0.1",
"msw": "^2.12.14", "msw": "^2.12.14",
"playwright": "^1.58.2",
"tailwindcss": "^4.0.0", "tailwindcss": "^4.0.0",
"typescript": "^5.7.3", "typescript": "^5.7.3",
"typescript-eslint": "^8.56.1", "typescript-eslint": "^8.56.1",
"vite": "^6.3.5", "vite": "^6.3.5",
"vite-plugin-pwa": "^0.21.2", "vite-plugin-pwa": "^0.21.2",
"vitest": "^3.2.4" "vitest": "^3.2.4"
},
"overrides": {
"@rollup/pluginutils": "5.3.0",
"flatted": "^3.4.2",
"serialize-javascript": "7.0.5"
} }
} }
-19
View File
@@ -1,19 +0,0 @@
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',
},
});
-4
View File
@@ -1,4 +0,0 @@
User-agent: *
Allow: /
Sitemap: https://cartsnitch.com/sitemap.xml
+23 -17
View File
@@ -1,17 +1,23 @@
import { render, screen } from '@testing-library/react' import { render, screen } from '@testing-library/react'
import { describe, it, expect, vi } from 'vitest' import { describe, it, expect, vi } from 'vitest'
import App from './App.tsx' import App from './App.tsx'
vi.mock('./lib/auth-client.ts', () => ({ vi.mock('./lib/auth-client.ts', () => ({
authClient: { authClient: {
useSession: () => ({ data: null, isPending: false }), useSession: () => ({ data: null, isPending: false }),
}, },
})) }))
describe('App', () => { describe('App', () => {
it('redirects unauthenticated users to login', () => { it('renders the dashboard on the root route', () => {
render(<App />) render(<App />)
expect(screen.getByText('CartSnitch')).toBeInTheDocument() expect(screen.getByText('CartSnitch')).toBeInTheDocument()
expect(screen.getByRole('button', { name: /sign in/i })).toBeInTheDocument() })
})
}) it('renders the bottom navigation', () => {
render(<App />)
expect(screen.getByText('Home')).toBeInTheDocument()
expect(screen.getByText('Purchases')).toBeInTheDocument()
expect(screen.getByText('Products')).toBeInTheDocument()
})
})
+1 -1
View File
@@ -31,8 +31,8 @@ export default function App() {
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
<Route element={<Layout />}> <Route element={<Layout />}>
<Route index element={<Dashboard />} />
<Route element={<ProtectedRoute />}> <Route element={<ProtectedRoute />}>
<Route index element={<Dashboard />} />
<Route path="purchases" element={<Purchases />} /> <Route path="purchases" element={<Purchases />} />
<Route path="purchases/:id" element={<PurchaseDetail />} /> <Route path="purchases/:id" element={<PurchaseDetail />} />
<Route path="products" element={<Products />} /> <Route path="products" element={<Products />} />
+2 -12
View File
@@ -4,22 +4,12 @@ import { authClient } from '../lib/auth-client.ts'
import { useAuthStore } from '../stores/auth.ts' import { useAuthStore } from '../stores/auth.ts'
export function ProtectedRoute() { export function ProtectedRoute() {
const isMockAuth = import.meta.env.VITE_MOCK_AUTH === 'true'
const { data: session, isPending } = authClient.useSession() const { data: session, isPending } = authClient.useSession()
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
const setAuthenticated = useAuthStore((s) => s.setAuthenticated) const setAuthenticated = useAuthStore((s) => s.setAuthenticated)
useEffect(() => { useEffect(() => {
if (!isMockAuth) { setAuthenticated(!!session)
setAuthenticated(!!session) }, [session, setAuthenticated])
}
}, [session, setAuthenticated, isMockAuth])
// In mock auth mode, rely on Zustand store (set by Login/Register pages)
if (isMockAuth) {
if (!isAuthenticated) return <Navigate to="/login" replace />
return <Outlet />
}
if (isPending) { if (isPending) {
return ( return (
-28
View File
@@ -1,36 +1,8 @@
import { createAuthClient } from "better-auth/react" import { createAuthClient } from "better-auth/react"
import type { BetterFetchPlugin } from "@better-fetch/fetch"
/**
* Maps 'name' -> 'display_name' in register requests to match the API's RegisterRequest schema.
*/
const displayNameMapper: BetterFetchPlugin = {
id: "display-name-mapper",
name: "display-name-mapper",
hooks: {
onRequest: async (context) => {
const url = typeof context.url === "string" ? context.url : context.url.pathname
if (
url.endsWith("/auth/register") &&
context.method === "POST" &&
context.body &&
"name" in context.body
) {
context.body = {
...context.body,
display_name: context.body.name as string,
name: undefined,
}
}
return context
},
},
}
export const authClient = createAuthClient({ export const authClient = createAuthClient({
baseURL: import.meta.env.VITE_AUTH_URL || "", baseURL: import.meta.env.VITE_AUTH_URL || "",
basePath: "/auth", basePath: "/auth",
fetchPlugins: [displayNameMapper],
}) })
export const { useSession, signIn, signUp, signOut } = authClient export const { useSession, signIn, signUp, signOut } = authClient
-1
View File
@@ -173,7 +173,6 @@ function AuthenticatedDashboard({ userName }: { userName: string }) {
function DashboardSkeleton() { function DashboardSkeleton() {
return ( return (
<div className="animate-pulse"> <div className="animate-pulse">
<h1 className="sr-only">Loading CartSnitch</h1>
<div className="h-8 w-40 rounded bg-gray-200" /> <div className="h-8 w-40 rounded bg-gray-200" />
<div className="mt-4 grid grid-cols-2 gap-3"> <div className="mt-4 grid grid-cols-2 gap-3">
<div className="h-24 rounded-xl bg-gray-200" /> <div className="h-24 rounded-xl bg-gray-200" />
+5 -11
View File
@@ -31,14 +31,8 @@ export function Login() {
throw new Error(authError.message ?? 'Sign in failed') throw new Error(authError.message ?? 'Sign in failed')
} }
// After successful signIn, force a session fetch to confirm the cookie is set setAuthenticated(true)
// before navigating to the protected route navigate('/')
const sessionResult = await authClient.getSession()
if (sessionResult.data) {
navigate('/')
} else {
setError('Sign in failed. Please try again.')
}
} catch { } catch {
if (import.meta.env.VITE_MOCK_AUTH === 'true') { if (import.meta.env.VITE_MOCK_AUTH === 'true') {
setAuthenticated(true) setAuthenticated(true)
@@ -52,7 +46,7 @@ export function Login() {
} }
return ( return (
<main className="flex min-h-screen flex-col items-center justify-center px-4"> <div className="flex min-h-screen flex-col items-center justify-center px-4">
<h1 className="mb-2 text-3xl font-bold text-gray-900">CartSnitch</h1> <h1 className="mb-2 text-3xl font-bold text-gray-900">CartSnitch</h1>
<p className="mb-8 text-sm text-gray-500">Track prices. Save money.</p> <p className="mb-8 text-sm text-gray-500">Track prices. Save money.</p>
@@ -94,10 +88,10 @@ export function Login() {
<p className="mt-6 text-sm text-gray-500"> <p className="mt-6 text-sm text-gray-500">
Don't have an account?{' '} Don't have an account?{' '}
<Link to="/register" className="text-brand-blue underline"> <Link to="/register" className="text-brand-blue">
Sign up Sign up
</Link> </Link>
</p> </p>
</main> </div>
) )
} }
+2 -9
View File
@@ -38,15 +38,8 @@ export function Register() {
throw new Error(authError.message ?? 'Registration failed') throw new Error(authError.message ?? 'Registration failed')
} }
// After successful signUp, force a session fetch to confirm the cookie is set setAuthenticated(true)
// before navigating to the protected route navigate('/')
const sessionResult = await authClient.getSession()
if (sessionResult.data) {
navigate('/')
} else {
// Session not established — show success message and link to login
setError('Account created! Please sign in.')
}
} catch { } catch {
if (import.meta.env.VITE_MOCK_AUTH === 'true') { if (import.meta.env.VITE_MOCK_AUTH === 'true') {
setAuthenticated(true) setAuthenticated(true)
-1
View File
@@ -7,6 +7,5 @@ export default defineConfig({
environment: 'jsdom', environment: 'jsdom',
globals: true, globals: true,
setupFiles: ['./src/test/setup.ts'], setupFiles: ['./src/test/setup.ts'],
exclude: ['e2e/**', 'node_modules/**'],
}, },
}) })