forked from cartsnitch/cartsnitch
Compare commits
43 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 00b2b2469b | |||
| 1f9086f2f2 | |||
| 59407ae54a | |||
| e82ed5ac12 | |||
| 0d8ee5f386 | |||
| 09864c1a96 | |||
| 3621504c22 | |||
| 24adc7e35b | |||
| 99294ea46d | |||
| a28e9d9dd4 | |||
| d405caceca | |||
| f0d1694a1c | |||
| 6b32197ad2 | |||
| 528887a4a2 | |||
| bca46bf68e | |||
| 5d3b8fc8c2 | |||
| 6e76222b81 | |||
| 65e670a887 | |||
| 63aae4f2eb | |||
| e9bc46121f | |||
| 56d9d5ad2e | |||
| 1966b94a97 | |||
| a33b6a0c30 | |||
| c2b5ccb830 | |||
| 69e1be1560 | |||
| 43673583c1 | |||
| b7b9e987df | |||
| e6ed9d9193 | |||
| f0c60778cc | |||
| 7d31491114 | |||
| aba26b9d2f | |||
| d0cecf9686 | |||
| dfe7b42db3 | |||
| b6df3dc0cb | |||
| 6c09db5478 | |||
| 3f13cb1bf6 | |||
| d4f7194d3f | |||
| ee731c4aa3 | |||
| 98d95a661a | |||
| de120cb429 | |||
| 41380e9526 | |||
| 31b7c14719 | |||
| c62a151210 |
+153
-6
@@ -19,6 +19,7 @@ env:
|
|||||||
IMAGE_NAME: cartsnitch/cartsnitch
|
IMAGE_NAME: cartsnitch/cartsnitch
|
||||||
AUTH_IMAGE_NAME: cartsnitch/auth
|
AUTH_IMAGE_NAME: cartsnitch/auth
|
||||||
RECEIPTWITNESS_IMAGE_NAME: cartsnitch/receiptwitness
|
RECEIPTWITNESS_IMAGE_NAME: cartsnitch/receiptwitness
|
||||||
|
API_IMAGE_NAME: cartsnitch/api
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
@@ -47,9 +48,59 @@ 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
|
||||||
|
LHCI_CHROME_PATH="$CHROME_PATH" lhci autorun
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
needs: [lint, test]
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
needs: [lint, test, e2e]
|
||||||
outputs:
|
outputs:
|
||||||
calver_tag: ${{ steps.calver.outputs.version }}
|
calver_tag: ${{ steps.calver.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
@@ -74,6 +125,13 @@ 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
|
||||||
@@ -111,7 +169,8 @@ jobs:
|
|||||||
|
|
||||||
build-and-push-auth:
|
build-and-push-auth:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
needs: [lint, test]
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
needs: [lint, test, e2e]
|
||||||
outputs:
|
outputs:
|
||||||
calver_tag: ${{ steps.calver.outputs.version }}
|
calver_tag: ${{ steps.calver.outputs.version }}
|
||||||
steps:
|
steps:
|
||||||
@@ -135,6 +194,13 @@ 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
|
||||||
@@ -164,6 +230,7 @@ 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 }}
|
||||||
@@ -183,6 +250,13 @@ 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
|
||||||
@@ -210,10 +284,66 @@ jobs:
|
|||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
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:
|
deploy-dev:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness]
|
needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api]
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: always() && !cancelled() && 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
|
||||||
@@ -238,18 +368,35 @@ jobs:
|
|||||||
- name: Install kustomize
|
- name: Install kustomize
|
||||||
uses: imranismail/setup-kustomize@v2
|
uses: imranismail/setup-kustomize@v2
|
||||||
|
|
||||||
- name: Update dev overlay image tags
|
- name: Update frontend image tag
|
||||||
|
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 }}
|
||||||
|
|
||||||
- name: Commit and push to infra
|
- name: Commit and push to infra
|
||||||
run: |
|
run: |
|
||||||
cd infra
|
cd infra
|
||||||
git config user.name "cartsnitch-ci[bot]"
|
git config user.name "cartsnitch-ci[bot]"
|
||||||
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
|
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
|
||||||
git add apps/overlays/dev/kustomization.yaml
|
git add apps/overlays/dev/kustomization.yaml
|
||||||
git commit -m "ci(dev): update cartsnitch, auth, and receiptwitness images"
|
git commit -m "ci(dev): update cartsnitch, auth, receiptwitness, and api images"
|
||||||
git push origin main
|
git push origin main
|
||||||
|
|||||||
+10
-5
@@ -1,3 +1,5 @@
|
|||||||
|
# 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 \
|
||||||
@@ -6,18 +8,21 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY pyproject.toml ./
|
COPY api/pyproject.toml ./
|
||||||
COPY src/ ./src/
|
COPY api/src/ ./src/
|
||||||
RUN pip install --no-cache-dir --prefix=/install .
|
RUN pip install --no-cache-dir --prefix=/install .
|
||||||
|
|
||||||
|
# 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
|
||||||
COPY src/ ./src/
|
COPY api/src/ ./src/
|
||||||
COPY alembic.ini ./
|
COPY api/alembic.ini ./
|
||||||
COPY alembic/ ./alembic/
|
COPY api/alembic/ ./alembic/
|
||||||
|
|
||||||
USER 1000
|
USER 1000
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
|
|||||||
@@ -0,0 +1,122 @@
|
|||||||
|
"""Fix users.id UUID->text type mismatch for Better-Auth compatibility.
|
||||||
|
|
||||||
|
Better-Auth generates nanoid-style text IDs (e.g. pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI),
|
||||||
|
but the users table was using PostgreSQL uuid type. When Better-Auth tries to INSERT
|
||||||
|
a new user, Postgres throws:
|
||||||
|
ERROR: invalid input syntax for type uuid: "pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI"
|
||||||
|
|
||||||
|
The sessions, accounts, and verifications tables already use text IDs — only users,
|
||||||
|
user_store_accounts.user_id, and purchases.user_id needed fixing.
|
||||||
|
|
||||||
|
Revision ID: 004_fix_user_id_text
|
||||||
|
Revises: 003_make_users_hashed_password_nullable
|
||||||
|
Create Date: 2026-03-31
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from sqlalchemy import text
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = "004_fix_user_id_text"
|
||||||
|
down_revision = "003_make_users_hashed_password_nullable"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
# Step 1: Drop existing FK constraints
|
||||||
|
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
|
||||||
|
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
|
||||||
|
|
||||||
|
# Step 2: Alter users.id from uuid to text
|
||||||
|
op.alter_column(
|
||||||
|
"users",
|
||||||
|
"id",
|
||||||
|
type_=sa.Text(),
|
||||||
|
existing_type=sa.UUID(),
|
||||||
|
postgresql_using="id::text",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 3: Alter user_store_accounts.user_id from uuid to text
|
||||||
|
op.alter_column(
|
||||||
|
"user_store_accounts",
|
||||||
|
"user_id",
|
||||||
|
type_=sa.Text(),
|
||||||
|
existing_type=sa.UUID(),
|
||||||
|
postgresql_using="user_id::text",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 4: Alter purchases.user_id from uuid to text
|
||||||
|
op.alter_column(
|
||||||
|
"purchases",
|
||||||
|
"user_id",
|
||||||
|
type_=sa.Text(),
|
||||||
|
existing_type=sa.UUID(),
|
||||||
|
postgresql_using="user_id::text",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Step 5: Re-add FK constraints
|
||||||
|
op.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE user_store_accounts "
|
||||||
|
"ADD CONSTRAINT user_store_accounts_user_id_fkey "
|
||||||
|
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE purchases "
|
||||||
|
"ADD CONSTRAINT purchases_user_id_fkey "
|
||||||
|
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
# Drop FK constraints
|
||||||
|
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
|
||||||
|
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
|
||||||
|
|
||||||
|
# Revert users.id from text to uuid
|
||||||
|
op.alter_column(
|
||||||
|
"users",
|
||||||
|
"id",
|
||||||
|
type_=sa.UUID(),
|
||||||
|
existing_type=sa.Text(),
|
||||||
|
postgresql_using="id::uuid",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Revert user_store_accounts.user_id from text to uuid
|
||||||
|
op.alter_column(
|
||||||
|
"user_store_accounts",
|
||||||
|
"user_id",
|
||||||
|
type_=sa.UUID(),
|
||||||
|
existing_type=sa.Text(),
|
||||||
|
postgresql_using="user_id::uuid",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Revert purchases.user_id from text to uuid
|
||||||
|
op.alter_column(
|
||||||
|
"purchases",
|
||||||
|
"user_id",
|
||||||
|
type_=sa.UUID(),
|
||||||
|
existing_type=sa.Text(),
|
||||||
|
postgresql_using="user_id::uuid",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Re-add FK constraints (PostgreSQL will auto-name them)
|
||||||
|
op.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE user_store_accounts "
|
||||||
|
"ADD CONSTRAINT user_store_accounts_user_id_fkey "
|
||||||
|
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
text(
|
||||||
|
"ALTER TABLE purchases "
|
||||||
|
"ADD CONSTRAINT purchases_user_id_fkey "
|
||||||
|
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -32,7 +32,7 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
|
|
||||||
__tablename__ = "purchases"
|
__tablename__ = "purchases"
|
||||||
|
|
||||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||||
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
store_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)
|
||||||
|
|||||||
@@ -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, UniqueConstraint
|
from sqlalchemy import DateTime, ForeignKey, String, Text, 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,11 +16,12 @@ if TYPE_CHECKING:
|
|||||||
from cartsnitch_api.models.store import Store
|
from cartsnitch_api.models.store import Store
|
||||||
|
|
||||||
|
|
||||||
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
class User(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))
|
||||||
@@ -36,7 +37,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[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||||
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
store_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))
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ class UpdateUserRequest(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class UserResponse(BaseModel):
|
class UserResponse(BaseModel):
|
||||||
id: UUID
|
id: str
|
||||||
email: str
|
email: str
|
||||||
display_name: str
|
display_name: str
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
|
|||||||
@@ -36,6 +36,15 @@ 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: {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { test as base, expect } from "@playwright/test";
|
||||||
|
import AxeBuilder from "@axe-core/playwright";
|
||||||
|
|
||||||
|
export const test = base.extend<{ axeCheck: void }>({
|
||||||
|
axeCheck: [async ({ page }, use) => {
|
||||||
|
await use();
|
||||||
|
const results = await new AxeBuilder({ page }).analyze();
|
||||||
|
expect(results.violations).toEqual([]);
|
||||||
|
}, { auto: true }],
|
||||||
|
});
|
||||||
|
|
||||||
|
export { expect } from "@playwright/test";
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
import { test, expect } from "./fixtures";
|
||||||
|
|
||||||
|
test("app loads", async ({ page }) => {
|
||||||
|
await page.goto("/");
|
||||||
|
await expect(page).toHaveTitle(/CartSnitch/);
|
||||||
|
});
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"ci": {
|
||||||
|
"collect": {
|
||||||
|
"staticDistDir": "./dist",
|
||||||
|
"url": ["http://localhost:4173/"],
|
||||||
|
"numberOfRuns": 1
|
||||||
|
},
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+675
-97
File diff suppressed because it is too large
Load Diff
+12
-2
@@ -9,11 +9,13 @@
|
|||||||
"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",
|
||||||
@@ -21,24 +23,32 @@
|
|||||||
"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.49.0",
|
||||||
"@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.5.2",
|
"@vitejs/plugin-react": "^4.7.0",
|
||||||
"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",
|
||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { defineConfig, devices } from '@playwright/test';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: './e2e',
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
name: 'chromium',
|
||||||
|
use: { ...devices['Desktop Chrome'] },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
webServer: {
|
||||||
|
command: 'npm run dev',
|
||||||
|
url: 'http://localhost:5173',
|
||||||
|
reuseExistingServer: !process.env.CI,
|
||||||
|
},
|
||||||
|
use: {
|
||||||
|
baseURL: 'http://localhost:5173',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://cartsnitch.com/sitemap.xml
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { renderHook, waitFor } from '@testing-library/react'
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { usePurchases } from '../useApi'
|
||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import { server } from '../../test/mocks/server'
|
||||||
|
|
||||||
|
function createWrapper() {
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { retry: false } },
|
||||||
|
})
|
||||||
|
return function Wrapper({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
{children}
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useApi hooks', () => {
|
||||||
|
describe('usePurchases', () => {
|
||||||
|
it('fetches and returns purchases', async () => {
|
||||||
|
const { result } = renderHook(() => usePurchases(), { wrapper: createWrapper() })
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true))
|
||||||
|
|
||||||
|
expect(result.current.data).toHaveLength(1)
|
||||||
|
expect(result.current.data![0]).toMatchObject({
|
||||||
|
id: 'pur_1',
|
||||||
|
storeName: 'Kroger',
|
||||||
|
total: 42.5,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns an error when the endpoint fails', async () => {
|
||||||
|
server.use(
|
||||||
|
http.get('/api/v1/purchases', () => HttpResponse.error()),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { result } = renderHook(() => usePurchases(), { wrapper: createWrapper() })
|
||||||
|
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -173,6 +173,7 @@ 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" />
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import type { Purchase, Product, Coupon, PriceAlert } from '../../types/api.ts'
|
||||||
|
|
||||||
|
const mockPurchases: Purchase[] = [
|
||||||
|
{
|
||||||
|
id: 'pur_1',
|
||||||
|
storeId: 'store_1',
|
||||||
|
storeName: 'Kroger',
|
||||||
|
date: '2024-01-15',
|
||||||
|
total: 42.5,
|
||||||
|
items: [
|
||||||
|
{ id: 'item_1', productId: 'prod_1', name: 'Milk', quantity: 1, price: 3.99, unitPrice: 3.99 },
|
||||||
|
{ id: 'item_2', productId: 'prod_2', name: 'Bread', quantity: 2, price: 5.98, unitPrice: 2.99 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockProducts: Product[] = [
|
||||||
|
{
|
||||||
|
id: 'prod_1',
|
||||||
|
name: 'Whole Milk',
|
||||||
|
brand: 'Kroger',
|
||||||
|
category: 'Dairy',
|
||||||
|
prices: [{ storeId: 'store_1', storeName: 'Kroger', price: 3.99, lastUpdated: '2024-01-15' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prod_2',
|
||||||
|
name: 'Whole Wheat Bread',
|
||||||
|
brand: 'Nature\'s Own',
|
||||||
|
category: 'Bakery',
|
||||||
|
prices: [{ storeId: 'store_1', storeName: 'Kroger', price: 2.99, lastUpdated: '2024-01-15' }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockCoupons: Coupon[] = [
|
||||||
|
{
|
||||||
|
id: 'coupon_1',
|
||||||
|
productId: 'prod_1',
|
||||||
|
storeName: 'Kroger',
|
||||||
|
description: '$1 off milk',
|
||||||
|
discount: '$1.00',
|
||||||
|
expiresAt: '2024-12-31',
|
||||||
|
code: 'MILK1',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockAlerts: PriceAlert[] = [
|
||||||
|
{
|
||||||
|
id: 'alert_1',
|
||||||
|
productId: 'prod_1',
|
||||||
|
productName: 'Whole Milk',
|
||||||
|
targetPrice: 2.99,
|
||||||
|
currentPrice: 3.99,
|
||||||
|
triggered: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const handlers = [
|
||||||
|
http.get('/api/v1/health', () => HttpResponse.json({ status: 'ok' })),
|
||||||
|
http.get('/api/v1/purchases', () => HttpResponse.json(mockPurchases)),
|
||||||
|
http.get('/api/v1/products', () => HttpResponse.json(mockProducts)),
|
||||||
|
http.get('/api/v1/products/prod_1', () => HttpResponse.json(mockProducts[0])),
|
||||||
|
http.get('/api/v1/coupons', () => HttpResponse.json(mockCoupons)),
|
||||||
|
http.get('/api/v1/price-alerts', () => HttpResponse.json(mockAlerts)),
|
||||||
|
]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
import { setupServer } from 'msw/node'
|
||||||
|
import { handlers } from './handlers'
|
||||||
|
|
||||||
|
export const server = setupServer(...handlers)
|
||||||
@@ -1 +1,6 @@
|
|||||||
import '@testing-library/jest-dom/vitest'
|
import '@testing-library/jest-dom/vitest'
|
||||||
|
import { server } from './mocks/server'
|
||||||
|
|
||||||
|
beforeAll(() => server.listen())
|
||||||
|
afterEach(() => server.resetHandlers())
|
||||||
|
afterAll(() => server.close())
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ 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/**'],
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user