Compare commits
89 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b67c257b7c | |||
| 51e6d2493c | |||
| ee6352a2f5 | |||
| 2f37f0501f | |||
| 4c36fd4156 | |||
| c9172f088f | |||
| ac4cba2b0d | |||
| 0c47be8ef3 | |||
| 440f92e96e | |||
| 97bbdf68a5 | |||
| 02e5bee390 | |||
| d475b3876a | |||
| 76bcc53992 | |||
| 470b615528 | |||
| f26f8f7e56 | |||
| 78b7831d43 | |||
| e45b510519 | |||
| f25044ea7e | |||
| b637fd9c11 | |||
| 983ee2c398 | |||
| 8af7b37b38 | |||
| b21a30b2e7 | |||
| 361ad3acc2 | |||
| 5e165d277e | |||
| 6828e4d0a9 | |||
| 0b9dd74f7d | |||
| 7a06f0618b | |||
| 9385463171 | |||
| b658f77f9c | |||
| 8706112be3 | |||
| 00b2b2469b | |||
| 1a464fd77d | |||
| 962e64b72a | |||
| ff91003e90 | |||
| cd733fbc7d | |||
| 1f9086f2f2 | |||
| 59407ae54a | |||
| 8659b99059 | |||
| 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 | |||
| b18cb24ec4 | |||
| 1491974aba | |||
| fe8e2567a2 | |||
| ea8dcad398 | |||
| e9eb9cf489 | |||
| 14ba9d0b82 | |||
| 6b73647689 | |||
| 4f42247bf2 | |||
| d5ee743d84 | |||
| 41380e9526 | |||
| 4c29d8a241 | |||
| 31b7c14719 | |||
| 6b6b9e7d01 | |||
| c62a151210 |
+203
-6
@@ -18,6 +18,8 @@ env:
|
|||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
IMAGE_NAME: cartsnitch/cartsnitch
|
IMAGE_NAME: cartsnitch/cartsnitch
|
||||||
AUTH_IMAGE_NAME: cartsnitch/auth
|
AUTH_IMAGE_NAME: cartsnitch/auth
|
||||||
|
RECEIPTWITNESS_IMAGE_NAME: cartsnitch/receiptwitness
|
||||||
|
API_IMAGE_NAME: cartsnitch/api
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
lint:
|
||||||
@@ -46,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
|
||||||
|
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
|
||||||
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:
|
||||||
@@ -73,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
|
||||||
@@ -110,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:
|
||||||
@@ -134,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
|
||||||
@@ -161,10 +228,122 @@ jobs:
|
|||||||
tags: ${{ steps.meta.outputs.tags }}
|
tags: ${{ steps.meta.outputs.tags }}
|
||||||
labels: ${{ steps.meta.outputs.labels }}
|
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:
|
deploy-dev:
|
||||||
runs-on: runners-cartsnitch
|
runs-on: runners-cartsnitch
|
||||||
needs: [build-and-push, build-and-push-auth]
|
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
|
||||||
@@ -189,17 +368,35 @@ jobs:
|
|||||||
- name: Install kustomize
|
- name: Install kustomize
|
||||||
uses: imranismail/setup-kustomize@v2
|
uses: imranismail/setup-kustomize@v2
|
||||||
|
|
||||||
- name: Update dev overlay image tag
|
- 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 }}
|
||||||
|
|
||||||
|
- 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 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
|
git push origin main
|
||||||
|
|||||||
Vendored
-164
@@ -1,164 +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/api
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
lint:
|
|
||||||
runs-on: runners-cartsnitch
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
cache: pip
|
|
||||||
- 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 system dependencies
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y libpq-dev build-essential
|
|
||||||
- name: Install cartsnitch-common from GitHub
|
|
||||||
run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git"
|
|
||||||
- run: pip install -e ".[dev]" mypy
|
|
||||||
- name: Type check
|
|
||||||
run: mypy src/cartsnitch_api
|
|
||||||
|
|
||||||
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:
|
|
||||||
CARTSNITCH_DATABASE_URL: postgresql+asyncpg://cartsnitch:cartsnitch_test@localhost:5432/cartsnitch_test
|
|
||||||
CARTSNITCH_REDIS_URL: redis://localhost:6379/0
|
|
||||||
CARTSNITCH_JWT_SECRET_KEY: test-secret-do-not-use-in-prod
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v4
|
|
||||||
- uses: actions/setup-python@v5
|
|
||||||
with:
|
|
||||||
python-version: "3.12"
|
|
||||||
cache: pip
|
|
||||||
- name: Install system dependencies
|
|
||||||
run: sudo apt-get update && sudo apt-get install -y libpq-dev build-essential
|
|
||||||
- name: Install cartsnitch-common from GitHub
|
|
||||||
run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git"
|
|
||||||
- run: pip install -e ".[dev]"
|
|
||||||
- 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: Log in to Docker Hub
|
|
||||||
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.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
|
|
||||||
|
|
||||||
- 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 }}"
|
|
||||||
+11
-4
@@ -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,16 +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 api/alembic.ini ./
|
||||||
|
COPY api/alembic/ ./alembic/
|
||||||
|
|
||||||
USER 1000
|
USER 1000
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
@@ -23,4 +30,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 ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn cartsnitch_api.main:app --host 0.0.0.0 --port 8000"]
|
||||||
@@ -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)
|
||||||
@@ -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"
|
||||||
|
)
|
||||||
|
)
|
||||||
@@ -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 uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
|
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
|
||||||
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
||||||
@@ -19,18 +18,27 @@ from cartsnitch_api.database import get_db
|
|||||||
# but we support Bearer tokens for service-to-service or mobile clients.
|
# but we support Bearer tokens for service-to-service or mobile clients.
|
||||||
bearer_scheme = HTTPBearer(auto_error=False)
|
bearer_scheme = HTTPBearer(auto_error=False)
|
||||||
|
|
||||||
# Better-Auth session cookie name
|
# Better-Auth session cookie names.
|
||||||
SESSION_COOKIE_NAME = "better-auth.session_token"
|
# Over HTTPS Better-Auth adds the __Secure- prefix automatically.
|
||||||
|
SESSION_COOKIE_NAMES = [
|
||||||
|
"__Secure-better-auth.session_token", # HTTPS (deployed)
|
||||||
|
"better-auth.session_token", # HTTP (local dev)
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
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.
|
"""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.
|
||||||
|
Better-Auth v1.5.6 stores raw tokens in the DB. The session cookie
|
||||||
|
is signed: ``rawToken.base64HMACSignature``. Strip the signature
|
||||||
|
before querying.
|
||||||
"""
|
"""
|
||||||
|
# Signed cookie format: rawToken.hmacSignature — split and use only the token part
|
||||||
|
raw_token = token.split(".")[0] if "." in token else token
|
||||||
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": token},
|
{"token": raw_token},
|
||||||
)
|
)
|
||||||
row = result.first()
|
row = result.first()
|
||||||
|
|
||||||
@@ -51,14 +59,14 @@ async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
|
|||||||
detail="Session expired",
|
detail="Session expired",
|
||||||
)
|
)
|
||||||
|
|
||||||
return UUID(str(user_id))
|
return str(user_id)
|
||||||
|
|
||||||
|
|
||||||
async def get_current_user(
|
async def get_current_user(
|
||||||
request: Request,
|
request: Request,
|
||||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> UUID:
|
) -> str:
|
||||||
"""Extract and validate the session token from cookie or Authorization header.
|
"""Extract and validate the session token from cookie or Authorization header.
|
||||||
|
|
||||||
Checks in order:
|
Checks in order:
|
||||||
@@ -67,8 +75,12 @@ async def get_current_user(
|
|||||||
"""
|
"""
|
||||||
token: str | None = None
|
token: str | None = None
|
||||||
|
|
||||||
# 1. Check session cookie
|
# 1. Check session cookie (try both names for HTTP/HTTPS compatibility)
|
||||||
cookie_token = request.cookies.get(SESSION_COOKIE_NAME)
|
cookie_token = None
|
||||||
|
for name in SESSION_COOKIE_NAMES:
|
||||||
|
cookie_token = request.cookies.get(name)
|
||||||
|
if cookie_token:
|
||||||
|
break
|
||||||
if cookie_token:
|
if cookie_token:
|
||||||
token = cookie_token
|
token = cookie_token
|
||||||
|
|
||||||
|
|||||||
@@ -2,22 +2,21 @@
|
|||||||
|
|
||||||
from datetime import UTC, datetime, timedelta
|
from datetime import UTC, datetime, timedelta
|
||||||
from typing import Any, cast
|
from typing import Any, cast
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from jose import JWTError, jwt
|
from jose import JWTError, jwt
|
||||||
|
|
||||||
from cartsnitch_api.config import settings
|
from cartsnitch_api.config import settings
|
||||||
|
|
||||||
|
|
||||||
def create_access_token(user_id: UUID) -> str:
|
def create_access_token(user_id: str) -> str:
|
||||||
expire = datetime.now(UTC) + timedelta(minutes=settings.jwt_access_token_expire_minutes)
|
expire = datetime.now(UTC) + timedelta(minutes=settings.jwt_access_token_expire_minutes)
|
||||||
payload = {"sub": str(user_id), "exp": expire, "type": "access"}
|
payload = {"sub": user_id, "exp": expire, "type": "access"}
|
||||||
return cast(str, jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm))
|
return cast(str, jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm))
|
||||||
|
|
||||||
|
|
||||||
def create_refresh_token(user_id: UUID) -> str:
|
def create_refresh_token(user_id: str) -> str:
|
||||||
expire = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_expire_days)
|
expire = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_expire_days)
|
||||||
payload = {"sub": str(user_id), "exp": expire, "type": "refresh"}
|
payload = {"sub": user_id, "exp": expire, "type": "refresh"}
|
||||||
return cast(str, jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm))
|
return cast(str, jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ the Better-Auth service (auth/). This router provides user profile
|
|||||||
endpoints that query our own user data from the shared database.
|
endpoints that query our own user data from the shared database.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -23,7 +21,7 @@ router = APIRouter(prefix="/auth", tags=["auth"])
|
|||||||
|
|
||||||
@router.get("/me", response_model=UserResponse)
|
@router.get("/me", response_model=UserResponse)
|
||||||
async def get_me(
|
async def get_me(
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = AuthService(db)
|
svc = AuthService(db)
|
||||||
@@ -38,7 +36,7 @@ async def get_me(
|
|||||||
@router.patch("/me", response_model=UserResponse)
|
@router.patch("/me", response_model=UserResponse)
|
||||||
async def update_me(
|
async def update_me(
|
||||||
body: UpdateUserRequest,
|
body: UpdateUserRequest,
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = AuthService(db)
|
svc = AuthService(db)
|
||||||
@@ -54,7 +52,7 @@ async def update_me(
|
|||||||
|
|
||||||
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def delete_me(
|
async def delete_me(
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = AuthService(db)
|
svc = AuthService(db)
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
from contextlib import asynccontextmanager
|
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.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,15 +46,19 @@ 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)
|
|
||||||
app.include_router(purchases_router)
|
# Data endpoints mounted under /api/v1
|
||||||
app.include_router(products_router)
|
v1_router = APIRouter(prefix="/api/v1")
|
||||||
app.include_router(prices_router)
|
v1_router.include_router(stores_router)
|
||||||
app.include_router(coupons_router)
|
v1_router.include_router(purchases_router)
|
||||||
app.include_router(shopping_router)
|
v1_router.include_router(products_router)
|
||||||
app.include_router(alerts_router)
|
v1_router.include_router(prices_router)
|
||||||
app.include_router(scraping_router)
|
v1_router.include_router(coupons_router)
|
||||||
app.include_router(public_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
|
return app
|
||||||
|
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""Alert routes: list alerts, manage settings."""
|
"""Alert routes: list alerts, manage settings."""
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -15,7 +13,7 @@ router = APIRouter(prefix="/alerts", tags=["alerts"])
|
|||||||
|
|
||||||
@router.get("", response_model=list[AlertResponse])
|
@router.get("", response_model=list[AlertResponse])
|
||||||
async def list_alerts(
|
async def list_alerts(
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = AlertService(db)
|
svc = AlertService(db)
|
||||||
@@ -24,7 +22,7 @@ async def list_alerts(
|
|||||||
|
|
||||||
@router.get("/settings", response_model=AlertSettingsResponse)
|
@router.get("/settings", response_model=AlertSettingsResponse)
|
||||||
async def get_alert_settings(
|
async def get_alert_settings(
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = AlertService(db)
|
svc = AlertService(db)
|
||||||
@@ -34,7 +32,7 @@ async def get_alert_settings(
|
|||||||
@router.put("/settings")
|
@router.put("/settings")
|
||||||
async def update_alert_settings(
|
async def update_alert_settings(
|
||||||
body: AlertSettingsRequest,
|
body: AlertSettingsRequest,
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ router = APIRouter(prefix="/coupons", tags=["coupons"])
|
|||||||
@router.get("", response_model=list[CouponResponse])
|
@router.get("", response_model=list[CouponResponse])
|
||||||
async def list_coupons(
|
async def list_coupons(
|
||||||
store_id: UUID | None = Query(None),
|
store_id: UUID | None = Query(None),
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = CouponService(db)
|
svc = CouponService(db)
|
||||||
@@ -25,7 +25,7 @@ async def list_coupons(
|
|||||||
|
|
||||||
@router.get("/relevant", response_model=list[CouponResponse])
|
@router.get("/relevant", response_model=list[CouponResponse])
|
||||||
async def relevant_coupons(
|
async def relevant_coupons(
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = CouponService(db)
|
svc = CouponService(db)
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ router = APIRouter(prefix="/prices", tags=["prices"])
|
|||||||
|
|
||||||
@router.get("/trends", response_model=list[PriceTrendResponse])
|
@router.get("/trends", response_model=list[PriceTrendResponse])
|
||||||
async def price_trends(
|
async def price_trends(
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
category: str | None = Query(None),
|
category: str | None = Query(None),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
@@ -30,7 +30,7 @@ async def price_trends(
|
|||||||
|
|
||||||
@router.get("/increases", response_model=list[PriceIncreaseResponse])
|
@router.get("/increases", response_model=list[PriceIncreaseResponse])
|
||||||
async def price_increases(
|
async def price_increases(
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = PriceService(db)
|
svc = PriceService(db)
|
||||||
@@ -40,7 +40,7 @@ async def price_increases(
|
|||||||
@router.get("/comparison", response_model=list[PriceComparisonResponse])
|
@router.get("/comparison", response_model=list[PriceComparisonResponse])
|
||||||
async def price_comparison(
|
async def price_comparison(
|
||||||
product_ids: Annotated[list[UUID], Query()],
|
product_ids: Annotated[list[UUID], Query()],
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = PriceService(db)
|
svc = PriceService(db)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ router = APIRouter(prefix="/products", tags=["products"])
|
|||||||
|
|
||||||
@router.get("", response_model=list[ProductResponse])
|
@router.get("", response_model=list[ProductResponse])
|
||||||
async def list_products(
|
async def list_products(
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
q: str | None = Query(None),
|
q: str | None = Query(None),
|
||||||
category: str | None = Query(None),
|
category: str | None = Query(None),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
@@ -29,7 +29,7 @@ async def list_products(
|
|||||||
@router.get("/{product_id}", response_model=ProductDetailResponse)
|
@router.get("/{product_id}", response_model=ProductDetailResponse)
|
||||||
async def get_product(
|
async def get_product(
|
||||||
product_id: UUID,
|
product_id: UUID,
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = ProductService(db)
|
svc = ProductService(db)
|
||||||
@@ -44,7 +44,7 @@ async def get_product(
|
|||||||
@router.get("/{product_id}/prices", response_model=PriceTrendResponse)
|
@router.get("/{product_id}/prices", response_model=PriceTrendResponse)
|
||||||
async def get_product_prices(
|
async def get_product_prices(
|
||||||
product_id: UUID,
|
product_id: UUID,
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = ProductService(db)
|
svc = ProductService(db)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ router = APIRouter(prefix="/purchases", tags=["purchases"])
|
|||||||
|
|
||||||
@router.get("", response_model=list[PurchaseResponse])
|
@router.get("", response_model=list[PurchaseResponse])
|
||||||
async def list_purchases(
|
async def list_purchases(
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
store_id: UUID | None = Query(None),
|
store_id: UUID | None = Query(None),
|
||||||
page: int = Query(1, ge=1),
|
page: int = Query(1, ge=1),
|
||||||
page_size: int = Query(20, ge=1, le=100),
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
@@ -27,7 +27,7 @@ async def list_purchases(
|
|||||||
|
|
||||||
@router.get("/stats", response_model=PurchaseStatsResponse)
|
@router.get("/stats", response_model=PurchaseStatsResponse)
|
||||||
async def purchase_stats(
|
async def purchase_stats(
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = PurchaseService(db)
|
svc = PurchaseService(db)
|
||||||
@@ -37,7 +37,7 @@ async def purchase_stats(
|
|||||||
@router.get("/{purchase_id}", response_model=PurchaseDetailResponse)
|
@router.get("/{purchase_id}", response_model=PurchaseDetailResponse)
|
||||||
async def get_purchase(
|
async def get_purchase(
|
||||||
purchase_id: UUID,
|
purchase_id: UUID,
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = PurchaseService(db)
|
svc = PurchaseService(db)
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""Scraping routes: trigger sync, check status (proxy to ReceiptWitness)."""
|
"""Scraping routes: trigger sync, check status (proxy to ReceiptWitness)."""
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from httpx import HTTPStatusError, RequestError
|
from httpx import HTTPStatusError, RequestError
|
||||||
|
|
||||||
@@ -13,7 +11,7 @@ router = APIRouter(prefix="/scraping", tags=["scraping"])
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/{store_slug}/sync", response_model=SyncTriggerResponse)
|
@router.post("/{store_slug}/sync", response_model=SyncTriggerResponse)
|
||||||
async def trigger_sync(store_slug: str, user_id: UUID = Depends(get_current_user)):
|
async def trigger_sync(store_slug: str, user_id: str = Depends(get_current_user)):
|
||||||
client = ReceiptWitnessClient()
|
client = ReceiptWitnessClient()
|
||||||
try:
|
try:
|
||||||
result = await client.trigger_sync(str(user_id), store_slug)
|
result = await client.trigger_sync(str(user_id), store_slug)
|
||||||
@@ -31,7 +29,7 @@ async def trigger_sync(store_slug: str, user_id: UUID = Depends(get_current_user
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/status", response_model=list[SyncStatusResponse])
|
@router.get("/status", response_model=list[SyncStatusResponse])
|
||||||
async def sync_status(user_id: UUID = Depends(get_current_user)):
|
async def sync_status(user_id: str = Depends(get_current_user)):
|
||||||
client = ReceiptWitnessClient()
|
client = ReceiptWitnessClient()
|
||||||
try:
|
try:
|
||||||
return await client.get_sync_status(str(user_id))
|
return await client.get_sync_status(str(user_id))
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""Shopping routes: optimize list, saved lists."""
|
"""Shopping routes: optimize list, saved lists."""
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from httpx import HTTPStatusError, RequestError
|
from httpx import HTTPStatusError, RequestError
|
||||||
|
|
||||||
@@ -13,7 +11,7 @@ router = APIRouter(prefix="/shopping", tags=["shopping"])
|
|||||||
|
|
||||||
|
|
||||||
@router.post("/optimize", response_model=OptimizeResponse)
|
@router.post("/optimize", response_model=OptimizeResponse)
|
||||||
async def optimize_shopping(body: OptimizeRequest, user_id: UUID = Depends(get_current_user)):
|
async def optimize_shopping(body: OptimizeRequest, user_id: str = Depends(get_current_user)):
|
||||||
client = ClipArtistClient()
|
client = ClipArtistClient()
|
||||||
try:
|
try:
|
||||||
result = await client.optimize(
|
result = await client.optimize(
|
||||||
@@ -37,7 +35,7 @@ async def optimize_shopping(body: OptimizeRequest, user_id: UUID = Depends(get_c
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/lists", response_model=list[ShoppingListResponse])
|
@router.get("/lists", response_model=list[ShoppingListResponse])
|
||||||
async def list_shopping_lists(user_id: UUID = Depends(get_current_user)):
|
async def list_shopping_lists(user_id: str = Depends(get_current_user)):
|
||||||
client = ClipArtistClient()
|
client = ClipArtistClient()
|
||||||
try:
|
try:
|
||||||
return await client.get_shopping_lists(str(user_id))
|
return await client.get_shopping_lists(str(user_id))
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
"""Store routes: list stores, manage user store connections."""
|
"""Store routes: list stores, manage user store connections."""
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status
|
from fastapi import APIRouter, Depends, HTTPException, status
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -21,7 +19,7 @@ async def list_stores(db: AsyncSession = Depends(get_db)):
|
|||||||
|
|
||||||
@router.get("/me/stores", response_model=list[StoreAccountResponse])
|
@router.get("/me/stores", response_model=list[StoreAccountResponse])
|
||||||
async def list_user_stores(
|
async def list_user_stores(
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = StoreService(db)
|
svc = StoreService(db)
|
||||||
@@ -36,7 +34,7 @@ async def list_user_stores(
|
|||||||
async def connect_store(
|
async def connect_store(
|
||||||
store_slug: str,
|
store_slug: str,
|
||||||
body: ConnectStoreRequest,
|
body: ConnectStoreRequest,
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = StoreService(db)
|
svc = StoreService(db)
|
||||||
@@ -51,7 +49,7 @@ async def connect_store(
|
|||||||
@router.delete("/me/stores/{store_slug}", status_code=status.HTTP_204_NO_CONTENT)
|
@router.delete("/me/stores/{store_slug}", status_code=status.HTTP_204_NO_CONTENT)
|
||||||
async def disconnect_store(
|
async def disconnect_store(
|
||||||
store_slug: str,
|
store_slug: str,
|
||||||
user_id: UUID = Depends(get_current_user),
|
user_id: str = Depends(get_current_user),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
svc = StoreService(db)
|
svc = StoreService(db)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ Alerts are generated by StickerShock and ShrinkRay services and written to the D
|
|||||||
This service reads them for the API gateway.
|
This service reads them for the API gateway.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.orm import selectinload
|
from sqlalchemy.orm import selectinload
|
||||||
@@ -15,7 +13,7 @@ class AlertService:
|
|||||||
def __init__(self, db: AsyncSession) -> None:
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
self.db = db
|
self.db = db
|
||||||
|
|
||||||
async def list_alerts(self, user_id: UUID) -> list[dict]:
|
async def list_alerts(self, user_id: str) -> list[dict]:
|
||||||
"""List shrinkflation events for products the user has purchased."""
|
"""List shrinkflation events for products the user has purchased."""
|
||||||
from cartsnitch_api.models import Purchase, PurchaseItem, ShrinkflationEvent
|
from cartsnitch_api.models import Purchase, PurchaseItem, ShrinkflationEvent
|
||||||
|
|
||||||
@@ -57,7 +55,7 @@ class AlertService:
|
|||||||
for e in events
|
for e in events
|
||||||
]
|
]
|
||||||
|
|
||||||
async def get_settings(self, user_id: UUID) -> dict:
|
async def get_settings(self, user_id: str) -> dict:
|
||||||
# Alert settings would be stored in a user_settings table.
|
# Alert settings would be stored in a user_settings table.
|
||||||
# For now, return defaults since the table doesn't exist yet in common lib.
|
# For now, return defaults since the table doesn't exist yet in common lib.
|
||||||
return {
|
return {
|
||||||
@@ -66,7 +64,7 @@ class AlertService:
|
|||||||
"email_notifications": False,
|
"email_notifications": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
async def update_settings(self, user_id: UUID, **fields) -> dict:
|
async def update_settings(self, user_id: str, **fields) -> dict:
|
||||||
# Would update user_settings table. Return merged defaults for now.
|
# Would update user_settings table. Return merged defaults for now.
|
||||||
current = await self.get_settings(user_id)
|
current = await self.get_settings(user_id)
|
||||||
for k, v in fields.items():
|
for k, v in fields.items():
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ handled by the Better-Auth service (auth/). This service provides
|
|||||||
user lookup and profile update operations for the API gateway.
|
user lookup and profile update operations for the API gateway.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
@@ -15,7 +13,7 @@ class AuthService:
|
|||||||
def __init__(self, db: AsyncSession) -> None:
|
def __init__(self, db: AsyncSession) -> None:
|
||||||
self.db = db
|
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
|
from cartsnitch_api.models import User
|
||||||
|
|
||||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||||
@@ -30,7 +28,7 @@ class AuthService:
|
|||||||
"created_at": user.created_at,
|
"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
|
from cartsnitch_api.models import User
|
||||||
|
|
||||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||||
@@ -58,7 +56,7 @@ class AuthService:
|
|||||||
"created_at": user.created_at,
|
"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
|
from cartsnitch_api.models import User
|
||||||
|
|
||||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ class CouponService:
|
|||||||
coupons = result.scalars().all()
|
coupons = result.scalars().all()
|
||||||
return [self._to_dict(c) for c in coupons]
|
return [self._to_dict(c) for c in coupons]
|
||||||
|
|
||||||
async def relevant_coupons(self, user_id: UUID) -> list[dict]:
|
async def relevant_coupons(self, user_id: str) -> list[dict]:
|
||||||
"""Coupons for products the user has purchased."""
|
"""Coupons for products the user has purchased."""
|
||||||
from cartsnitch_api.models import Coupon, PurchaseItem
|
from cartsnitch_api.models import Coupon, PurchaseItem
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class PurchaseService:
|
|||||||
|
|
||||||
async def list_purchases(
|
async def list_purchases(
|
||||||
self,
|
self,
|
||||||
user_id: UUID,
|
user_id: str,
|
||||||
store_id: UUID | None = None,
|
store_id: UUID | None = None,
|
||||||
page: int = 1,
|
page: int = 1,
|
||||||
page_size: int = 20,
|
page_size: int = 20,
|
||||||
@@ -56,7 +56,7 @@ class PurchaseService:
|
|||||||
for p, item_count, store_name in result.all()
|
for p, item_count, store_name in result.all()
|
||||||
]
|
]
|
||||||
|
|
||||||
async def get_purchase(self, purchase_id: UUID, user_id: UUID) -> dict:
|
async def get_purchase(self, purchase_id: UUID, user_id: str) -> dict:
|
||||||
from cartsnitch_api.models import Purchase
|
from cartsnitch_api.models import Purchase
|
||||||
|
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
@@ -88,7 +88,7 @@ class PurchaseService:
|
|||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
async def get_stats(self, user_id: UUID) -> dict:
|
async def get_stats(self, user_id: str) -> dict:
|
||||||
from cartsnitch_api.models import Purchase
|
from cartsnitch_api.models import Purchase
|
||||||
|
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"""Store service — list stores, manage user store account connections."""
|
"""Store service — list stores, manage user store account connections."""
|
||||||
|
|
||||||
import json
|
import json
|
||||||
from uuid import UUID
|
|
||||||
|
|
||||||
from cryptography.fernet import Fernet
|
from cryptography.fernet import Fernet
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
@@ -35,7 +34,7 @@ class StoreService:
|
|||||||
for s in stores
|
for s in stores
|
||||||
]
|
]
|
||||||
|
|
||||||
async def list_user_stores(self, user_id: UUID) -> list[dict]:
|
async def list_user_stores(self, user_id: str) -> list[dict]:
|
||||||
from cartsnitch_api.models import UserStoreAccount
|
from cartsnitch_api.models import UserStoreAccount
|
||||||
|
|
||||||
result = await self.db.execute(
|
result = await self.db.execute(
|
||||||
@@ -60,7 +59,7 @@ class StoreService:
|
|||||||
for a in accounts
|
for a in accounts
|
||||||
]
|
]
|
||||||
|
|
||||||
async def connect_store(self, user_id: UUID, store_slug: str, credentials: dict | None) -> dict:
|
async def connect_store(self, user_id: str, store_slug: str, credentials: dict | None) -> dict:
|
||||||
from cartsnitch_api.models import Store, UserStoreAccount
|
from cartsnitch_api.models import Store, UserStoreAccount
|
||||||
|
|
||||||
result = await self.db.execute(select(Store).where(Store.slug == store_slug))
|
result = await self.db.execute(select(Store).where(Store.slug == store_slug))
|
||||||
@@ -107,7 +106,7 @@ class StoreService:
|
|||||||
"sync_status": "active",
|
"sync_status": "active",
|
||||||
}
|
}
|
||||||
|
|
||||||
async def disconnect_store(self, user_id: UUID, store_slug: str) -> None:
|
async def disconnect_store(self, user_id: str, store_slug: str) -> None:
|
||||||
from cartsnitch_api.models import Store, UserStoreAccount
|
from cartsnitch_api.models import Store, UserStoreAccount
|
||||||
|
|
||||||
result = await self.db.execute(select(Store).where(Store.slug == store_slug))
|
result = await self.db.execute(select(Store).where(Store.slug == store_slug))
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
|||||||
__tablename__ = "users"
|
__tablename__ = "users"
|
||||||
|
|
||||||
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 | None] = mapped_column(String(255), nullable=True)
|
||||||
display_name: Mapped[str | None] = mapped_column(String(100))
|
display_name: Mapped[str | None] = mapped_column(String(100))
|
||||||
email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
|
||||||
image: Mapped[str | None] = mapped_column(Text, nullable=True)
|
image: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|||||||
@@ -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 |
|
||||||
|
|
||||||
@@ -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,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/');
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
@@ -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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+676
-97
File diff suppressed because it is too large
Load Diff
+13
-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,33 @@
|
|||||||
"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.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",
|
||||||
|
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
User-agent: *
|
||||||
|
Allow: /
|
||||||
|
|
||||||
|
Sitemap: https://cartsnitch.com/sitemap.xml
|
||||||
-168
@@ -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 }}"
|
|
||||||
+10
-13
@@ -3,24 +3,21 @@ FROM python:3.12-slim AS build
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# git is required to install cartsnitch-common from GitHub; build-essential and
|
# build-essential and libpq-dev are needed to compile any C-extension wheels
|
||||||
# libpq-dev are needed to compile any C-extension wheels (e.g. psycopg2 fallback)
|
# (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 \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
git \
|
|
||||||
libpq-dev \
|
libpq-dev \
|
||||||
build-essential \
|
build-essential \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
COPY pyproject.toml ./
|
# Build context is the repo root. These paths are relative to the root.
|
||||||
COPY src/ ./src/
|
COPY receiptwitness/pyproject.toml ./
|
||||||
|
COPY receiptwitness/src/ ./src/
|
||||||
|
COPY common/ ./common/
|
||||||
|
|
||||||
# cartsnitch-common is not on PyPI — install it directly from GitHub, then
|
# Install from the local common/ (cartsnitch-common>=0.1.0 in pyproject.toml
|
||||||
# install the rest of the package dependencies in a single resolver pass so
|
# will be satisfied by the local package) then install receiptwitness itself.
|
||||||
# pip can satisfy the cartsnitch-common>=0.1.0 constraint declared in
|
RUN pip install --no-cache-dir --prefix=/install ./common/ .
|
||||||
# pyproject.toml without hitting PyPI for it.
|
|
||||||
RUN pip install --no-cache-dir --prefix=/install \
|
|
||||||
"cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b" \
|
|
||||||
.
|
|
||||||
|
|
||||||
# Stage 2: Production image with Playwright + Chromium
|
# Stage 2: Production image with Playwright + Chromium
|
||||||
FROM python:3.12-slim AS prod
|
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
|
RUN adduser --system --group --uid 1000 app
|
||||||
|
|
||||||
COPY --from=build /install /usr/local
|
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)
|
# Install Playwright Chromium browser (runs as root; /opt/playwright is world-readable)
|
||||||
RUN PLAYWRIGHT_BROWSERS_PATH=/opt/playwright playwright install chromium
|
RUN PLAYWRIGHT_BROWSERS_PATH=/opt/playwright playwright install chromium
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# seed-dev-job.yaml
|
||||||
|
# K8s Job to run the CartSnitch seed runner against the dev database.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# kubectl apply -f seed-dev-job.yaml -n cartsnitch-dev
|
||||||
|
#
|
||||||
|
# To view logs:
|
||||||
|
# kubectl logs -n cartsnitch-dev job/seed-dev -f
|
||||||
|
#
|
||||||
|
# To re-run after fixing issues:
|
||||||
|
# kubectl delete -f seed-dev-job.yaml -n cartsnitch-dev && kubectl apply -f seed-dev-job.yaml -n cartsnitch-dev
|
||||||
|
#
|
||||||
|
apiVersion: batch/v1
|
||||||
|
kind: Job
|
||||||
|
metadata:
|
||||||
|
name: seed-dev
|
||||||
|
namespace: cartsnitch-dev
|
||||||
|
labels:
|
||||||
|
app: cartsnitch
|
||||||
|
component: seed
|
||||||
|
environment: dev
|
||||||
|
annotations:
|
||||||
|
description: "Runs cartsnitch-common seed runner to populate dev database with realistic test data."
|
||||||
|
spec:
|
||||||
|
# Prevent retries — a failed seed run should be investigated, not auto-repeated.
|
||||||
|
backoffLimit: 0
|
||||||
|
# Do not run concurrently; sequential runs are safer for truncate+reseed.
|
||||||
|
concurrencyPolicy: Forbid
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: cartsnitch
|
||||||
|
component: seed
|
||||||
|
environment: dev
|
||||||
|
spec:
|
||||||
|
restartPolicy: Never
|
||||||
|
containers:
|
||||||
|
- name: seed
|
||||||
|
# Use slim Python image with the cartsnitch-common package installed from git.
|
||||||
|
# The common repo is public; no additional secret is needed for the pip install.
|
||||||
|
image: python:3.12-slim
|
||||||
|
command:
|
||||||
|
- sh
|
||||||
|
- -c
|
||||||
|
- |
|
||||||
|
pip install --no-cache-dir "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@main" && \
|
||||||
|
python -m cartsnitch_common.seed --database-url "$${DATABASE_URL}"
|
||||||
|
env:
|
||||||
|
- name: DATABASE_URL
|
||||||
|
valueFrom:
|
||||||
|
secretKeyRef:
|
||||||
|
name: cartsnitch-secrets
|
||||||
|
key: database-url-pg
|
||||||
|
optional: false
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: 100m
|
||||||
|
memory: 256Mi
|
||||||
|
limits:
|
||||||
|
cpu: 500m
|
||||||
|
memory: 512Mi
|
||||||
Executable
+104
@@ -0,0 +1,104 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# =============================================================================
|
||||||
|
# seed-dev.sh — Run the CartSnitch seed runner against the dev database.
|
||||||
|
#
|
||||||
|
# Usage:
|
||||||
|
# ./seed-dev.sh Run full seed against dev
|
||||||
|
# ./seed-dev.sh --dry-run Show planned record counts without writing
|
||||||
|
# ./seed-dev.sh --help Show this help
|
||||||
|
#
|
||||||
|
# Prerequisites:
|
||||||
|
# - kubectl configured for the cartsnitch-dev cluster
|
||||||
|
# - Namespace cartsnitch-dev exists (CNPG Postgres must be running)
|
||||||
|
#
|
||||||
|
# What it does:
|
||||||
|
# 1. Starts a background port-forward to cartsnitch-pg-rw:5432
|
||||||
|
# 2. Waits for the tunnel to be ready
|
||||||
|
# 3. Runs python -m cartsnitch_common.seed with --database-url pointing
|
||||||
|
# to localhost:<forwarded-port>/cartsnitch
|
||||||
|
# 4. Cleans up the port-forward on exit (normal, interrupt, or error)
|
||||||
|
# =============================================================================
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# --- Config -------------------------------------------------------------------
|
||||||
|
readonly NAMESPACE="cartsnitch-dev"
|
||||||
|
readonly SVC_NAME="cartsnitch-pg-rw"
|
||||||
|
readonly LOCAL_PORT="5433" # use a non-privileged port to avoid conflicts
|
||||||
|
readonly DB_NAME="cartsnitch"
|
||||||
|
readonly PG_USER="cartsnitch"
|
||||||
|
# Retrieve password from the CNPG credentials secret
|
||||||
|
readonly PG_PASSWORD="$(
|
||||||
|
kubectl get secret cartsnitch-pg-credentials \
|
||||||
|
-n "$NAMESPACE" \
|
||||||
|
-o jsonpath='{.data.password}' \
|
||||||
|
| base64 -d
|
||||||
|
)"
|
||||||
|
readonly DB_URL="postgresql://${PG_USER}:${PG_PASSWORD}@localhost:${LOCAL_PORT}/${DB_NAME}"
|
||||||
|
|
||||||
|
# --- Helpers ------------------------------------------------------------------
|
||||||
|
log() { echo "[seed-dev] $*"; }
|
||||||
|
fail() { log "ERROR: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
# Cleanup port-forward and exit.
|
||||||
|
cleanup() {
|
||||||
|
if [[ -n "${PF_PID:-}" ]]; then
|
||||||
|
log "Stopping port-forward (PID $PF_PID)..."
|
||||||
|
kill "$PF_PID" 2>/dev/null || true
|
||||||
|
wait "$PF_PID" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
trap cleanup EXIT
|
||||||
|
|
||||||
|
# --- Args ---------------------------------------------------------------------
|
||||||
|
DRY_RUN=""
|
||||||
|
HELP_FLAG=""
|
||||||
|
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--dry-run) DRY_RUN="--dry-run"; shift ;;
|
||||||
|
--help) HELP_FLAG="1"; shift ;;
|
||||||
|
*) fail "Unknown argument: $1";;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -n "$HELP_FLAG" ]]; then
|
||||||
|
sed -n '3,/^# ---/p' "$0" | head -n -1 | sed 's/^# //'
|
||||||
|
echo ""
|
||||||
|
echo "Additional arguments are passed through to the seed runner."
|
||||||
|
echo "Common seed-runner options:"
|
||||||
|
echo " --dry-run Show planned record counts without writing"
|
||||||
|
echo " --seed N Set random seed (default: 42)"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Prerequisites ------------------------------------------------------------
|
||||||
|
if ! command -v kubectl &>/dev/null; then
|
||||||
|
fail "kubectl not found — must be installed and configured."
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- Port-forward -------------------------------------------------------------
|
||||||
|
log "Starting port-forward ${SVC_NAME}:5432 -> localhost:${LOCAL_PORT} ..."
|
||||||
|
kubectl port-forward \
|
||||||
|
-n "$NAMESPACE" \
|
||||||
|
svc/"$SVC_NAME" \
|
||||||
|
"${LOCAL_PORT}:5432" \
|
||||||
|
&>/dev/null &
|
||||||
|
PF_PID=$!
|
||||||
|
|
||||||
|
# Give the tunnel a moment to establish
|
||||||
|
sleep 2
|
||||||
|
|
||||||
|
# Verify the tunnel is up
|
||||||
|
if ! kill -0 "$PF_PID" 2>/dev/null; then
|
||||||
|
fail "Port-forward failed to start."
|
||||||
|
fi
|
||||||
|
log "Port-forward active (PID $PF_PID) on localhost:${LOCAL_PORT}"
|
||||||
|
|
||||||
|
# --- Seed --------------------------------------------------------------------
|
||||||
|
log "Running seed against dev database..."
|
||||||
|
set -x
|
||||||
|
python -m cartsnitch_common.seed --database-url "$DB_URL" $DRY_RUN
|
||||||
|
set +x
|
||||||
|
|
||||||
|
log "Done."
|
||||||
+17
-23
@@ -1,23 +1,17 @@
|
|||||||
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('renders the dashboard on the root route', () => {
|
it('redirects unauthenticated users to login', () => {
|
||||||
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
@@ -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 />} />
|
||||||
|
|||||||
@@ -4,12 +4,22 @@ 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(() => {
|
||||||
setAuthenticated(!!session)
|
if (!isMockAuth) {
|
||||||
}, [session, setAuthenticated])
|
setAuthenticated(!!session)
|
||||||
|
}
|
||||||
|
}, [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 (
|
||||||
|
|||||||
@@ -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))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
+2
-2
@@ -35,7 +35,7 @@ export function useProduct(id: string) {
|
|||||||
export function usePriceHistory(productId: string) {
|
export function usePriceHistory(productId: string) {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['priceHistory', productId],
|
queryKey: ['priceHistory', productId],
|
||||||
queryFn: () => api.get<PriceHistory[]>(`/products/${productId}/price-history`),
|
queryFn: () => api.get<PriceHistory[]>(`/products/${productId}/prices`),
|
||||||
enabled: !!productId,
|
enabled: !!productId,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -50,6 +50,6 @@ export function useCoupons() {
|
|||||||
export function usePriceAlerts() {
|
export function usePriceAlerts() {
|
||||||
return useQuery({
|
return useQuery({
|
||||||
queryKey: ['priceAlerts'],
|
queryKey: ['priceAlerts'],
|
||||||
queryFn: () => api.get<PriceAlert[]>('/price-alerts'),
|
queryFn: () => api.get<PriceAlert[]>('/alerts'),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -15,7 +15,7 @@ const mockRoutes: Record<string, (path: string) => unknown> = {
|
|||||||
'/purchases': () => mockPurchases,
|
'/purchases': () => mockPurchases,
|
||||||
'/products': () => mockProducts,
|
'/products': () => mockProducts,
|
||||||
'/coupons': () => mockCoupons,
|
'/coupons': () => mockCoupons,
|
||||||
'/price-alerts': () => mockAlerts,
|
'/alerts': () => mockAlerts,
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchMockRoute<T>(path: string): T | null {
|
function matchMockRoute<T>(path: string): T | null {
|
||||||
@@ -30,7 +30,7 @@ function matchMockRoute<T>(path: string): T | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// /products/:id/price-history
|
// /products/:id/price-history
|
||||||
const priceHistoryMatch = path.match(/^\/products\/(.+)\/price-history$/)
|
const priceHistoryMatch = path.match(/^\/products\/(.+)\/prices$/)
|
||||||
if (priceHistoryMatch) {
|
if (priceHistoryMatch) {
|
||||||
return getMockPriceHistory(priceHistoryMatch[1]) as T
|
return getMockPriceHistory(priceHistoryMatch[1]) as T
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
+11
-5
@@ -31,8 +31,14 @@ export function Login() {
|
|||||||
throw new Error(authError.message ?? 'Sign in failed')
|
throw new Error(authError.message ?? 'Sign in failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
setAuthenticated(true)
|
// After successful signIn, force a session fetch to confirm the cookie is set
|
||||||
navigate('/')
|
// before navigating to the protected route
|
||||||
|
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)
|
||||||
@@ -46,7 +52,7 @@ export function Login() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-screen flex-col items-center justify-center px-4">
|
<main 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>
|
||||||
|
|
||||||
@@ -88,10 +94,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">
|
<Link to="/register" className="text-brand-blue underline">
|
||||||
Sign up
|
Sign up
|
||||||
</Link>
|
</Link>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,8 +38,15 @@ export function Register() {
|
|||||||
throw new Error(authError.message ?? 'Registration failed')
|
throw new Error(authError.message ?? 'Registration failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
setAuthenticated(true)
|
// After successful signUp, force a session fetch to confirm the cookie is set
|
||||||
navigate('/')
|
// before navigating to the protected route
|
||||||
|
const sessionResult = await authClient.getSession()
|
||||||
|
if (sessionResult.data) {
|
||||||
|
navigate('/')
|
||||||
|
} else {
|
||||||
|
// Session not established — show success message and link to login
|
||||||
|
setError('Account created! Please sign in.')
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
if (import.meta.env.VITE_MOCK_AUTH === 'true') {
|
||||||
setAuthenticated(true)
|
setAuthenticated(true)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import { http, HttpResponse } from 'msw'
|
||||||
|
import type { Purchase, Product, Coupon, PriceAlert } from '../../types/api.ts'
|
||||||
|
|
||||||
|
const mockPurchases: Purchase[] = [
|
||||||
|
{
|
||||||
|
id: 'pur_1',
|
||||||
|
storeId: 'store_1',
|
||||||
|
storeName: 'Kroger',
|
||||||
|
date: '2024-01-15',
|
||||||
|
total: 42.5,
|
||||||
|
items: [
|
||||||
|
{ id: 'item_1', productId: 'prod_1', name: 'Milk', quantity: 1, price: 3.99, unitPrice: 3.99 },
|
||||||
|
{ id: 'item_2', productId: 'prod_2', name: 'Bread', quantity: 2, price: 5.98, unitPrice: 2.99 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockProducts: Product[] = [
|
||||||
|
{
|
||||||
|
id: 'prod_1',
|
||||||
|
name: 'Whole Milk',
|
||||||
|
brand: 'Kroger',
|
||||||
|
category: 'Dairy',
|
||||||
|
prices: [{ storeId: 'store_1', storeName: 'Kroger', price: 3.99, lastUpdated: '2024-01-15' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prod_2',
|
||||||
|
name: 'Whole Wheat Bread',
|
||||||
|
brand: 'Nature\'s Own',
|
||||||
|
category: 'Bakery',
|
||||||
|
prices: [{ storeId: 'store_1', storeName: 'Kroger', price: 2.99, lastUpdated: '2024-01-15' }],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockCoupons: Coupon[] = [
|
||||||
|
{
|
||||||
|
id: 'coupon_1',
|
||||||
|
productId: 'prod_1',
|
||||||
|
storeName: 'Kroger',
|
||||||
|
description: '$1 off milk',
|
||||||
|
discount: '$1.00',
|
||||||
|
expiresAt: '2024-12-31',
|
||||||
|
code: 'MILK1',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const mockAlerts: PriceAlert[] = [
|
||||||
|
{
|
||||||
|
id: 'alert_1',
|
||||||
|
productId: 'prod_1',
|
||||||
|
productName: 'Whole Milk',
|
||||||
|
targetPrice: 2.99,
|
||||||
|
currentPrice: 3.99,
|
||||||
|
triggered: false,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const handlers = [
|
||||||
|
http.get('/api/v1/health', () => HttpResponse.json({ status: 'ok' })),
|
||||||
|
http.get('/api/v1/purchases', () => HttpResponse.json(mockPurchases)),
|
||||||
|
http.get('/api/v1/products', () => HttpResponse.json(mockProducts)),
|
||||||
|
http.get('/api/v1/products/prod_1', () => HttpResponse.json(mockProducts[0])),
|
||||||
|
http.get('/api/v1/coupons', () => HttpResponse.json(mockCoupons)),
|
||||||
|
http.get('/api/v1/alerts', () => HttpResponse.json(mockAlerts)),
|
||||||
|
]
|
||||||
@@ -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())
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatCurrency } from '../formatCurrency';
|
||||||
|
|
||||||
|
describe('formatCurrency', () => {
|
||||||
|
it('formats 0 cents as $0.00', () => {
|
||||||
|
expect(formatCurrency(0)).toBe('$0.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats 199 cents as $1.99', () => {
|
||||||
|
expect(formatCurrency(199)).toBe('$1.99');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats 10000 cents as $100.00', () => {
|
||||||
|
expect(formatCurrency(10000)).toBe('$100.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles negative values', () => {
|
||||||
|
expect(formatCurrency(-500)).toBe('-$5.00');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles large numbers', () => {
|
||||||
|
expect(formatCurrency(99999999)).toBe('$999,999.99');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports custom locale', () => {
|
||||||
|
expect(formatCurrency(1999, 'de-DE', 'EUR')).toContain('19,99');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports custom currency', () => {
|
||||||
|
const result = formatCurrency(1000, 'en-US', 'EUR');
|
||||||
|
expect(result).toContain('10.00');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import { formatDate } from '../formatDate';
|
||||||
|
|
||||||
|
describe('formatDate', () => {
|
||||||
|
describe('short style', () => {
|
||||||
|
it('formats an ISO date string', () => {
|
||||||
|
const result = formatDate('2024-03-15', 'short');
|
||||||
|
expect(result).toMatch(/Mar 15, 2024/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats a Date object', () => {
|
||||||
|
const result = formatDate(new Date('2024-03-15'), 'short');
|
||||||
|
expect(result).toMatch(/Mar 15, 2024/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('long style', () => {
|
||||||
|
it('formats with weekday and full month name', () => {
|
||||||
|
const result = formatDate('2024-03-15', 'long');
|
||||||
|
expect(result).toMatch(/Friday/);
|
||||||
|
expect(result).toMatch(/March/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('relative style', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns "just now" for very recent dates', () => {
|
||||||
|
const now = new Date('2024-01-01T12:00:00Z');
|
||||||
|
vi.setSystemTime(now);
|
||||||
|
const result = formatDate(new Date('2024-01-01T11:59:59Z'), 'relative');
|
||||||
|
expect(result).toBe('just now');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns minutes ago', () => {
|
||||||
|
const now = new Date('2024-01-01T12:00:00Z');
|
||||||
|
vi.setSystemTime(now);
|
||||||
|
const result = formatDate(new Date('2024-01-01T11:45:00Z'), 'relative');
|
||||||
|
expect(result).toBe('15m ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns hours ago', () => {
|
||||||
|
const now = new Date('2024-01-01T12:00:00Z');
|
||||||
|
vi.setSystemTime(now);
|
||||||
|
const result = formatDate(new Date('2024-01-01T09:00:00Z'), 'relative');
|
||||||
|
expect(result).toBe('3h ago');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns days ago', () => {
|
||||||
|
const now = new Date('2024-01-05T12:00:00Z');
|
||||||
|
vi.setSystemTime(now);
|
||||||
|
const result = formatDate(new Date('2024-01-01T12:00:00Z'), 'relative');
|
||||||
|
expect(result).toBe('4d ago');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { getStore, getStoreName, STORE_SLUGS } from '../storeSlugs';
|
||||||
|
|
||||||
|
describe('storeSlugs', () => {
|
||||||
|
describe('STORE_SLUGS constant', () => {
|
||||||
|
it('contains meijer, kroger, and target', () => {
|
||||||
|
expect(STORE_SLUGS).toHaveProperty('meijer');
|
||||||
|
expect(STORE_SLUGS).toHaveProperty('kroger');
|
||||||
|
expect(STORE_SLUGS).toHaveProperty('target');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStore', () => {
|
||||||
|
it('returns store data for known slug', () => {
|
||||||
|
const store = getStore('meijer');
|
||||||
|
expect(store).toEqual({
|
||||||
|
name: 'Meijer',
|
||||||
|
color: '#e31837',
|
||||||
|
icon: '/icons/stores/meijer.svg',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null for unknown slug', () => {
|
||||||
|
expect(getStore('unknown-store')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case insensitive', () => {
|
||||||
|
expect(getStore('KROGER')).toBeTruthy();
|
||||||
|
expect(getStore('Target')).toBeTruthy();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getStoreName', () => {
|
||||||
|
it('returns store name for known slug', () => {
|
||||||
|
expect(getStoreName('kroger')).toBe('Kroger');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns raw slug for unknown store', () => {
|
||||||
|
expect(getStoreName('unknown-store')).toBe('unknown-store');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is case insensitive', () => {
|
||||||
|
expect(getStoreName('TARGET')).toBe('Target');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
export function formatCurrency(
|
||||||
|
cents: number,
|
||||||
|
locale = 'en-US',
|
||||||
|
currency = 'USD'
|
||||||
|
): string {
|
||||||
|
return new Intl.NumberFormat(locale, {
|
||||||
|
style: 'currency',
|
||||||
|
currency,
|
||||||
|
}).format(cents / 100);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
export function formatDate(
|
||||||
|
date: string | Date,
|
||||||
|
style: 'short' | 'long' | 'relative' = 'short'
|
||||||
|
): string {
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date;
|
||||||
|
|
||||||
|
if (style === 'short') {
|
||||||
|
return d.toLocaleDateString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (style === 'long') {
|
||||||
|
return d.toLocaleDateString('en-US', {
|
||||||
|
weekday: 'long',
|
||||||
|
month: 'long',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// relative
|
||||||
|
const diff = Date.now() - d.getTime();
|
||||||
|
const seconds = Math.floor(diff / 1000);
|
||||||
|
if (seconds < 60) return 'just now';
|
||||||
|
const minutes = Math.floor(seconds / 60);
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
const hours = Math.floor(minutes / 60);
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
const days = Math.floor(hours / 24);
|
||||||
|
return `${days}d ago`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
export const STORE_SLUGS: Record<string, { name: string; color: string; icon: string }> = {
|
||||||
|
meijer: { name: 'Meijer', color: '#e31837', icon: '/icons/stores/meijer.svg' },
|
||||||
|
kroger: { name: 'Kroger', color: '#0033a0', icon: '/icons/stores/kroger.svg' },
|
||||||
|
target: { name: 'Target', color: '#cc0000', icon: '/icons/stores/target.svg' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getStore(slug: string) {
|
||||||
|
return STORE_SLUGS[slug.toLowerCase()] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getStoreName(slug: string): string {
|
||||||
|
return getStore(slug)?.name ?? slug;
|
||||||
|
}
|
||||||
@@ -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