Compare commits

..

21 Commits

Author SHA1 Message Date
Barcode Betty 6451058f7d Merge pull request 'fix: add psycopg2-binary to runtime deps (CAR-1422)' (#20) from betty/car-1422-psycopg2-binary into dev
CI / build-and-push (push) Successful in 1m22s
CI / build-and-push (pull_request) Has been skipped
CI / deploy-uat (push) Has been skipped
CI / lint (pull_request) Failing after 3s
CI / test (pull_request) Successful in 9s
CI / grype (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
CI / lint (push) Successful in 3s
CI / test (push) Successful in 8s
CI / grype (push) Failing after 2s
CI / deploy-dev (push) Failing after 38s
CI / deploy-dev (pull_request) Has been skipped
fix: add psycopg2-binary to runtime deps (CAR-1422)

CI passed: lint+test green (run 3779/8090 rerun success).
Unblocks alembic upgrade head in standalone image.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-22 23:11:28 +00:00
Barcode Betty 87a7d63aeb fix: add psycopg2-binary to runtime deps (CAR-1422)
CI / test (pull_request) Successful in 9s
CI / lint (pull_request) Successful in 4s
CI / deploy-dev (pull_request) Has been skipped
CI / build-and-push (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
CI / grype (pull_request) Has been skipped
alembic upgrade head uses SQLAlchemy sync engine with postgresql:// URL,
which requires psycopg2. The old image had it via cartsnitch-common;
standalone image needs it explicit.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-22 23:09:45 +00:00
Barcode Betty 8718f33083 Merge pull request 'fix: move python-multipart to runtime deps (CAR-1422)' (#18) from betty/car-1422-multipart-runtime-dep into dev
CI / lint (push) Successful in 5s
CI / test (push) Successful in 9s
CI / build-and-push (push) Successful in 1m26s
CI / grype (push) Failing after 25s
CI / deploy-uat (push) Has been skipped
CI / deploy-dev (push) Failing after 43s
CI / lint (pull_request) Successful in 4s
CI / test (pull_request) Successful in 9s
CI / build-and-push (pull_request) Has been skipped
CI / grype (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
fix: move python-multipart to runtime deps (CAR-1422)

Moves python-multipart from dev extras to main dependencies so the
production wheel install includes it. Fixes POST /inbound/email 500.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-22 22:02:21 +00:00
Barcode Betty adfdf6b999 fix: move python-multipart to runtime deps (CAR-1422)
CI / lint (pull_request) Successful in 4s
CI / test (pull_request) Successful in 15s
CI / build-and-push (pull_request) Has been skipped
CI / grype (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
Starlette requires python-multipart to parse form/multipart bodies.
Previously only in dev extras, so prod image never included it.
Fixes POST /inbound/email 500 AssertionError.
2026-06-22 21:52:18 +00:00
Savannah Savings 2089395699 Merge pull request 'fix(ci): migrate Docker registry from GHCR to Gitea' (#14) from barcode-betty/ghcr-to-gitea-registry into dev
CI / deploy-uat (push) Has been skipped
CI / deploy-dev (push) Failing after 41s
CI / lint (push) Successful in 5s
CI / grype (push) Has been skipped
CI / test (push) Successful in 9s
CI / build-and-push (push) Failing after 8s
2026-05-24 18:51:46 +00:00
Savannah Savings a197ab0530 {{.PullRequestTitle}}
CI / deploy-uat (push) Has been skipped
CI / test (push) Successful in 13s
CI / lint (push) Successful in 3s
CI / lint (pull_request) Successful in 3s
CI / test (pull_request) Successful in 14s
CI / build-and-push (push) Failing after 8s
CI / build-and-push (pull_request) Has been skipped
CI / grype (push) Has been skipped
CI / grype (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
CI / deploy-dev (push) Failing after 30s
2026-05-24 18:48:28 +00:00
Barcode Betty da9e1e9af1 ci: migrate GHCR → Gitea registry, use REGISTRY_TOKEN
CI / lint (pull_request) Successful in 5s
CI / deploy-dev (pull_request) Has been skipped
CI / test (pull_request) Successful in 24s
CI / build-and-push (pull_request) Has been skipped
CI / grype (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
Fixes CAR-1009.

- REGISTRY already git.farh.net (via CAR-964)
- Change GITEA_TOKEN → REGISTRY_TOKEN for consistency

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-24 18:39:33 +00:00
Barcode Betty 1cf7f92b6b fix(ci): migrate Docker registry from GHCR to Gitea
CI / lint (pull_request) Has been cancelled
CI / test (pull_request) Has been cancelled
CI / build-and-push (pull_request) Has been cancelled
CI / grype (pull_request) Has been cancelled
CI / deploy-dev (pull_request) Has been cancelled
CI / deploy-uat (pull_request) Has been cancelled
- Change REGISTRY from ghcr.io to git.farh.net
- Replace GHCR login with Gitea Container Registry login using REGISTRY_TOKEN
- Move workflow from .github/workflows to .gitea/workflows

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-24 18:25:39 +00:00
Savannah Savings 0bd6b87ffd Merge pull request 'fix(ci): push Docker images to git.farh.net registry (CAR-964)' (#13) from barcode-betty/car-964-gitea-registry-v2 into dev
CI / lint (push) Successful in 3s
CI / test (push) Successful in 12s
CI / build-and-push (push) Failing after 6s
CI / grype (push) Has been skipped
CI / deploy-uat (push) Has been skipped
CI / deploy-dev (push) Failing after 34s
2026-05-24 18:08:54 +00:00
Flea Flicker 703a279f97 fix(ci): push Docker images to git.farh.net registry (CAR-964)
CI / build-and-push (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
CI / lint (pull_request) Failing after 1m33s
CI / test (pull_request) Failing after 1m32s
CI / grype (pull_request) Has been skipped
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 16:07:51 +00:00
Coupon Carl ef5102aad9 Merge pull request 'chore: move workflows from .github to .gitea (CAR-900)' (#8) from carl/car-900-move-workflows-to-gitea into dev
CI / test (push) Successful in 8s
CI / build-and-push (push) Has been skipped
CI / lint (push) Failing after 3s
CI / deploy-uat (push) Has been skipped
CI / grype (push) Has been skipped
CI / deploy-dev (push) Failing after 32s
CI / test (pull_request) Failing after 1m34s
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
CI / lint (pull_request) Failing after 1m27s
CI / grype (pull_request) Has been skipped
CI / build-and-push (pull_request) Has been skipped
chore: move workflows from .github to .gitea (CAR-900)
2026-05-21 18:56:01 +00:00
Savannah Savings f63f6c613d chore: move workflows from .github to .gitea
CI / deploy-uat (pull_request) Has been skipped
CI / lint (pull_request) Successful in 3s
CI / test (pull_request) Successful in 8s
CI / build-and-push (pull_request) Has been skipped
CI / grype (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
Part of Gitea migration (CAR-900 / CAR-894).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 18:54:46 +00:00
Savannah Savings 377f8428c5 Merge pull request 'ci: convert GitHub Actions to Gitea Actions (ubuntu-latest)' (#6) from betty/car-876-gitea-actions-receiptwitness into dev
CI / lint (push) Successful in 4s
CI / test (push) Successful in 9s
CI / lint (pull_request) Successful in 5s
CI / test (pull_request) Successful in 7s
CI / build-and-push (push) Failing after 6s
CI / build-and-push (pull_request) Has been skipped
CI / grype (push) Has been skipped
CI / deploy-uat (push) Has been skipped
CI / grype (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-dev (push) Failing after 30s
ci: convert GitHub Actions to Gitea Actions (ubuntu-latest)

CTO-approved. QA passed. Mechanical CI migration.

cc @cpfarhood
2026-05-21 11:55:48 +00:00
Flea Flicker 340b974532 ci: convert GitHub Actions to Gitea Actions (ubuntu-latest)
CI / deploy-dev (pull_request) Has been skipped
CI / build-and-push (pull_request) Has been skipped
CI / grype (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
CI / lint (pull_request) Successful in 4s
CI / test (pull_request) Successful in 13s
- Replace runs-on: runners-cartsnitch with runs-on: ubuntu-latest
- Replace GitHub App token auth with secrets.GITEA_TOKEN for cross-repo checkout
- Remove actions/create-github-app-token steps from deploy-dev and deploy-uat

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 04:13:14 +00:00
cartsnitch-engineer[bot] 2fd2cdca71 feat: migrate receiptwitness to standalone repo with inlined common
Migrates receiptwitness to a standalone repo with inlined common models. Includes SQLite test compatibility fixes (server_default stripping for gen_random_bytes, relationship stubs for SQLAlchemy mapper).

QA PASS (cartsnitch-qa): lint  test  306/306 passed.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-04 21:01:21 +00:00
savannah-savings-cto[bot] 8128b3a76f chore: promote uat to main — receiptwitness migration CI fixes
Merged to production. UAT regression and security review both passed.

- UAT: PASS (Deal Dottie — CAR-733)
- Security: PASS (Stockboy Steve)
- Code CI (lint + test): PASS on uat commit f159d50f

Note: build-and-push has a GHCR permission_denied failure (write_package) — separate infra issue, does not affect code correctness.
2026-04-19 14:02:18 +00:00
cartsnitch-ceo[bot] f159d50f7c Merge pull request #4 from cartsnitch/dev
chore: promote dev to uat — receiptwitness migration CI fixes
2026-04-19 13:25:40 +00:00
cartsnitch-ceo[bot] c4880d3553 Merge pull request #3 from cartsnitch/betty/car-724-ci-fix
fix: resolve CI failures — SQLite incompatibility and ruff lint errors
2026-04-19 13:25:16 +00:00
Barcode Betty 873f53b9fc fix: resolve CI failures — SQLite incompatibility and ruff lint errors
- Remove PostgreSQL-specific server_default from User.email_inbound_token.
  The column has a Python-side default (secrets.token_urlsafe) that works
  for both SQLite and PostgreSQL. The gen_random_bytes() server_default
  caused sqlite table creation to fail.

- Add missing back_populates relationships to stub models so SQLAlchemy
  mapper configuration succeeds. Purchase.user and Store.user_accounts
  were missing, causing "has no property" errors during Base.metadata.create_all.

- Auto-fix ruff import sorting (I001) across all source and test files.

- Manually fix line-too-long (E501) in config.py.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 13:06:47 +00:00
Barcode Betty f47da487da feat: migrate receiptwitness to standalone repo with inlined common
Extract receiptwitness/ from the monorepo into cartsnitch/receiptwitness.
Inline the consumed modules from cartsnitch-common so there is no
cross-repo dependency.

- Add src/receiptwitness/shared/ with inlined models, schemas, constants, database
- Update all imports from cartsnitch_common to receiptwitness.shared
- Remove cartsnitch-common dependency from pyproject.toml
- Copy and update Alembic config (alembic.ini, alembic/)
- Update Dockerfile for standalone build context, add migration CMD
- Add CI workflow with lint, test, build, grype scan, deploy-dev, deploy-uat
- Add .grype.yaml

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-19 12:18:11 +00:00
cartsnitch-ceo[bot] bf7cabc9d8 release: fix HIGH-severity CVEs in receiptwitness image (UAT+Security PASS)
release: fix HIGH-severity CVEs in receiptwitness image (UAT+Security PASS)
2026-04-19 02:40:14 +00:00
382 changed files with 633 additions and 36250 deletions
+11 -7
View File
@@ -1,8 +1,12 @@
node_modules
dist
.git
.github
__pycache__/
*.pyc
.pytest_cache/
*.egg-info/
dist/
.venv/
.env
.git/
.github/
tests/
*.md
.env*
.vscode
coverage
renovate.json
+194
View File
@@ -0,0 +1,194 @@
name: CI
on:
push:
branches: [main, dev, uat]
pull_request:
branches: [main, dev, uat]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
packages: write
env:
REGISTRY: git.farh.net
IMAGE_NAME: cartsnitch/receiptwitness
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install ruff
- name: Lint
run: ruff check src/ tests/
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: pip install -e ".[dev]"
- name: Run tests
run: pytest tests/ -v
build-and-push:
runs-on: ubuntu-latest
if: github.event_name == 'push'
needs: [lint, test]
outputs:
calver_tag: ${{ steps.calver.outputs.version }}
sha_tag: sha-${{ github.sha }}
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 Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-,format=long
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
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: APT_CACHE_BUST=${{ github.run_id }}
- 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 }}"
grype:
runs-on: ubuntu-latest
needs: [build-and-push]
if: github.event_name == 'push'
steps:
- uses: actions/checkout@v4
- name: Run Grype vulnerability scan
uses: anchore/sbom-action@v0
with:
image: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}
format: spdx-json
output-file: sbom.spdx.json
- name: Upload SBOM
uses: actions/upload-artifact@v4
with:
name: sbom
path: sbom.spdx.json
- name: Run Grype
uses: anchore/grype-action@v1
with:
sbom: sbom.spdx.json
fail-on: high
ignore-file: .grype.yaml
deploy-dev:
runs-on: ubuntu-latest
needs: [grype]
if: always() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/dev'
steps:
- name: Checkout infra repo
uses: actions/checkout@v4
with:
repository: cartsnitch/infra
token: ${{ secrets.GITEA_TOKEN }}
ref: main
path: infra
- name: Install kubectl
uses: azure/setup-kubectl@v4
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
- name: Update receiptwitness image tag
run: |
cd infra/apps/overlays/dev
kustomize edit set image git.farh.net/cartsnitch/receiptwitness:sha-${{ github.sha }}
- name: Commit and push to infra
run: |
cd infra
git config user.name "cartsnitch-ci[bot]"
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/dev/kustomization.yaml
git commit -m "ci(dev): update receiptwitness to sha-${{ github.sha }}"
git pull --rebase origin main
git push origin main
deploy-uat:
runs-on: ubuntu-latest
needs: [grype]
if: always() && !cancelled() && github.event_name == 'push' && github.ref == 'refs/heads/uat'
steps:
- name: Checkout infra repo
uses: actions/checkout@v4
with:
repository: cartsnitch/infra
token: ${{ secrets.GITEA_TOKEN }}
ref: main
path: infra
- name: Install kubectl
uses: azure/setup-kubectl@v4
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
- name: Update receiptwitness image tag
run: |
cd infra/apps/overlays/uat
kustomize edit set image git.farh.net/cartsnitch/receiptwitness:${{ needs.build-and-push.outputs.calver_tag }}
- name: Commit and push to infra
run: |
cd infra
git config user.name "cartsnitch-ci[bot]"
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/uat/kustomization.yaml
git commit -m "ci(uat): update receiptwitness to ${{ needs.build-and-push.outputs.calver_tag }}"
git pull --rebase origin main
git push origin main
-674
View File
@@ -1,674 +0,0 @@
name: CI
on:
push:
branches: [main, dev, uat]
pull_request:
branches: [main, dev, uat]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
packages: write
security-events: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: cartsnitch/cartsnitch
AUTH_IMAGE_NAME: cartsnitch/auth
RECEIPTWITNESS_IMAGE_NAME: cartsnitch/receiptwitness
API_IMAGE_NAME: cartsnitch/api
jobs:
lint:
runs-on: runners-cartsnitch
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- name: ESLint
run: npx eslint .
- name: Type check
run: npx tsc --noEmit
test:
runs-on: runners-cartsnitch
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- name: Run tests
run: npx vitest run
audit:
runs-on: runners-cartsnitch
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- name: Check for vulnerabilities
run: npm audit --audit-level=high
e2e:
runs-on: runners-cartsnitch
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
lighthouse:
runs-on: runners-cartsnitch
needs: [test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- run: npm run build
- name: Install Chromium for Lighthouse
run: |
npm install -g playwright
npx playwright install --with-deps chromium
- name: Start preview server
run: |
npm run preview &
npx wait-on http://localhost:4173/ --timeout 30000
- name: Run Lighthouse CI
run: |
CHROME_PATH=$(find /home/runner/.cache/ms-playwright -name chrome -type f 2>/dev/null | head -1)
npm install -g @lhci/cli
CHROME_PATH="$CHROME_PATH" lhci autorun --chrome-flags="--headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage"
build-and-push:
runs-on: runners-cartsnitch
if: github.event_name == 'push'
needs: [lint, test, e2e]
outputs:
calver_tag: ${{ steps.calver.outputs.version }}
sha_tag: sha-${{ github.sha }}
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
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR
if: github.event_name == 'push'
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-,format=long
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 Docker image
uses: docker/build-push-action@v6
with:
context: .
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
target: prod
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan frontend image for vulnerabilities
uses: anchore/scan-action@v5
id: scan
env:
GRYPE_CONFIG: .grype.yaml
with:
image: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}"
fail-build: true
severity-cutoff: high
only-fixed: "true"
output-format: sarif
- name: Upload frontend scan results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: ${{ steps.scan.outputs.sarif }}
- name: Push Docker image
if: github.event_name == 'push'
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
target: prod
cache-from: type=gha
- 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 }}"
build-and-push-auth:
runs-on: runners-cartsnitch
if: github.event_name == 'push'
needs: [lint, test, e2e]
outputs:
calver_tag: ${{ steps.calver.outputs.version }}
sha_tag: sha-${{ github.sha }}
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'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata (auth)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.AUTH_IMAGE_NAME }}
tags: |
type=sha,prefix=sha-,format=long
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 Docker image
uses: docker/build-push-action@v6
with:
context: ./auth
file: ./auth/Dockerfile
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan auth image for vulnerabilities
uses: anchore/scan-action@v5
id: scan
env:
GRYPE_CONFIG: .grype.yaml
with:
image: "${{ env.REGISTRY }}/${{ env.AUTH_IMAGE_NAME }}:sha-${{ github.sha }}"
fail-build: true
severity-cutoff: high
only-fixed: "true"
output-format: sarif
- name: Upload auth scan results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: ${{ steps.scan.outputs.sarif }}
- name: Push Docker image
if: github.event_name == 'push'
uses: docker/build-push-action@v6
with:
context: ./auth
file: ./auth/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
build-and-push-receiptwitness:
runs-on: runners-cartsnitch
if: github.event_name == 'push'
needs: [lint, test]
outputs:
calver_tag: ${{ steps.calver.outputs.version }}
sha_tag: sha-${{ github.sha }}
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'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR
if: github.event_name == 'push'
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-,format=long
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 Docker image
uses: docker/build-push-action@v6
with:
context: .
file: ./receiptwitness/Dockerfile
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APT_CACHE_BUST=${{ github.run_id }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan receiptwitness image for vulnerabilities
uses: anchore/scan-action@v5
id: scan
env:
GRYPE_CONFIG: .grype.yaml
with:
image: "${{ env.REGISTRY }}/${{ env.RECEIPTWITNESS_IMAGE_NAME }}:sha-${{ github.sha }}"
fail-build: true
severity-cutoff: high
only-fixed: "true"
output-format: sarif
- name: Upload receiptwitness scan results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: ${{ steps.scan.outputs.sarif }}
- name: Push Docker image
if: github.event_name == 'push'
uses: docker/build-push-action@v6
with:
context: .
file: ./receiptwitness/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APT_CACHE_BUST=${{ github.run_id }}
cache-from: type=gha
build-and-push-api:
runs-on: runners-cartsnitch
if: github.event_name == 'push'
needs: [lint, test]
outputs:
calver_tag: ${{ steps.calver.outputs.version }}
sha_tag: sha-${{ github.sha }}
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'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR
if: github.event_name == 'push'
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-,format=long
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 Docker image
uses: docker/build-push-action@v6
with:
context: ./api
file: ./api/Dockerfile
load: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APT_CACHE_BUST=${{ github.run_id }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Scan api image for vulnerabilities
uses: anchore/scan-action@v5
id: scan
env:
GRYPE_CONFIG: .grype.yaml
with:
image: "${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }}:sha-${{ github.sha }}"
fail-build: true
severity-cutoff: high
only-fixed: "true"
output-format: sarif
- name: Upload api scan results to GitHub Security
uses: github/codeql-action/upload-sarif@v3
if: always()
with:
sarif_file: ${{ steps.scan.outputs.sarif }}
- name: Push Docker image
if: github.event_name == 'push'
uses: docker/build-push-action@v6
with:
context: ./api
file: ./api/Dockerfile
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
build-args: |
APT_CACHE_BUST=${{ github.run_id }}
cache-from: type=gha
deploy-dev:
runs-on: runners-cartsnitch
needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api]
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CARTSNITCH_APP_ID }}
private-key: ${{ secrets.CARTSNITCH_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: infra
- name: Checkout infra repo
uses: actions/checkout@v4
with:
repository: cartsnitch/infra
token: ${{ steps.app-token.outputs.token }}
ref: main
path: infra
- name: Install kubectl
uses: azure/setup-kubectl@v4
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
- name: Determine image tag for frontend
id: frontend_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Update frontend image tag
if: needs.build-and-push.result == 'success'
run: |
cd infra/apps/overlays/dev
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ steps.frontend_tag.outputs.tag }}
- name: Determine image tag for auth
id: auth_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push-auth.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push-auth.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- 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:${{ steps.auth_tag.outputs.tag }}
- name: Determine image tag for receiptwitness
id: receiptwitness_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push-receiptwitness.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push-receiptwitness.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- 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:${{ steps.receiptwitness_tag.outputs.tag }}
- name: Determine image tag for api
id: api_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push-api.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push-api.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- 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:${{ steps.api_tag.outputs.tag }}
- name: Commit and push to infra
run: |
cd infra
git config user.name "cartsnitch-ci[bot]"
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/dev/kustomization.yaml
git diff --cached --quiet && echo "No image changes to deploy" && exit 0
git commit -m "ci(dev): update cartsnitch, auth, receiptwitness, and api images"
git pull --rebase origin main
git push origin main
deploy-uat:
runs-on: runners-cartsnitch
needs: [build-and-push, build-and-push-auth, build-and-push-receiptwitness, build-and-push-api]
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/uat' || github.ref == 'refs/heads/main')
steps:
- name: Generate GitHub App token
id: app-token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.CARTSNITCH_APP_ID }}
private-key: ${{ secrets.CARTSNITCH_APP_PRIVATE_KEY }}
owner: ${{ github.repository_owner }}
repositories: infra
- name: Checkout infra repo
uses: actions/checkout@v4
with:
repository: cartsnitch/infra
token: ${{ steps.app-token.outputs.token }}
ref: main
path: infra
- name: Install kubectl
uses: azure/setup-kubectl@v4
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
- name: Determine image tag for frontend
id: frontend_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Update frontend image tag
if: needs.build-and-push.result == 'success'
run: |
cd infra/apps/overlays/uat
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ steps.frontend_tag.outputs.tag }}
- name: Determine image tag for auth
id: auth_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push-auth.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push-auth.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Update auth image tag
if: needs.build-and-push-auth.result == 'success'
run: |
cd infra/apps/overlays/uat
kustomize edit set image ghcr.io/cartsnitch/auth:${{ steps.auth_tag.outputs.tag }}
- name: Determine image tag for receiptwitness
id: receiptwitness_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push-receiptwitness.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push-receiptwitness.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Update receiptwitness image tag
if: needs.build-and-push-receiptwitness.result == 'success'
run: |
cd infra/apps/overlays/uat
kustomize edit set image ghcr.io/cartsnitch/receiptwitness:${{ steps.receiptwitness_tag.outputs.tag }}
- name: Determine image tag for api
id: api_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push-api.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push-api.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Update api image tag
if: needs.build-and-push-api.result == 'success'
run: |
cd infra/apps/overlays/uat
kustomize edit set image ghcr.io/cartsnitch/api:${{ steps.api_tag.outputs.tag }}
- name: Commit and push to infra
run: |
cd infra
git config user.name "cartsnitch-ci[bot]"
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/uat/kustomization.yaml
git diff --cached --quiet && echo "No image changes to deploy" && exit 0
git commit -m "ci(uat): update cartsnitch, auth, receiptwitness, and api images"
git pull --rebase origin main
git push origin main
+6 -24
View File
@@ -1,25 +1,7 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
__pycache__/
*.pyc
.pytest_cache/
*.egg-info/
dist/
.venv/
.env
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
+1 -105
View File
@@ -1,108 +1,4 @@
ignore:
# Python 3.12 CVEs — only fixed in 3.13+, cannot upgrade major version safely
- vulnerability: CVE-2025-13836
- vulnerability: CVE-2026-4519
# Chrome CVEs — Playwright bundles Chromium and controls version separately.
# Chrome is not a system package that can be upgraded via apt-get upgrade.
# These CVEs are specific to the Chromium version bundled with Playwright.
# Upstream fix: upgrade Playwright to a version that includes patched Chrome.
- vulnerability: CVE-2026-2313
- vulnerability: CVE-2026-2314
- vulnerability: CVE-2026-2315
- vulnerability: CVE-2026-2319
- vulnerability: CVE-2026-2321
- vulnerability: CVE-2026-2441
- vulnerability: CVE-2026-2648
- vulnerability: CVE-2026-2649
- vulnerability: CVE-2026-2650
- vulnerability: CVE-2026-3061
- vulnerability: CVE-2026-3062
- vulnerability: CVE-2026-3536
- vulnerability: CVE-2026-3537
- vulnerability: CVE-2026-3538
- vulnerability: CVE-2026-3539
- vulnerability: CVE-2026-3540
- vulnerability: CVE-2026-3541
- vulnerability: CVE-2026-3542
- vulnerability: CVE-2026-3543
- vulnerability: CVE-2026-3544
- vulnerability: CVE-2026-3545
- vulnerability: CVE-2026-3913
- vulnerability: CVE-2026-3914
- vulnerability: CVE-2026-3915
- vulnerability: CVE-2026-3916
- vulnerability: CVE-2026-3917
- vulnerability: CVE-2026-3918
- vulnerability: CVE-2026-3919
- vulnerability: CVE-2026-3920
- vulnerability: CVE-2026-3921
- vulnerability: CVE-2026-3922
- vulnerability: CVE-2026-3923
- vulnerability: CVE-2026-3924
- vulnerability: CVE-2026-3926
- vulnerability: CVE-2026-3931
- vulnerability: CVE-2026-3932
- vulnerability: CVE-2026-3936
- vulnerability: CVE-2026-5858
- vulnerability: CVE-2026-5859
- vulnerability: CVE-2026-5860
- vulnerability: CVE-2026-5861
- vulnerability: CVE-2026-5862
- vulnerability: CVE-2026-5863
- vulnerability: CVE-2026-5865
- vulnerability: CVE-2026-5866
- vulnerability: CVE-2026-5868
- vulnerability: CVE-2026-5870
- vulnerability: CVE-2026-5871
- vulnerability: CVE-2026-5872
- vulnerability: CVE-2026-5873
- vulnerability: CVE-2026-5874
- vulnerability: CVE-2026-5877
- vulnerability: CVE-2026-5879
- vulnerability: CVE-2026-5883
- vulnerability: CVE-2026-5884
- vulnerability: CVE-2026-5902
- vulnerability: CVE-2026-5904
- vulnerability: CVE-2026-5907
- vulnerability: CVE-2026-5908
- vulnerability: CVE-2026-5909
- vulnerability: CVE-2026-5910
- vulnerability: CVE-2026-5912
- vulnerability: CVE-2026-5913
- vulnerability: CVE-2026-5914
- vulnerability: CVE-2026-5915
- vulnerability: CVE-2026-6296
- vulnerability: CVE-2026-6297
- vulnerability: CVE-2026-6299
- vulnerability: CVE-2026-6300
- vulnerability: CVE-2026-6301
- vulnerability: CVE-2026-6302
- vulnerability: CVE-2026-6303
- vulnerability: CVE-2026-6304
- vulnerability: CVE-2026-6305
- vulnerability: CVE-2026-6306
- vulnerability: CVE-2026-6307
- vulnerability: CVE-2026-6308
- vulnerability: CVE-2026-6309
- vulnerability: CVE-2026-6310
- vulnerability: CVE-2026-6311
- vulnerability: CVE-2026-6314
- vulnerability: CVE-2026-6315
- vulnerability: CVE-2026-6316
- vulnerability: CVE-2026-6317
- vulnerability: CVE-2026-6318
- vulnerability: CVE-2026-6319
- vulnerability: CVE-2026-6358
- vulnerability: CVE-2026-6359
- vulnerability: CVE-2026-6360
- vulnerability: CVE-2026-6361
- vulnerability: CVE-2026-6363
# Node.js CVE — comes from Playwright's bundled tooling (playwright-core uses Node.js
# for its CLI). The system Node.js is not used by receiptwitness service.
# Fix requires upgrading Playwright to a version that ships with patched Node.js.
- vulnerability: CVE-2026-21710
# cryptography GHSA — fixed by upgrading to >=46.0 per requirements
- vulnerability: GHSA-r6ph-v2qm-q3c2
- vulnerability: CVE-2026-4519
+183 -148
View File
@@ -1,192 +1,227 @@
# CartSnitch Monorepo
# ReceiptWitness — CartSnitch Receipt Ingestion Service
## Project Context
CartSnitch is a self-hosted grocery price intelligence platform. This repo (`cartsnitch/cartsnitch`) is the **monorepo** containing the flagship frontend PWA and core backend services.
CartSnitch is a self-hosted grocery price intelligence platform built as a polyrepo microservices architecture. This repo (`cartsnitch/receiptwitness`) is the receipt/purchase history ingestion service.
**GitHub org:** github.com/cartsnitch
**Domain:** cartsnitch.com
### Monorepo Layout
| Directory | Service | Purpose |
|-----------|---------|---------|
| `/` (root) | Frontend | React PWA, mobile-first (this directory) |
| `auth/` | Auth | Better-Auth Node.js service (session management, email/password, OAuth) |
| `api/` | API Gateway | Frontend-facing REST API |
| `common/` | Common | Shared Python models, schemas, Alembic migrations |
| `receiptwitness/` | ReceiptWitness | Purchase data ingestion via retailer scrapers |
### Other CartSnitch Repos (still separate)
### CartSnitch Services
| Repo | Service | Purpose |
|------|---------|---------|
| `cartsnitch/common` | — | Shared models, schemas, utilities (extracted into individual service repos) |
| `cartsnitch/receiptwitness` | ReceiptWitness | Purchase data ingestion via retailer scrapers (this repo) |
| `cartsnitch/api` | API Gateway | Frontend-facing REST API |
| `cartsnitch/cartsnitch` | Frontend | React PWA (mobile-first) |
| `cartsnitch/stickershock` | StickerShock | Price increase detection & CPI comparison |
| `cartsnitch/shrinkray` | ShrinkRay | Shrinkflation monitoring |
| `cartsnitch/clipartist` | ClipArtist | Coupon/deal watching & shopping optimization |
| `cartsnitch/infra` | — | K8s manifests, Flux kustomizations |
## What This App Does
### Architecture Decisions
The frontend is a mobile-first PWA that serves as the primary user interface for CartSnitch. Users interact with it to:
- **Polyrepo:** Each service has its own repo, Dockerfile, CI/CD pipeline.
- **Shared DB:** One PostgreSQL cluster. This service writes to `purchases`, `purchase_items`, `price_history` tables. Models are inlined under `src/receiptwitness/shared/` (extracted from `cartsnitch-common` during the CAR-724 migration).
- **Inter-service comms:** REST (synchronous) + Redis pub/sub (async events).
- **Target scale:** 5001,000 users. Each user has their own authenticated sessions to up to 3 retailers.
1. **Connect store accounts** — link their Meijer, Kroger, Target loyalty accounts
2. **View purchase history** — see all purchases across stores in one timeline
3. **Browse products** — search the normalized product catalog
4. **Track prices** — view per-item price charts across stores over time
5. **Get alerts** — see price increase and shrinkflation notifications
6. **View coupons/deals** — browse active coupons relevant to their shopping
7. **Generate optimized shopping lists** — input what they need, get a store-split plan with coupon instructions
8. **Public dashboards** — shareable price transparency views (store comparisons, inflation tracking)
## What This Service Does
ReceiptWitness authenticates with grocery retailer web portals using per-user sessions, scrapes purchase history / receipt data, parses it into structured records, and writes it to the shared database. After ingestion, it publishes a `cartsnitch.receipts.ingested` event so downstream services (StickerShock, ClipArtist) can react.
### Target Retailers (MVP)
#### Meijer (mPerks)
- **Auth:** No public API. Session cookie-based auth on mperks.meijer.com.
- **Receipt location:** meijer.com/mperks/receipts-savings.html (or underlying XHR endpoints)
- **Approach:** Playwright login → capture session → hit receipt XHR endpoints directly. Map the API calls the frontend makes via browser dev tools network tab.
- **Prior art:** `dapperfu/python_Meijer` (requires MITM proxy for auth — avoid this pattern, prefer direct browser automation).
- **Data available:** Digital receipts appear ~15 minutes after purchase if mPerks ID was used at checkout. Includes item names, prices, discounts, savings.
#### Kroger
- **Auth:** No public API for purchase history (that's behind Partner API). Session cookie-based auth on kroger.com.
- **Receipt location:** kroger.com/mypurchases
- **Approach:** Playwright login → scrape purchase history pages or intercept XHR endpoints.
- **Anti-bot:** Kroger uses Akamai Bot Manager. Aggressive headless browser detection. Need Playwright stealth, realistic fingerprinting, human-like interaction pacing.
- **Prior art:** `phyllis-vance/KrogerScrape` (.NET, old), `callaginn/kroger-sweeper` (Puppeteer/Node), `ThermoMan/Get-Kroger-Grocery-List` (Greasemonkey userscript).
- **Kroger public API:** Free developer account at developer.kroger.com provides product catalog data (`product.compact` scope) — useful for enriching scraped receipt data with UPCs, categories, product images. NOT useful for purchase history.
- **Data available:** Purchase history tied to Kroger Plus loyalty card. Shows items, prices, quantities.
#### Target (Circle)
- **Auth:** Session-based auth on target.com.
- **Receipt location:** target.com account → Orders → In-store tab, or target.com/account/orders
- **Approach:** Playwright login → scrape in-store purchase history.
- **Data available:** ~1 year of history if user paid with a linked card, used the Target app wallet, or entered their Target Circle phone number at checkout. Includes item names, prices.
## Tech Stack
- React 18+ (or Next.js if SSR/SSG is valuable for public pages)
- TypeScript
- Tailwind CSS (mobile-first responsive design)
- Workbox (service worker, offline caching, PWA manifest)
- Recharts or Chart.js (price trend visualizations)
- TanStack Query (React Query) for data fetching and caching
- React Router (client-side routing)
- Zustand or Jotai (lightweight state management)
- Vite (build tool)
- Python 3.12+
- Playwright (Python async API) for headless browser automation
- FastAPI (lightweight internal API for triggering scrapes, health checks, status)
- SQLAlchemy 2.0 (models inlined under `src/receiptwitness/shared/`)
- Redis (pub/sub event publishing)
- APScheduler or Celery (for scheduled scraping jobs)
- cryptography / Fernet (encrypting stored session data)
## Repo Structure
```
frontend/
receiptwitness/
├── CLAUDE.md
├── README.md
├── package.json
├── tsconfig.json
├── vite.config.ts
├── tailwind.config.ts
├── Dockerfile # Multi-stage: build + nginx serve
├── docker-compose.yml # Local dev
├── public/
│ ├── manifest.json # PWA manifest
│ ├── sw.js # Service worker (Workbox generated)
│ ├── icons/ # PWA icons (192, 512, maskable)
│ └── favicon.ico
├── pyproject.toml
├── Dockerfile # Playwright + Chromium headless
├── docker-compose.yml # Local dev (Postgres, Redis, this service)
├── src/
── main.tsx
├── App.tsx
├── api/
├── client.ts # Axios/fetch wrapper, JWT interceptor
├── auth.ts # Auth API calls
│ │ ├── purchases.ts
│ │ ├── products.ts
│ │ ├── prices.ts
│ │ ├── coupons.ts
│ │ ── shopping.ts
── alerts.ts
├── hooks/
│ │ ├── useAuth.ts
│ │ ├── usePurchases.ts
│ │ ── useProducts.ts
├── usePrices.ts
│ │ ── useAlerts.ts
├── pages/
│ │ ── Login.tsx
├── Register.tsx
├── Dashboard.tsx # Home — summary, recent purchases, alerts
├── Purchases.tsx # Purchase history timeline
│ │ ├── PurchaseDetail.tsx # Single receipt view
│ │ ├── Products.tsx # Product catalog / search
│ │ ── ProductDetail.tsx # Product with cross-store price chart
── Prices.tsx # Price trends overview
│ │ ├── Coupons.tsx # Active deals
│ │ ├── ShoppingList.tsx # Optimized shopping list builder
│ │ ├── Alerts.tsx # Price increase / shrinkflation alerts
│ │ ├── StoreAccounts.tsx # Manage connected stores
│ │ ├── Settings.tsx
│ │ └── public/
│ │ ├── PriceTrends.tsx # Public shareable price dashboards
│ │ └── StoreComparison.tsx
│ ├── components/
│ │ ├── layout/
│ │ │ ├── AppShell.tsx # Mobile nav, header, bottom tabs
│ │ │ ├── BottomNav.tsx
│ │ │ └── Header.tsx
│ │ ├── charts/
│ │ │ ├── PriceHistoryChart.tsx
│ │ │ ├── SpendingChart.tsx
│ │ │ └── InflationComparisonChart.tsx
│ │ ├── purchases/
│ │ │ ├── PurchaseCard.tsx
│ │ │ └── PurchaseItemRow.tsx
│ │ ├── products/
│ │ │ ├── ProductCard.tsx
│ │ │ └── StorePriceComparison.tsx
│ │ ├── coupons/
│ │ │ └── CouponCard.tsx
│ │ ├── shopping/
│ │ │ ├── ShoppingListEditor.tsx
│ │ │ └── OptimizedPlan.tsx
│ │ ├── alerts/
│ │ │ ├── PriceAlertCard.tsx
│ │ │ └── ShrinkflationCard.tsx
│ │ └── common/
│ │ ├── LoadingSpinner.tsx
│ │ ├── EmptyState.tsx
│ │ ├── StoreLogo.tsx
│ │ └── ErrorBoundary.tsx
│ ├── stores/ # Zustand/Jotai state stores
│ │ ├── authStore.ts
│ │ └── uiStore.ts
│ ├── utils/
│ │ ├── formatCurrency.ts
│ │ ├── formatDate.ts
│ │ └── storeSlugs.ts
│ └── types/
│ ├── purchase.ts
│ ├── product.ts
│ ├── price.ts
│ ├── coupon.ts
│ └── alert.ts
── receiptwitness/
├── __init__.py
├── config.py # Service-specific settings
├── main.py # FastAPI app + scheduler bootstrap
├── scrapers/
│ ├── __init__.py
│ ├── base.py # Abstract BaseScraper class
│ ├── meijer.py # Meijer/mPerks scraper
│ ├── kroger.py # Kroger scraper
── target.py # Target/Circle scraper
── parsers/
│ ├── __init__.py
│ ├── meijer.py # Parse raw Meijer receipt data → PurchaseItem records
│ ├── kroger.py
── target.py
├── session/
── __init__.py
│ ├── manager.py # Session storage, retrieval, refresh logic
── encryption.py # Encrypt/decrypt session cookies at rest
├── scheduler.py # Scrape scheduling (per-user cron jobs)
├── events.py # Publish receipt.ingested events to Redis
├── api/
│ ├── __init__.py
│ ├── routes.py # Internal API: trigger scrape, check status, health
── auth.py # Internal service auth (API key or JWT)
── enrichment.py # Optional: enrich receipt data via Kroger public API
└── tests/
── ...
── conftest.py
├── fixtures/ # Sample receipt HTML/JSON for testing parsers
│ ├── meijer_receipt.json
│ ├── kroger_receipt.html
│ └── target_receipt.html
├── test_scrapers/
├── test_parsers/
└── test_session/
```
## Design Principles
## Scraper Architecture
- **Mobile-first.** Primary use case is checking prices and managing lists on a phone in-store or on the couch. Design for 375px viewport first, scale up.
- **Fast and offline-capable.** Service worker caches the app shell and recent data. Users should be able to browse their purchase history and shopping lists offline.
- **Minimal, opinionated UI.** This isn't a generic dashboard. Every screen should answer a specific question: "What did I buy?" "Where should I buy this?" "Am I getting ripped off?"
- **Store branding.** Use each retailer's brand colors for visual differentiation (Meijer red, Kroger blue, Target red — careful with the two reds, differentiate with icons/logos).
- **Charts that tell a story.** Price history charts should make it immediately obvious when a price spiked. Annotations for "coupon available" or "inflation baseline" add context.
### Base Scraper Pattern
## PWA Requirements
```python
class BaseScraper(ABC):
"""All retailer scrapers implement this interface."""
- `manifest.json` with proper app name, icons (192x192, 512x512, maskable), theme color, background color, display: standalone
- Service worker via Workbox: precache app shell, runtime cache API responses with stale-while-revalidate
- Add to Home Screen support on iOS and Android
- Offline fallback page
@abstractmethod
async def login(self, credentials: UserStoreAccount) -> SessionData: ...
## API Integration
@abstractmethod
async def check_session(self, session: SessionData) -> bool: ...
All data comes from the CartSnitch API gateway (`cartsnitch/api`). Base URL configured via environment variable `VITE_API_URL`.
@abstractmethod
async def scrape_receipts(self, session: SessionData, since: datetime | None) -> list[RawReceipt]: ...
- **Authentication via Better-Auth** (`auth/` service). Sessions are managed via httpOnly cookies — no tokens in localStorage or memory.
- Auth service URL configured via `VITE_AUTH_URL` (default: `http://localhost:3001`)
- Frontend uses `better-auth/react` client for sign-in, sign-up, sign-out, and `useSession()` hook
- API gateway validates sessions by querying the shared `sessions` table in Postgres
- Both cookie-based and Bearer token auth are supported (cookies for web, Bearer for API clients)
- TanStack Query handles caching, background refetching, and optimistic updates.
- API client sends `credentials: 'include'` on all requests to forward session cookies.
@abstractmethod
def parse_receipt(self, raw: RawReceipt) -> tuple[Purchase, list[PurchaseItem]]: ...
```
### Scraping Flow
1. **Scheduler fires** for a user+store combination
2. **Load session** from `user_store_accounts` table (encrypted)
3. **Check session validity** — quick lightweight request to verify auth
4. **If expired:** launch Playwright, re-authenticate, save new session
5. **Scrape receipts** since `last_sync_at` timestamp
6. **Parse** raw data into `Purchase` and `PurchaseItem` records
7. **Deduplicate** — skip receipts already in DB (match on `receipt_id` per store)
8. **Write to DB** — insert new purchases and items
9. **Derive price_history** entries from purchase_items
10. **Publish event**`cartsnitch.receipts.ingested` to Redis
11. **Update** `user_store_accounts.last_sync_at`
### Session Management
- Sessions (cookies, tokens) are encrypted at rest using Fernet symmetric encryption.
- The encryption key is provided via environment variable, not stored in the DB.
- Sessions are stored in the `user_store_accounts` table as encrypted JSONB.
- Each scrape attempt first checks if the existing session is valid before launching a full Playwright browser instance.
- When a session expires, the service needs the user's stored credentials OR a manual re-auth flow (the user logs in via the frontend, and we capture the session).
### Anti-Bot Considerations
- Use `playwright-stealth` or equivalent to mask automation signals.
- Set realistic viewport sizes, user agents, and locale settings.
- Add human-like delays between page navigations (randomized 1-5 seconds).
- For Kroger specifically (Akamai Bot Manager): may need to use non-headless mode on initial auth, or route through a persistent browser profile that has established trust.
- Rate limit scraping: no more than 1 scrape per user per store per hour. Default cadence: once daily.
- Store and reuse browser profiles/cookies to minimize fresh logins.
### Dockerfile
The Dockerfile must include Playwright and Chromium. Base image pattern:
```dockerfile
FROM mcr.microsoft.com/playwright/python:v1.49.0-noble
# Install deps, copy code, etc.
```
This is a large image (~2GB) due to Chromium. Consider multi-stage builds if the final image can be slimmed down.
## Internal API Endpoints
This service exposes a lightweight internal API (not public-facing):
- `GET /health` — health check
- `GET /status/{user_id}` — sync status per store for a user
- `POST /scrape/{user_id}/{store_slug}` — trigger an immediate scrape for a user+store
- `POST /scrape/{user_id}/all` — trigger scrape across all configured stores
- `GET /sessions/{user_id}` — list configured store sessions and their status
The public-facing API gateway (`cartsnitch/api`) proxies user-facing requests to this service's internal API.
## Events Published
### `cartsnitch.receipts.ingested`
Published after new receipt data is successfully written to the DB.
```json
{
"event_type": "cartsnitch.receipts.ingested",
"timestamp": "2026-03-15T12:00:00Z",
"service": "receiptwitness",
"payload": {
"user_id": "uuid",
"store_slug": "meijer",
"purchase_id": "uuid",
"purchase_date": "2026-03-14",
"item_count": 23,
"total": 87.42
}
}
```
## Development Workflow
- **Never push directly to main.** Always create feature branches and open PRs.
- Branch naming: `feature/<description>` or `fix/<description>`
- Branch naming: `feature/<store>/<description>` or `fix/<description>`
- Use conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`
- `npm run dev` for local development with hot reload
- `npm run build` for production build
- Lint with ESLint, format with Prettier
- Test parsers with fixture data (sample receipts in `tests/fixtures/`). Scraper integration tests require real credentials and should be tagged/skipped in CI.
- Local dev: `docker-compose up` starts Postgres, Redis, and the service. Playwright runs inside the container.
## Important Notes
- The store account connection flow is the most complex UX. Users need to authenticate with each retailer. This likely involves opening a controlled browser window/iframe where they log in, and CartSnitch captures the resulting session. Design this flow carefully — it needs to feel safe and trustworthy.
- Public price transparency pages should be SSR-friendly or statically generated for SEO if the "naming and shaming" feature is going to get organic traffic. Consider Next.js for these pages specifically, or a separate lightweight static site.
- The optimized shopping list is the killer feature for retention. Make it dead simple: add items → see the split → go shop. No friction.
- Push notifications (via service worker) for price alerts and deal notifications are a Phase 2+ feature but design the alert system with this in mind.
- The Playwright container image is large. On K8s, consider using a dedicated node or tolerating scheduling delays.
- Each user needs their own authenticated sessions. At 1,000 users × 3 stores = 3,000 sessions to manage. Sessions expire at different rates per retailer.
- Scraping must be respectful: randomized intervals, rate limiting, no parallel scraping of the same store for the same user.
- Receipt data structure varies significantly between retailers. The parsers must be robust and handle edge cases (returns, voided items, weighted produce, BOGO items, coupon stacking).
- Kroger's public API (`product.compact` scope) can be used to enrich scraped data with UPCs and product metadata after receipt parsing. This is optional but improves product normalization downstream.
- Store credentials for users should ideally NOT be stored by CartSnitch. Prefer a flow where the user authenticates in a controlled browser session, and we capture/store only the resulting session cookies. If credential storage is necessary, use strong encryption and make the tradeoffs clear to users.
+57 -18
View File
@@ -1,25 +1,64 @@
# Stage 1: Build
FROM node:20-alpine AS build
RUN apk update && apk upgrade --no-cache
# Stage 1: Build dependencies
FROM python:3.12-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
ARG APT_CACHE_BUST=1
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
libpq-dev \
build-essential \
&& rm -rf /var/lib/apt/lists/*
COPY . .
RUN npm run build
# Build context is the receiptwitness repo root.
COPY pyproject.toml ./
COPY src/ ./src/
# Stage 2: Production — uses nginxinc/nginx-unprivileged which runs as non-root (UID 101)
FROM nginxinc/nginx-unprivileged:stable-alpine AS prod
USER root
RUN apk update && apk upgrade --no-cache
USER 101
# Install receiptwitness (shared modules are inlined under src/receiptwitness/shared/).
RUN pip install --no-cache-dir --prefix=/install .
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
# Stage 2: Production image with Playwright + Chromium
FROM python:3.12-slim AS prod
USER 101
EXPOSE 8080
WORKDIR /app
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget -qO- http://localhost:8080/health || exit 1
# Install Playwright system dependencies for Chromium
ARG APT_CACHE_BUST=1
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
libnss3 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxrandr2 \
libgbm1 \
libpango-1.0-0 \
libcairo2 \
libasound2 \
libxshmfence1 \
libx11-xcb1 \
libxcb-dri3-0 \
fonts-liberation \
&& rm -rf /var/lib/apt/lists/*
RUN adduser --system --group --uid 1000 app
COPY --from=build /install /usr/local
COPY src/ ./src/
COPY alembic.ini ./
COPY alembic/ ./alembic/
# Install Playwright Chromium browser (runs as root; /opt/playwright is world-readable)
RUN PLAYWRIGHT_BROWSERS_PATH=/opt/playwright playwright install chromium
ENV PLAYWRIGHT_BROWSERS_PATH=/opt/playwright
USER 1000
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn receiptwitness.main:app --host 0.0.0.0 --port 8000"]
+29 -1
View File
@@ -1 +1,29 @@
# CartSnitch
# ReceiptWitness
Purchase data ingestion service for CartSnitch. Authenticates with grocery retailer web portals (Meijer, Kroger, Target) via Playwright, scrapes purchase history, and writes structured records to the shared PostgreSQL database.
## Quick Start
```bash
# Install dependencies
pip install -e ".[dev]"
# Run tests
pytest tests/ -v
# Local dev with Docker Compose
docker-compose up
```
## Architecture
- **Scrapers:** Playwright-based browser automation for each retailer
- **Parsers:** Converts raw receipt data to structured `Purchase` / `PurchaseItem` records
- **Database:** SQLAlchemy 2.0 async; models inlined under `src/receiptwitness/shared/`
- **Events:** Publishes `cartsnitch.receipts.ingested` to Redis after ingestion
## Branches
- `dev` — development, auto-deploys to dev cluster
- `uat` — user acceptance testing
- `main` — production, auto-deploys to prod cluster
View File
+1 -1
View File
@@ -6,7 +6,7 @@ from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from cartsnitch_common.models.base import Base
from receiptwitness.shared.models.base import Base
config = context.config
if config.config_file_name is not None:
-14
View File
@@ -1,14 +0,0 @@
.git
.github
.pytest_cache
.ruff_cache
__pycache__
*.py[cod]
*.egg-info
dist
.venv
.env
tests
openapi.json
CLAUDE.md
README.md
-164
View File
@@ -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 }}"
-9
View File
@@ -1,9 +0,0 @@
__pycache__/
*.py[cod]
*.egg-info/
dist/
.venv/
.env
.pytest_cache/
.ruff_cache/
openapi.json
-175
View File
@@ -1,175 +0,0 @@
# CartSnitch API Gateway
## Project Context
CartSnitch is a self-hosted grocery price intelligence platform built as a polyrepo microservices architecture. This repo (`cartsnitch/api`) is the public-facing API gateway that serves the frontend and proxies requests to internal services.
**GitHub org:** github.com/cartsnitch
**Domain:** cartsnitch.com
### CartSnitch Services
| Repo | Service | Purpose |
|------|---------|---------|
| `cartsnitch/common` | — | Shared models, schemas, utilities |
| `cartsnitch/receiptwitness` | ReceiptWitness | Purchase data ingestion via retailer scrapers |
| `cartsnitch/api` | API Gateway | Frontend-facing REST API (this repo) |
| `cartsnitch/cartsnitch` | Frontend | React PWA (mobile-first) |
| `cartsnitch/stickershock` | StickerShock | Price increase detection & CPI comparison |
| `cartsnitch/shrinkray` | ShrinkRay | Shrinkflation monitoring |
| `cartsnitch/clipartist` | ClipArtist | Coupon/deal watching & shopping optimization |
| `cartsnitch/infra` | — | K8s manifests, Flux kustomizations |
### Architecture Decisions
- **Polyrepo:** Each service has its own repo, Dockerfile, CI/CD pipeline.
- **Shared DB:** One PostgreSQL cluster. This service reads from all tables for serving frontend queries. Models come from `cartsnitch-common`.
- **Inter-service comms:** REST to internal services, Redis pub/sub for event subscriptions.
- **Target scale:** 5001,000 users initially.
## What This Service Does
The API Gateway is the single entry point for the frontend PWA and any external consumers. It:
1. **Handles user authentication** — registration, login, JWT token management
2. **Serves purchase/product/price data** — reads from the shared DB
3. **Proxies scraping operations** — forwards scrape triggers to ReceiptWitness
4. **Serves coupon/deal data** — reads from shared DB (written by ClipArtist)
5. **Serves alerts** — price increase alerts (StickerShock), shrinkflation alerts (ShrinkRay)
6. **Provides public data endpoints** — aggregate price trends for the transparency/shaming features
## Tech Stack
- Python 3.12+
- FastAPI (async)
- SQLAlchemy 2.0 (via `cartsnitch-common`, read-heavy)
- Pydantic v2 (request/response validation)
- python-jose or PyJWT (JWT auth)
- passlib + bcrypt (password hashing)
- httpx (async HTTP client for proxying to internal services)
- Redis (subscribe to events for websocket push, caching)
- uvicorn (ASGI server)
## Repo Structure
```
api/
├── CLAUDE.md
├── README.md
├── pyproject.toml
├── Dockerfile
├── docker-compose.yml
├── src/
│ └── cartsnitch_api/
│ ├── __init__.py
│ ├── config.py # Service-specific settings
│ ├── main.py # FastAPI app factory, lifespan, middleware
│ ├── auth/
│ │ ├── __init__.py
│ │ ├── jwt.py # JWT creation/validation
│ │ ├── passwords.py # Hashing, verification
│ │ ├── dependencies.py # FastAPI dependency injection (get_current_user)
│ │ └── routes.py # /auth/register, /auth/login, /auth/refresh
│ ├── routes/
│ │ ├── __init__.py
│ │ ├── purchases.py # Purchase history endpoints
│ │ ├── products.py # Normalized product catalog
│ │ ├── prices.py # Price history and trends
│ │ ├── coupons.py # Active coupons and deals
│ │ ├── alerts.py # Price increase / shrinkflation alerts
│ │ ├── stores.py # Store info, user store account management
│ │ ├── scraping.py # Proxy to ReceiptWitness (trigger scrape, status)
│ │ ├── shopping.py # Optimized shopping list (proxy to ClipArtist)
│ │ ├── public.py # Public price transparency endpoints (no auth)
│ │ └── health.py
│ ├── services/
│ │ ├── __init__.py
│ │ ├── receiptwitness.py # HTTP client for ReceiptWitness internal API
│ │ ├── stickershock.py # HTTP client for StickerShock internal API
│ │ ├── clipartist.py # HTTP client for ClipArtist internal API
│ │ └── shrinkray.py # HTTP client for ShrinkRay internal API
│ ├── middleware/
│ │ ├── __init__.py
│ │ ├── cors.py
│ │ └── rate_limit.py
│ └── cache.py # Redis caching helpers
└── tests/
├── conftest.py
├── test_auth/
├── test_routes/
└── test_services/
```
## API Endpoint Design
### Auth
- `POST /auth/register` — create account
- `POST /auth/login` — get JWT access + refresh tokens
- `POST /auth/refresh` — refresh access token
- `GET /auth/me` — current user profile
### Store Accounts
- `GET /stores` — list supported stores
- `GET /me/stores` — list user's connected store accounts + sync status
- `POST /me/stores/{store_slug}/connect` — initiate store connection flow
- `DELETE /me/stores/{store_slug}` — disconnect store account
### Purchases
- `GET /purchases` — list user's purchases (paginated, filterable by store/date)
- `GET /purchases/{id}` — purchase detail with line items
- `GET /purchases/stats` — spending summary (by store, by category, by period)
### Products
- `GET /products` — normalized product catalog (search, filter)
- `GET /products/{id}` — product detail with cross-store price comparison
- `GET /products/{id}/prices` — price history for a product across stores
### Prices
- `GET /prices/trends` — aggregate price trends (public-capable)
- `GET /prices/increases` — recent significant price increases
- `GET /prices/comparison` — compare specific items across stores
### Coupons
- `GET /coupons` — active coupons/deals (filterable by store)
- `GET /coupons/relevant` — coupons relevant to user's purchase history
### Shopping
- `POST /shopping/optimize` — input: shopping list → output: store-split + coupons
- `GET /shopping/lists` — user's saved shopping lists
### Alerts
- `GET /alerts` — user's price increase and shrinkflation alerts
- `PUT /alerts/settings` — configure alert thresholds
### Public (No Auth)
- `GET /public/trends/{product_id}` — public price trend for a product
- `GET /public/store-comparison` — public store-vs-store price comparison
- `GET /public/inflation` — price changes vs CPI baseline
### Scraping (Proxy to ReceiptWitness)
- `POST /scraping/{store_slug}/sync` — trigger a sync for the current user
- `GET /scraping/status` — sync status across all stores
## Authentication
- JWT-based auth with short-lived access tokens (15 min) and longer refresh tokens (7 days).
- Passwords hashed with bcrypt via passlib.
- All user-specific endpoints require a valid JWT in the `Authorization: Bearer` header.
- Public endpoints under `/public/` do not require auth.
- Internal service-to-service calls (ReceiptWitness, etc.) use a shared API key in the `X-Service-Key` header — not user JWTs.
## Development Workflow
- **Never push directly to main.** Always create feature branches and open PRs.
- Branch naming: `feature/<description>` or `fix/<description>`
- Use conventional commits: `feat:`, `fix:`, `refactor:`, `docs:`, `chore:`
- OpenAPI docs auto-generated at `/docs` (Swagger) and `/redoc`.
- Write tests for all routes. Use httpx.AsyncClient with FastAPI's TestClient pattern.
## Important Notes
- This service is read-heavy on the shared DB. Use async SQLAlchemy sessions.
- Consider Redis caching for expensive queries (price trends, product comparisons). Cache invalidation via Redis pub/sub events from other services.
- Rate limiting on public endpoints is important — these could get hammered if the price transparency features get attention.
- CORS must allow the frontend origin (cartsnitch.com and localhost for dev).
- The store connection flow is the trickiest UX challenge: the user needs to authenticate with each retailer, and we need to capture the resulting session. This likely involves a controlled Playwright browser session that the user can see/interact with, or an OAuth-like redirect flow if the retailer supports it (Kroger does for its public API, but not for purchase history access).
-32
View File
@@ -1,32 +0,0 @@
FROM python:3.12-slim AS build
ARG APT_CACHE_BUST=0
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
libpq-dev \
build-essential \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY pyproject.toml ./
COPY src/ ./src/
RUN pip install --no-cache-dir --prefix=/install .
FROM python:3.12-slim AS prod
ARG APT_CACHE_BUST=0
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
WORKDIR /app
RUN adduser --system --group --uid 1000 app
COPY --from=build /install /usr/local
COPY src/ ./src/
COPY alembic.ini ./
COPY alembic/ ./alembic/
USER 1000
EXPOSE 8000
HEALTHCHECK --interval=30s --timeout=3s \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn cartsnitch_api.main:app --host 0.0.0.0 --port 8000"]
-36
View File
@@ -1,36 +0,0 @@
[alembic]
script_location = alembic
sqlalchemy.url = postgresql://OVERRIDE_VIA_ENV_VAR
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
-67
View File
@@ -1,67 +0,0 @@
"""Alembic environment configuration for CartSnitch."""
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
from cartsnitch_api.models import Base # noqa: F401 — imports all models for autogenerate
config = context.config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
db_url = os.environ.get("CARTSNITCH_DATABASE_URL_SYNC")
if not db_url:
raise RuntimeError(
"CARTSNITCH_DATABASE_URL_SYNC must be set. "
"Example: postgresql://user:pass@localhost:5432/cartsnitch"
)
config.set_main_option("sqlalchemy.url", db_url.replace("%", "%%"))
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
version_table_column_width=128,
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata, version_table_column_width=128)
with context.begin_transaction():
context.run_migrations()
# Create any tables defined in models but not yet created by migrations.
# This bootstraps fresh databases that have no legacy schema.
# checkfirst=True ensures this is a no-op on existing databases.
try:
Base.metadata.create_all(bind=connection, checkfirst=True)
connection.commit()
except Exception as exc:
import logging
logging.getLogger("alembic.env").warning(
"create_all failed (non-fatal, migrations should handle table creation): %s", exc
)
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()
@@ -1,103 +0,0 @@
"""Encrypt existing plaintext session_data with Fernet.
Revision ID: 001_encrypt_session_data
Revises:
Create Date: 2026-03-19
"""
import json
import os
import sqlalchemy as sa
from cryptography.fernet import Fernet
from sqlalchemy import text
from alembic import op
revision = "001_encrypt_session_data"
down_revision = None
branch_labels = None
depends_on = None
def _get_fernet() -> Fernet:
key = os.environ.get("CARTSNITCH_FERNET_KEY")
if not key:
raise RuntimeError("CARTSNITCH_FERNET_KEY must be set to run this migration")
return Fernet(key.encode())
def _is_fernet_token(value: str) -> bool:
"""Check if a string looks like a Fernet token (base64 starting with gAAAAA)."""
return value.startswith("gAAAAA")
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# Fresh DB — table created by Base.metadata.create_all with correct TEXT type
if not inspector.has_table("user_store_accounts"):
return
# Already migrated? Skip if session_data is already TEXT (not JSON)
cols = {c["name"]: c for c in inspector.get_columns("user_store_accounts")}
if "session_data" not in cols:
return
col_type = str(cols["session_data"]["type"]).lower()
if "text" in col_type and "json" not in col_type:
return # already TEXT — nothing to do
# Change column type from JSON to TEXT to hold Fernet ciphertext
op.alter_column(
"user_store_accounts",
"session_data",
type_=sa.Text(),
existing_type=sa.JSON(),
existing_nullable=True,
postgresql_using="session_data::text",
)
rows = conn.execute(
text("SELECT id, session_data FROM user_store_accounts WHERE session_data IS NOT NULL")
).fetchall()
f = _get_fernet()
for row_id, session_data in rows:
raw = str(session_data)
if _is_fernet_token(raw):
continue
plaintext = raw if isinstance(session_data, str) else json.dumps(session_data)
encrypted = f.encrypt(plaintext.encode()).decode()
conn.execute(
text("UPDATE user_store_accounts SET session_data = :data WHERE id = :id"),
{"data": encrypted, "id": row_id},
)
def downgrade() -> None:
conn = op.get_bind()
rows = conn.execute(
text("SELECT id, session_data FROM user_store_accounts WHERE session_data IS NOT NULL")
).fetchall()
f = _get_fernet()
for row_id, session_data in rows:
raw = str(session_data)
if not _is_fernet_token(raw):
continue
decrypted = f.decrypt(raw.encode()).decode()
conn.execute(
text("UPDATE user_store_accounts SET session_data = :data WHERE id = :id"),
{"data": decrypted, "id": row_id},
)
# Revert column type from TEXT back to JSON
op.alter_column(
"user_store_accounts",
"session_data",
type_=sa.JSON(),
existing_type=sa.Text(),
existing_nullable=True,
postgresql_using="session_data::json",
)
@@ -1,114 +0,0 @@
"""Add Better-Auth tables and extend users table.
Creates sessions, accounts, and verifications tables for Better-Auth.
Adds email_verified and image columns to existing users table.
Migrates password hashes from users.hashed_password to accounts.password.
Revision ID: 002_better_auth_tables
Revises: 001_encrypt_session_data
Create Date: 2026-03-28
"""
import sqlalchemy as sa
from sqlalchemy import text
from alembic import op
revision = "002_better_auth_tables"
down_revision = "001_encrypt_session_data"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# --- Extend users table for Better-Auth compatibility ---
# Guard: on a fresh DB Base.metadata.create_all (called in env.py after migrations)
# creates the users table with all columns, so migration 002 must not re-run add_column.
if inspector.has_table("users"):
existing_user_cols = [c["name"] for c in inspector.get_columns("users")]
if "email_verified" not in existing_user_cols:
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"))
if "image" not in existing_user_cols:
op.add_column("users", sa.Column("image", sa.Text(), nullable=True))
# --- Create sessions table ---
if not inspector.has_table("sessions"):
op.create_table(
"sessions",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("token", sa.Text(), nullable=False),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ip_address", sa.Text(), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sessions_token", "sessions", ["token"], unique=True)
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
# --- Create accounts table ---
if not inspector.has_table("accounts"):
op.create_table(
"accounts",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("user_id", sa.Text(), nullable=False),
sa.Column("account_id", sa.Text(), nullable=False),
sa.Column("provider_id", sa.Text(), nullable=False),
sa.Column("access_token", sa.Text(), nullable=True),
sa.Column("refresh_token", sa.Text(), nullable=True),
sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("refresh_token_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("scope", sa.Text(), nullable=True),
sa.Column("id_token", sa.Text(), nullable=True),
sa.Column("password", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_accounts_user_id", "accounts", ["user_id"])
# --- Create verifications table ---
if not inspector.has_table("verifications"):
op.create_table(
"verifications",
sa.Column("id", sa.Text(), nullable=False),
sa.Column("identifier", sa.Text(), nullable=False),
sa.Column("value", sa.Text(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.PrimaryKeyConstraint("id"),
)
# --- Migrate existing password hashes to accounts table ---
# Only run on existing (non-fresh) DBs that already have users table with data
if inspector.has_table("users"):
users = conn.execute(
text("SELECT id, hashed_password FROM users WHERE hashed_password IS NOT NULL")
).fetchall()
for user_id, hashed_password in users:
user_id_str = str(user_id)
conn.execute(
text(
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
),
{"user_id": user_id_str, "account_id": user_id_str, "password": hashed_password},
)
def downgrade() -> None:
op.execute(text("DROP INDEX IF EXISTS ix_accounts_user_id"))
op.execute(text("DROP TABLE IF EXISTS verifications"))
op.execute(text("DROP TABLE IF EXISTS accounts"))
op.execute(text("DROP INDEX IF EXISTS ix_sessions_user_id"))
op.execute(text("DROP INDEX IF EXISTS ix_sessions_token"))
op.execute(text("DROP TABLE IF EXISTS sessions"))
op.execute(text("ALTER TABLE users DROP COLUMN IF EXISTS image"))
op.execute(text("ALTER TABLE users DROP COLUMN IF EXISTS email_verified"))
@@ -1,43 +0,0 @@
"""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:
conn = op.get_bind()
inspector = sa.inspect(conn)
# Fresh DB — nothing to alter
if not inspector.has_table("users"):
return
cols = {c["name"]: c for c in inspector.get_columns("users")}
if "hashed_password" in cols and not cols["hashed_password"]["nullable"]:
op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=True)
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if not inspector.has_table("users"):
return
cols = {c["name"]: c for c in inspector.get_columns("users")}
if "hashed_password" in cols and cols["hashed_password"]["nullable"]:
op.alter_column("users", "hashed_password", existing_type=sa.String(255), nullable=False)
@@ -1,136 +0,0 @@
"""Fix users.id UUID->text type mismatch for Better-Auth compatibility.
Better-Auth generates nanoid-style text IDs (e.g. pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI),
but the users table was using PostgreSQL uuid type. When Better-Auth tries to INSERT
a new user, Postgres throws:
ERROR: invalid input syntax for type uuid: "pGud2ln2WAFHC0KYjBVKR4Rc7mM8OcTI"
The sessions, accounts, and verifications tables already use text IDs — only users,
user_store_accounts.user_id, and purchases.user_id needed fixing.
Revision ID: 004_fix_user_id_text
Revises: 003_make_users_hashed_password_nullable
Create Date: 2026-03-31
"""
import sqlalchemy as sa
from sqlalchemy import text
from alembic import op
revision = "004_fix_user_id_text"
down_revision = "003_make_users_hashed_password_nullable"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# Fresh DB — no tables yet, nothing to convert
if not inspector.has_table("users"):
return
# Check if already TEXT (Base.metadata.create_all uses TEXT for fresh DB)
users_cols = {c["name"]: c for c in inspector.get_columns("users")}
if "id" in users_cols:
id_type = str(users_cols["id"]["type"]).lower()
if "text" in id_type and "uuid" not in id_type:
return # already TEXT — nothing to do
# Step 1: Drop existing FK constraints (ignore if they don't exist)
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
# Step 2: Alter users.id from uuid to text
op.alter_column(
"users",
"id",
type_=sa.Text(),
existing_type=sa.UUID(),
postgresql_using="id::text",
)
# Step 3: Alter user_store_accounts.user_id from uuid to text
op.alter_column(
"user_store_accounts",
"user_id",
type_=sa.Text(),
existing_type=sa.UUID(),
postgresql_using="user_id::text",
)
# Step 4: Alter purchases.user_id from uuid to text
op.alter_column(
"purchases",
"user_id",
type_=sa.Text(),
existing_type=sa.UUID(),
postgresql_using="user_id::text",
)
# Step 5: Re-add FK constraints
op.execute(
text(
"ALTER TABLE user_store_accounts "
"ADD CONSTRAINT user_store_accounts_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
)
)
op.execute(
text(
"ALTER TABLE purchases "
"ADD CONSTRAINT purchases_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
)
)
def downgrade() -> None:
# Drop FK constraints
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
# Revert users.id from text to uuid
op.alter_column(
"users",
"id",
type_=sa.UUID(),
existing_type=sa.Text(),
postgresql_using="id::uuid",
)
# Revert user_store_accounts.user_id from text to uuid
op.alter_column(
"user_store_accounts",
"user_id",
type_=sa.UUID(),
existing_type=sa.Text(),
postgresql_using="user_id::uuid",
)
# Revert purchases.user_id from text to uuid
op.alter_column(
"purchases",
"user_id",
type_=sa.UUID(),
existing_type=sa.Text(),
postgresql_using="user_id::uuid",
)
# Re-add FK constraints (PostgreSQL will auto-name them)
op.execute(
text(
"ALTER TABLE user_store_accounts "
"ADD CONSTRAINT user_store_accounts_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
)
)
op.execute(
text(
"ALTER TABLE purchases "
"ADD CONSTRAINT purchases_user_id_fkey "
"FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE"
)
)
@@ -1,57 +0,0 @@
"""Add email_inbound_token to users.
Revision ID: 005_add_email_inbound_token
Revises: 004_fix_user_id_text
Create Date: 2026-04-02
"""
import secrets
import sqlalchemy as sa
from alembic import op
revision = "005_add_email_inbound_token"
down_revision = "004_fix_user_id_text"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# Guard: on a fresh DB Base.metadata.create_all creates users table with the column already present
if not inspector.has_table("users"):
return
existing_cols = [c["name"] for c in inspector.get_columns("users")]
if "email_inbound_token" in existing_cols:
return
# Add column nullable first so existing rows can be backfilled
op.add_column(
"users",
sa.Column("email_inbound_token", sa.String(22), nullable=True),
)
# Backfill existing users with unique tokens
result = conn.execute(sa.text("SELECT id FROM users WHERE email_inbound_token IS NULL"))
for (user_id,) in result:
token = secrets.token_urlsafe(16)
conn.execute(
sa.text("UPDATE users SET email_inbound_token = :token WHERE id = :id"),
{"token": token, "id": user_id},
)
# Now enforce non-null and unique
op.alter_column("users", "email_inbound_token", nullable=False)
op.create_index(
"ix_users_email_inbound_token",
"users",
["email_inbound_token"],
unique=True,
)
def downgrade() -> None:
op.drop_index("ix_users_email_inbound_token", table_name="users")
op.drop_column("users", "email_inbound_token")
@@ -1,42 +0,0 @@
"""Add server_default to users.email_inbound_token.
Revision ID: 006_email_inbound_token_server_default
Revises: 005_add_email_inbound_token
Create Date: 2026-04-04
"""
import sqlalchemy as sa
from alembic import op
revision = "006_email_inbound_token_server_default"
down_revision = "005_add_email_inbound_token"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# Guard: on a fresh DB Base.metadata.create_all already sets the server_default
if not inspector.has_table("users"):
return
cols = {c["name"]: c for c in inspector.get_columns("users")}
if "email_inbound_token" not in cols:
return
if cols["email_inbound_token"].get("default") is not None:
return
op.alter_column(
"users",
"email_inbound_token",
server_default=sa.text(
"replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
),
)
def downgrade() -> None:
op.alter_column(
"users",
"email_inbound_token",
server_default=None,
)
@@ -1,47 +0,0 @@
"""Bootstrap users table on fresh databases.
On fresh databases, migrations 001-006 skip users-table operations because
the table does not exist yet. Base.metadata.create_all() in env.py is meant
to handle this, but if it fails (import errors, etc.) the table is never
created. This migration creates the users table with raw SQL as a safety net.
Revision ID: 007_bootstrap_users_table
Revises: 006_email_inbound_token_server_default
Create Date: 2026-04-04
"""
import sqlalchemy as sa
from sqlalchemy import text
from alembic import op
revision = "007_bootstrap_users_table"
down_revision = "006_email_inbound_token_server_default"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if inspector.has_table("users"):
return # Table already exists (non-fresh DB or create_all already ran)
conn.execute(text("""
CREATE TABLE users (
id TEXT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
hashed_password VARCHAR(255),
display_name VARCHAR(100),
email_verified BOOLEAN NOT NULL DEFAULT false,
image TEXT,
email_inbound_token VARCHAR(22) NOT NULL UNIQUE
DEFAULT replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_'),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""))
def downgrade() -> None:
op.execute(text("DROP TABLE IF EXISTS users"))
@@ -1,210 +0,0 @@
"""Create domain tables (stores, purchases, coupons, etc.).
Revision ID: 008_create_domain_tables
Revises: 007_bootstrap_users_table
Create Date: 2026-04-04
"""
import sqlalchemy as sa
from sqlalchemy import text
from alembic import op
revision = "008_create_domain_tables"
down_revision = "007_bootstrap_users_table"
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# 1. stores
if not inspector.has_table("stores"):
op.create_table(
"stores",
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
sa.Column("name", sa.String(100), nullable=False),
sa.Column("slug", sa.String(20), nullable=False, unique=True),
sa.Column("logo_url", sa.String(500), nullable=True),
sa.Column("website_url", sa.String(500), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# 2. store_locations
if not inspector.has_table("store_locations"):
op.create_table(
"store_locations",
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False),
sa.Column("address", sa.String(300), nullable=False),
sa.Column("city", sa.String(100), nullable=False),
sa.Column("state", sa.String(2), nullable=False),
sa.Column("zip", sa.String(10), nullable=False),
sa.Column("lat", sa.Float(), nullable=True),
sa.Column("lng", sa.Float(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# 3. normalized_products
if not inspector.has_table("normalized_products"):
op.create_table(
"normalized_products",
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
sa.Column("canonical_name", sa.String(300), nullable=False),
sa.Column("category", sa.String(50), nullable=True),
sa.Column("subcategory", sa.String(100), nullable=True),
sa.Column("brand", sa.String(200), nullable=True),
sa.Column("size", sa.String(50), nullable=True),
sa.Column("size_unit", sa.String(10), nullable=True),
sa.Column("upc_variants", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# 4. purchases
if not inspector.has_table("purchases"):
op.create_table(
"purchases",
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
sa.Column("user_id", sa.Text(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False),
sa.Column("store_location_id", sa.Uuid(), sa.ForeignKey("store_locations.id"), nullable=True),
sa.Column("receipt_id", sa.String(200), nullable=False),
sa.Column("purchase_date", sa.Date(), nullable=False),
sa.Column("total", sa.Numeric(10, 2), nullable=False),
sa.Column("subtotal", sa.Numeric(10, 2), nullable=True),
sa.Column("tax", sa.Numeric(10, 2), nullable=True),
sa.Column("savings_total", sa.Numeric(10, 2), nullable=True),
sa.Column("source_url", sa.String(500), nullable=True),
sa.Column("raw_data", sa.JSON(), nullable=True),
sa.Column("ingested_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("user_id", "store_id", "receipt_id", name="uq_purchase_receipt"),
sa.Index("ix_purchases_user_store", "user_id", "store_id"),
)
# 5. purchase_items
if not inspector.has_table("purchase_items"):
op.create_table(
"purchase_items",
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
sa.Column("purchase_id", sa.Uuid(), sa.ForeignKey("purchases.id"), nullable=False),
sa.Column("product_name_raw", sa.String(300), nullable=False),
sa.Column("upc", sa.String(20), nullable=True),
sa.Column("quantity", sa.Numeric(10, 3), nullable=False),
sa.Column("unit_price", sa.Numeric(10, 2), nullable=False),
sa.Column("extended_price", sa.Numeric(10, 2), nullable=False),
sa.Column("regular_price", sa.Numeric(10, 2), nullable=True),
sa.Column("sale_price", sa.Numeric(10, 2), nullable=True),
sa.Column("coupon_discount", sa.Numeric(10, 2), nullable=True),
sa.Column("loyalty_discount", sa.Numeric(10, 2), nullable=True),
sa.Column("category_raw", sa.String(100), nullable=True),
sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# 6. coupons
if not inspector.has_table("coupons"):
op.create_table(
"coupons",
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False),
sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=True),
sa.Column("title", sa.String(300), nullable=False),
sa.Column("description", sa.String(1000), nullable=True),
sa.Column("discount_type", sa.String(20), nullable=False),
sa.Column("discount_value", sa.Numeric(10, 2), nullable=True),
sa.Column("min_purchase", sa.Numeric(10, 2), nullable=True),
sa.Column("valid_from", sa.Date(), nullable=True),
sa.Column("valid_to", sa.Date(), nullable=True),
sa.Column("requires_clip", sa.Boolean(), server_default=text("false"), nullable=False),
sa.Column("coupon_code", sa.String(100), nullable=True),
sa.Column("source_url", sa.String(500), nullable=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# 7. price_history
if not inspector.has_table("price_history"):
op.create_table(
"price_history",
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=False),
sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False),
sa.Column("observed_date", sa.Date(), nullable=False),
sa.Column("regular_price", sa.Numeric(10, 2), nullable=False),
sa.Column("sale_price", sa.Numeric(10, 2), nullable=True),
sa.Column("loyalty_price", sa.Numeric(10, 2), nullable=True),
sa.Column("coupon_price", sa.Numeric(10, 2), nullable=True),
sa.Column("source", sa.String(20), nullable=False),
sa.Column("purchase_item_id", sa.Uuid(), sa.ForeignKey("purchase_items.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Index("ix_price_history_product_store_date", "normalized_product_id", "store_id", "observed_date"),
)
# 8. shrinkflation_events
if not inspector.has_table("shrinkflation_events"):
op.create_table(
"shrinkflation_events",
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=False),
sa.Column("detected_date", sa.Date(), nullable=False),
sa.Column("old_size", sa.String(50), nullable=False),
sa.Column("new_size", sa.String(50), nullable=False),
sa.Column("old_unit", sa.String(10), nullable=True),
sa.Column("new_unit", sa.String(10), nullable=True),
sa.Column("price_at_old_size", sa.Numeric(10, 2), nullable=True),
sa.Column("price_at_new_size", sa.Numeric(10, 2), nullable=True),
sa.Column("confidence", sa.Numeric(3, 2), server_default=text("1.00"), nullable=False),
sa.Column("notes", sa.String(1000), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
)
# 9. user_store_accounts
if not inspector.has_table("user_store_accounts"):
op.create_table(
"user_store_accounts",
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
sa.Column("user_id", sa.Text(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False),
sa.Column("session_data", sa.JSON(), nullable=True),
sa.Column("session_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.String(20), server_default=text("'active'"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),
)
def downgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
if inspector.has_table("user_store_accounts"):
op.drop_table("user_store_accounts")
if inspector.has_table("shrinkflation_events"):
op.drop_table("shrinkflation_events")
if inspector.has_table("price_history"):
op.drop_table("price_history")
if inspector.has_table("coupons"):
op.drop_table("coupons")
if inspector.has_table("purchase_items"):
op.drop_table("purchase_items")
if inspector.has_table("purchases"):
op.drop_table("purchases")
if inspector.has_table("normalized_products"):
op.drop_table("normalized_products")
if inspector.has_table("store_locations"):
op.drop_table("store_locations")
if inspector.has_table("stores"):
op.drop_table("stores")
@@ -1,38 +0,0 @@
"""Add GIN index on upc_variants and alter column to JSONB.
Revision ID: 009_add_gin_index_upc_variants
Revises: 008_create_domain_tables
Create Date: 2026-04-14
"""
import sqlalchemy as sa
from alembic import op
revision = "009_add_gin_index_upc_variants"
down_revision = "008_create_domain_tables"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.alter_column(
"normalized_products",
"upc_variants",
type_=sa.dialects.postgresql.JSONB(),
postgresql_using="upc_variants::jsonb",
)
op.create_index(
"ix_normalized_products_upc_variants_gin",
"normalized_products",
["upc_variants"],
postgresql_using="gin",
)
def downgrade() -> None:
op.drop_index("ix_normalized_products_upc_variants_gin", table_name="normalized_products")
op.alter_column(
"normalized_products",
"upc_variants",
type_=sa.JSON(),
)
-58
View File
@@ -1,58 +0,0 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "cartsnitch-api"
version = "0.1.0"
description = "CartSnitch API Gateway — public-facing REST API"
requires-python = ">=3.12"
dependencies = [
"fastapi>=0.115.0",
"uvicorn[standard]>=0.30.0",
"pydantic[email]>=2.9.0",
"pydantic-settings>=2.5.0",
"sqlalchemy[asyncio]>=2.0.35",
"asyncpg>=0.30.0",
"alembic>=1.13,<2.0",
"psycopg2>=2.9,<3.0",
"python-jose[cryptography]>=3.3.0",
"passlib[bcrypt]>=1.7.4",
"httpx>=0.27.0",
"redis[hiredis]>=5.2.0",
"cryptography>=43.0.0",
]
[project.optional-dependencies]
dev = [
"pytest>=8.3.0",
"pytest-asyncio>=0.24.0",
"aiosqlite>=0.20.0",
"httpx>=0.27.0",
"ruff>=0.7.0",
"psycopg2-binary>=2.9,<3.0",
]
[tool.hatch.build.targets.wheel]
packages = ["src/cartsnitch_api"]
[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]
[tool.ruff]
target-version = "py312"
line-length = 100
[tool.ruff.lint]
select = ["E", "F", "I", "N", "UP", "B"]
[tool.ruff.lint.per-file-ignores]
"src/cartsnitch_api/**/routes*.py" = ["B008"]
"src/cartsnitch_api/**/dependencies.py" = ["B008"]
[tool.mypy]
python_version = "3.12"
ignore_missing_imports = true
warn_return_any = true
warn_unused_configs = true
-4
View File
@@ -1,4 +0,0 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["local>cartsnitch/.github:renovate-config"]
}
-101
View File
@@ -1,101 +0,0 @@
"""FastAPI dependency injection for authentication.
Validates Better-Auth session tokens from cookies or Bearer header.
Sessions are verified by querying the shared sessions table directly.
"""
from datetime import UTC, datetime
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.config import settings
from cartsnitch_api.database import get_db
# Keep Bearer scheme as optional — Better-Auth primarily uses cookies,
# but we support Bearer tokens for service-to-service or mobile clients.
bearer_scheme = HTTPBearer(auto_error=False)
# Better-Auth session cookie name
SESSION_COOKIE_NAME = "better-auth.session_token"
# Secure prefix used by better-auth on HTTPS deployments
SECURE_SESSION_COOKIE_NAME = "__Secure-better-auth.session_token"
async def _validate_session_token(token: str, db: AsyncSession) -> str:
"""Validate a Better-Auth session token against the sessions table.
Better-Auth stores the raw token in the DB. The cookie/Bearer header
carries the same raw token, so we compare directly.
"""
result = await db.execute(
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
{"token": token},
)
row = result.first()
if not row:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid session token",
)
user_id, expires_at = row
if expires_at.tzinfo is None:
# Treat naive datetimes as UTC
expires_at = expires_at.replace(tzinfo=UTC)
if expires_at < datetime.now(UTC):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Session expired",
)
return str(user_id)
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
) -> str:
"""Extract and validate the session token from cookie or Authorization header.
Checks in order:
1. Better-Auth session cookie (primary — web clients)
2. Bearer token in Authorization header (fallback — API clients)
"""
token: str | None = None
# 1. Check session cookie — prefer __Secure- variant (HTTPS) over plain (HTTP dev)
cookie_token = request.cookies.get(SECURE_SESSION_COOKIE_NAME) or request.cookies.get(
SESSION_COOKIE_NAME
)
if cookie_token:
# Better-Auth cookie format is "token.sessionId" — extract just the token part
token = cookie_token.split(".")[0] if "." in cookie_token else cookie_token
# 2. Fall back to Bearer header
if not token and credentials:
# Callers might pass the compound value here too
raw = credentials.credentials
token = raw.split(".")[0] if "." in raw else raw
if not token:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
)
user_id = await _validate_session_token(token, db)
request.state.user_id = user_id
return user_id
async def verify_service_key(x_service_key: str = Header()) -> None:
if x_service_key != settings.service_key:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Invalid service key",
)
-31
View File
@@ -1,31 +0,0 @@
"""JWT token creation and validation."""
from datetime import UTC, datetime, timedelta
from typing import Any, cast
from uuid import UUID
from jose import JWTError, jwt
from cartsnitch_api.config import settings
def create_access_token(user_id: UUID) -> str:
expire = datetime.now(UTC) + timedelta(minutes=settings.jwt_access_token_expire_minutes)
payload = {"sub": str(user_id), "exp": expire, "type": "access"}
return cast(str, jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm))
def create_refresh_token(user_id: UUID) -> str:
expire = datetime.now(UTC) + timedelta(days=settings.jwt_refresh_token_expire_days)
payload = {"sub": str(user_id), "exp": expire, "type": "refresh"}
return cast(str, jwt.encode(payload, settings.jwt_secret_key, algorithm=settings.jwt_algorithm))
def decode_token(token: str) -> dict:
try:
return cast(
dict[str, Any],
jwt.decode(token, settings.jwt_secret_key, algorithms=[settings.jwt_algorithm]),
)
except JWTError as e:
raise ValueError(f"Invalid token: {e}") from e
-11
View File
@@ -1,11 +0,0 @@
"""Password hashing and verification with bcrypt."""
import bcrypt
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
-67
View File
@@ -1,67 +0,0 @@
"""Auth routes: user profile management.
Registration, login, refresh, and session management are handled by
the Better-Auth service (auth/). This router provides user profile
endpoints that query our own user data from the shared database.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.models import User
from cartsnitch_api.schemas import (
UpdateUserRequest,
UserResponse,
)
from cartsnitch_api.services.auth import AuthService
router = APIRouter(prefix="/auth", tags=["auth"])
@router.get("/me", response_model=UserResponse)
async def get_me(
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AuthService(db)
try:
return await svc.get_user(user_id)
except LookupError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
) from None
@router.patch("/me", response_model=UserResponse)
async def update_me(
body: UpdateUserRequest,
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AuthService(db)
try:
return await svc.update_user(user_id, email=body.email, display_name=body.display_name)
except LookupError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
) from None
except ValueError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
async def delete_me(
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AuthService(db)
try:
await svc.delete_user(user_id)
except LookupError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
) from None
-108
View File
@@ -1,108 +0,0 @@
"""Redis/DragonflyDB caching helpers."""
import logging
from typing import TYPE_CHECKING
import redis.asyncio as redis
from redis.asyncio import Redis
from cartsnitch_api.config import settings
if TYPE_CHECKING:
from cartsnitch_api.config import Settings
logger = logging.getLogger(__name__)
_redis: "Redis | None" = None
def get_settings() -> "Settings":
return settings
async def init_redis() -> None:
global _redis
_redis = redis.from_url(settings.redis_url)
await _redis.ping()
async def close_redis() -> None:
global _redis
if _redis is not None:
await _redis.aclose()
_redis = None
def get_redis() -> Redis | None:
return _redis
class CacheClient:
"""Redis/DragonflyDB caching with connection pooling.
Will be used for expensive queries: price trends, product comparisons.
Cache invalidation via Redis pub/sub events from other services.
"""
def __init__(self) -> None:
self._pool: redis.ConnectionPool | None = None
self._client: redis.Redis | None = None
async def initialize(self) -> None:
"""Initialize the Redis connection pool."""
self._pool = redis.ConnectionPool.from_url(
settings.redis_url,
max_connections=20,
decode_responses=True,
)
self._client = redis.Redis(connection_pool=self._pool)
async def close(self) -> None:
"""Close the Redis connection pool."""
if self._client:
await self._client.aclose()
if self._pool:
await self._pool.aclose()
async def get(self, key: str) -> str | None:
if not self._client:
return None
return await self._client.get(key)
async def set(self, key: str, value: str, ttl_seconds: int = 300) -> None:
if not self._client:
return
await self._client.set(key, value, ex=ttl_seconds)
async def delete(self, key: str) -> None:
if not self._client:
return
await self._client.delete(key)
async def invalidate_price_cache(self, product_id: str) -> None:
"""Invalidate all price-related cache entries for a product."""
if not self._client:
return
pattern = f"price:*:{product_id}"
await self._delete_pattern(pattern)
async def invalidate_product_cache(self, product_id: str) -> None:
"""Invalidate the product detail cache entry."""
if not self._client:
return
await self._client.delete(f"product:{product_id}")
async def _delete_pattern(self, pattern: str) -> None:
"""Delete all keys matching a pattern using SCAN."""
if not self._client:
return
cursor = 0
while True:
cursor, keys = await self._client.scan(cursor=cursor, match=pattern, count=100)
if keys:
await self._client.delete(*keys)
if cursor == 0:
break
cache_client = CacheClient()
-84
View File
@@ -1,84 +0,0 @@
import base64
from pydantic import AliasChoices, Field, model_validator
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
model_config = {"env_prefix": "CARTSNITCH_"}
database_url: str = Field(
default="postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
validation_alias=AliasChoices("CARTSNITCH_DATABASE_URL", "DATABASE_URL"),
)
redis_url: str = "redis://localhost:6379/0"
jwt_secret_key: str
jwt_algorithm: str = "HS256"
jwt_access_token_expire_minutes: int = 15
jwt_refresh_token_expire_days: int = 7
service_key: str
fernet_key: str
auth_service_url: str = "http://auth:3001"
cors_origins: list[str] = ["http://localhost:3000", "https://cartsnitch.com"]
receiptwitness_url: str = "http://receiptwitness:8001"
stickershock_url: str = "http://stickershock:8002"
clipartist_url: str = "http://clipartist:8003"
shrinkray_url: str = "http://shrinkray:8004"
rate_limit_requests: int = 60
rate_limit_window_seconds: int = 60
rate_limit_auth_requests: int = 5
rate_limit_auth_window_seconds: int = 60
rate_limit_redis_enabled: bool = True
rate_limit_enabled: bool = True
_PLACEHOLDER_VALUES = {"change-me-in-production"}
@model_validator(mode="after")
def validate_secrets(self):
if not self.jwt_secret_key or self.jwt_secret_key in self._PLACEHOLDER_VALUES:
raise ValueError(
"CARTSNITCH_JWT_SECRET_KEY must be set to a secure value. "
'Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"'
)
if not self.service_key or self.service_key in self._PLACEHOLDER_VALUES:
raise ValueError(
"CARTSNITCH_SERVICE_KEY must be set to a secure value. "
'Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"'
)
if not self.fernet_key or self.fernet_key in self._PLACEHOLDER_VALUES:
raise ValueError(
"CARTSNITCH_FERNET_KEY must be set to a valid Fernet key. "
"Generate one with: python -c "
"'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'"
)
try:
decoded = base64.urlsafe_b64decode(self.fernet_key.encode())
if len(decoded) != 32:
raise ValueError
except Exception:
raise ValueError(
"CARTSNITCH_FERNET_KEY must be a valid Fernet key "
"(32 bytes, url-safe base64 encoded). "
"Generate one with: python -c "
"'from cryptography.fernet import Fernet; "
"print(Fernet.generate_key().decode())'"
) from None
return self
@model_validator(mode="after")
def normalize_database_url(self):
"""Normalize postgresql:// → postgresql+asyncpg:// for the asyncpg driver."""
if self.database_url.startswith("postgresql://"):
self.database_url = self.database_url.replace(
"postgresql://", "postgresql+asyncpg://", 1
)
return self
settings = Settings()
-60
View File
@@ -1,60 +0,0 @@
"""Database session management for the API gateway."""
from collections.abc import AsyncGenerator
from typing import TYPE_CHECKING
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from cartsnitch_api.config import settings
if TYPE_CHECKING:
from sqlalchemy.engine import Engine
_engine: "Engine | None" = None
async_session_factory: async_sessionmaker[AsyncSession] | None = None
def create_db_engine():
return create_async_engine(
settings.database_url,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
pool_recycle=3600,
echo=False,
)
async def init_db() -> None:
global _engine, async_session_factory
_engine = create_db_engine()
async_session_factory = async_sessionmaker(_engine, class_=AsyncSession, expire_on_commit=False)
async def close_db() -> None:
global _engine, async_session_factory
if _engine is not None:
await _engine.dispose()
_engine = None
async_session_factory = None
def get_engine():
return _engine
async def get_db() -> AsyncGenerator[AsyncSession, None]:
if async_session_factory is None:
raise RuntimeError("Database not initialized. Call init_db() first.")
async with async_session_factory() as session:
yield session
# Backward compatibility: module-level engine proxy that delegates to _engine
def __getattr__(name: str):
if name == "engine":
if _engine is None:
raise RuntimeError("Database not initialized. Call init_db() first.")
return _engine
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
-77
View File
@@ -1,77 +0,0 @@
"""FastAPI app factory for CartSnitch API Gateway."""
from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI
from cartsnitch_api.auth.routes import router as auth_router
from cartsnitch_api.cache import cache_client
from cartsnitch_api.database import dispose_engine
from cartsnitch_api.middleware.cors import add_cors_middleware
from cartsnitch_api.middleware.error_handler import add_error_handlers, add_error_monitor_middleware
from cartsnitch_api.middleware.rate_limit import add_rate_limit_middleware
from cartsnitch_api.middleware.audit import add_audit_middleware
from cartsnitch_api.routes.alerts import router as alerts_router
from cartsnitch_api.routes.coupons import router as coupons_router
from cartsnitch_api.routes.health import router as health_router
from cartsnitch_api.routes.prices import router as prices_router
from cartsnitch_api.routes.products import router as products_router
from cartsnitch_api.routes.public import router as public_router
from cartsnitch_api.routes.purchases import router as purchases_router
from cartsnitch_api.routes.scraping import router as scraping_router
from cartsnitch_api.routes.shopping import router as shopping_router
from cartsnitch_api.routes.stores import router as stores_router
from cartsnitch_api.routes.user import router as user_router
@asynccontextmanager
async def lifespan(app: FastAPI):
from cartsnitch_api.database import init_db, close_db
from cartsnitch_api.cache import init_redis, close_redis
await init_db()
await init_redis()
yield
await close_redis()
await close_db()
def create_app() -> FastAPI:
app = FastAPI(
title="CartSnitch API",
description="Grocery price tracking and shrinkflation detection API",
version="0.1.0",
lifespan=lifespan,
)
# Middleware (order matters — outermost first)
add_cors_middleware(app)
add_error_monitor_middleware(app)
add_rate_limit_middleware(app)
add_audit_middleware(app)
# Exception handlers
add_error_handlers(app)
# Routers
app.include_router(health_router)
app.include_router(auth_router)
# Data endpoints mounted under /api/v1
v1_router = APIRouter(prefix="/api/v1")
v1_router.include_router(user_router)
v1_router.include_router(stores_router)
v1_router.include_router(purchases_router)
v1_router.include_router(products_router)
v1_router.include_router(prices_router)
v1_router.include_router(coupons_router)
v1_router.include_router(shopping_router)
v1_router.include_router(alerts_router)
v1_router.include_router(scraping_router)
v1_router.include_router(public_router)
app.include_router(v1_router)
return app
app = create_app()
@@ -1,64 +0,0 @@
"""Audit logging middleware for sensitive API operations.
Logs structured JSON for POST/PUT/PATCH/DELETE requests and GET /auth/me.
Never logs request bodies, response bodies, Authorization headers, or cookie values.
"""
import json
import logging
import time
from collections.abc import Awaitable, Callable
from fastapi import FastAPI, Request
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger("cartsnitch_api.audit")
HEALTH_PATHS = {"/health", "/healthz", "/ready"}
class AuditMiddleware(BaseHTTPMiddleware):
"""Middleware to log structured audit events for sensitive operations."""
async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable],
):
if request.method == "OPTIONS" or request.url.path in HEALTH_PATHS:
return await call_next(request)
method = request.method
path = request.url.path
is_sensitive_write = method in {"POST", "PUT", "PATCH", "DELETE"}
is_auth_me_read = method == "GET" and path == "/auth/me"
if not (is_sensitive_write or is_auth_me_read):
return await call_next(request)
start = time.perf_counter()
response = await call_next(request)
duration_ms = (time.perf_counter() - start) * 1000
user_id = getattr(request.state, "user_id", None)
client_ip = request.client.host if request.client else "unknown"
log_entry = {
"event": "audit",
"timestamp": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
"user_id": user_id,
"method": method,
"path": path,
"client_ip": client_ip,
"status_code": response.status_code,
"duration_ms": round(duration_ms, 2),
}
logger.info(json.dumps(log_entry))
return response
def add_audit_middleware(app: FastAPI) -> None:
app.add_middleware(AuditMiddleware)
-16
View File
@@ -1,16 +0,0 @@
"""CORS middleware configuration."""
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from cartsnitch_api.config import settings
def add_cors_middleware(app: FastAPI) -> None:
app.add_middleware(
CORSMiddleware,
allow_origins=settings.cors_origins,
allow_credentials=True,
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allow_headers=["Content-Type", "Authorization", "Accept", "Origin", "X-Requested-With"],
)
@@ -1,190 +0,0 @@
"""Structured error responses and error monitoring.
Ensures all errors return a consistent JSON shape and never leak stack traces.
Provides hooks for error monitoring/alerting.
"""
import logging
import time
import traceback
from collections.abc import Awaitable, Callable
from fastapi import FastAPI, Request, status
from fastapi.exceptions import RequestValidationError
from fastapi.responses import JSONResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
from starlette.middleware.base import BaseHTTPMiddleware
logger = logging.getLogger("cartsnitch_api.errors")
def _error_response(
status_code: int,
detail: str,
code: str | None = None,
errors: list[dict] | None = None,
) -> JSONResponse:
"""Build a consistent error response."""
body: dict = {"detail": detail}
if code:
body["code"] = code
if errors:
body["errors"] = errors
return JSONResponse(status_code=status_code, content=body)
def add_error_handlers(app: FastAPI) -> None:
"""Register global exception handlers for consistent error responses."""
@app.exception_handler(RequestValidationError)
async def validation_error_handler(
request: Request, exc: RequestValidationError
) -> JSONResponse:
"""Return 422 with structured field-level error details."""
field_errors = []
for err in exc.errors():
loc = err.get("loc", ())
field_errors.append(
{
"field": ".".join(str(p) for p in loc[1:]) if len(loc) > 1 else str(loc),
"message": err.get("msg", "Invalid value"),
"type": err.get("type", "value_error"),
}
)
return _error_response(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Validation error",
code="VALIDATION_ERROR",
errors=field_errors,
)
@app.exception_handler(StarletteHTTPException)
async def http_exception_handler(request: Request, exc: StarletteHTTPException) -> JSONResponse:
"""Wrap HTTP exceptions (Starlette and FastAPI) in consistent format."""
detail = exc.detail if isinstance(exc.detail, str) else str(exc.detail)
return _error_response(
status_code=exc.status_code,
detail=detail,
code=_status_to_code(exc.status_code),
)
@app.exception_handler(Exception)
async def unhandled_exception_handler(request: Request, exc: Exception) -> JSONResponse:
"""Catch-all: log full traceback, return safe 500 to client."""
logger.error(
"Unhandled exception on %s %s: %s\n%s",
request.method,
request.url.path,
exc,
traceback.format_exc(),
)
_notify_error_monitor(request, exc)
return _error_response(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Internal server error",
code="INTERNAL_ERROR",
)
def _status_to_code(status_code: int) -> str:
"""Map HTTP status code to a machine-readable error code."""
mapping = {
400: "BAD_REQUEST",
401: "UNAUTHORIZED",
403: "FORBIDDEN",
404: "NOT_FOUND",
409: "CONFLICT",
422: "VALIDATION_ERROR",
429: "RATE_LIMITED",
502: "BAD_GATEWAY",
503: "SERVICE_UNAVAILABLE",
}
return mapping.get(status_code, f"HTTP_{status_code}")
# ---------- Error Monitoring ----------
class _ErrorMonitor:
"""Simple error counter for monitoring and alerting hooks.
Tracks error counts and rates. In production, this would forward
to an external monitoring service (Prometheus, Sentry, etc.).
"""
def __init__(self) -> None:
self.error_counts: dict[int, int] = {}
self.recent_5xx: list[dict] = []
self._max_recent = 100
def record(self, status_code: int, path: str, method: str, error: str | None = None) -> None:
self.error_counts[status_code] = self.error_counts.get(status_code, 0) + 1
if status_code >= 500:
entry = {
"timestamp": time.time(),
"status": status_code,
"path": path,
"method": method,
"error": error,
}
self.recent_5xx.append(entry)
if len(self.recent_5xx) > self._max_recent:
self.recent_5xx = self.recent_5xx[-self._max_recent :]
logger.warning(
"5xx error recorded: %s %s -> %d (%s)",
method,
path,
status_code,
error or "unknown",
)
def get_stats(self) -> dict:
return {
"error_counts": dict(self.error_counts),
"recent_5xx_count": len(self.recent_5xx),
}
_monitor = _ErrorMonitor()
def get_error_monitor() -> _ErrorMonitor:
"""Access the global error monitor (for health/metrics endpoints)."""
return _monitor
def _notify_error_monitor(request: Request, exc: Exception) -> None:
"""Record unhandled exception in the error monitor."""
_monitor.record(
status_code=500,
path=request.url.path,
method=request.method,
error=str(exc)[:200],
)
class ErrorMonitorMiddleware(BaseHTTPMiddleware):
"""Middleware to track all 4xx/5xx responses for monitoring."""
async def dispatch(
self,
request: Request,
call_next: Callable[[Request], Awaitable],
):
response = await call_next(request)
if response.status_code >= 400:
_monitor.record(
status_code=response.status_code,
path=request.url.path,
method=request.method,
)
return response
def add_error_monitor_middleware(app: FastAPI) -> None:
app.add_middleware(ErrorMonitorMiddleware)
@@ -1,193 +0,0 @@
"""Rate limiting middleware for public and authenticated endpoints.
Uses in-memory sliding window as fallback, Redis/DragonflyDB when available.
Per-IP limiting on public endpoints, per-token limiting on authenticated endpoints.
"""
import hashlib
import logging
import time
import uuid
from collections import defaultdict
from threading import Lock
from typing import Protocol
from fastapi import FastAPI, Request, status
from fastapi.responses import JSONResponse
from redis.asyncio import Redis, RedisError
from starlette.middleware.base import BaseHTTPMiddleware
from cartsnitch_api.config import settings
logger = logging.getLogger(__name__)
class RateLimitBackend(Protocol):
"""Protocol for rate limit backends."""
async def is_allowed(self, key: str) -> tuple[bool, int, int]:
"""Check if request is allowed. Returns (allowed, remaining, retry_after)."""
class InMemorySlidingWindow:
"""Thread-safe in-memory sliding window rate limiter."""
def __init__(self, max_requests: int, window_seconds: int) -> None:
self.max_requests = max_requests
self.window_seconds = window_seconds
self._hits: dict[str, list[float]] = defaultdict(list)
self._lock = Lock()
async def is_allowed(self, key: str) -> tuple[bool, int, int]:
"""Check if request is allowed. Returns (allowed, remaining, retry_after)."""
now = time.monotonic()
cutoff = now - self.window_seconds
with self._lock:
self._hits[key] = [t for t in self._hits[key] if t > cutoff]
current_count = len(self._hits[key])
if current_count >= self.max_requests:
retry_after = int(self._hits[key][0] - cutoff) + 1
return False, 0, retry_after
self._hits[key].append(now)
remaining = self.max_requests - current_count - 1
return True, remaining, 0
class RedisSlidingWindow:
"""Redis-backed sliding window rate limiter using sorted sets."""
def __init__(self, redis: Redis, max_requests: int, window_seconds: int) -> None:
self.redis = redis
self.max_requests = max_requests
self.window_seconds = window_seconds
async def is_allowed(self, key: str) -> tuple[bool, int, int]:
"""Check if request is allowed. Returns (allowed, remaining, retry_after)."""
try:
now = time.monotonic()
cutoff = now - self.window_seconds
now_ms = int(now * 1000)
cutoff_ms = int(cutoff * 1000)
pipe = self.redis.pipeline()
pipe.zremrangebyscore(key, 0, cutoff_ms)
pipe.zcard(key)
results = await pipe.execute()
current_count = results[1]
if current_count >= self.max_requests:
oldest = await self.redis.zrange(key, 0, 0, withscores=True)
if oldest:
retry_after = int((oldest[0][1] - cutoff) / 1000) + 1
else:
retry_after = self.window_seconds
return False, 0, retry_after
member = f"{now_ms}:{uuid.uuid4().hex[:8]}"
pipe = self.redis.pipeline()
pipe.zadd(key, {member: now_ms})
pipe.expire(key, self.window_seconds)
await pipe.execute()
remaining = self.max_requests - current_count - 1
return True, remaining, 0
except RedisError as e:
logger.warning("Redis rate limit error, falling back to in-memory: %s", e)
in_memory = InMemorySlidingWindow(self.max_requests, self.window_seconds)
return await in_memory.is_allowed(key)
_redis_client: Redis | None = None
_use_redis = False
if settings.rate_limit_redis_enabled:
try:
_redis_client = Redis.from_url(settings.redis_url)
_use_redis = True
logger.info("Rate limiting will use Redis at %s", settings.redis_url)
except Exception as e:
logger.warning("Failed to connect to Redis for rate limiting, using in-memory: %s", e)
_use_redis = False
if _use_redis and _redis_client:
_public_limiter = RedisSlidingWindow(
_redis_client, settings.rate_limit_requests, settings.rate_limit_window_seconds
)
_auth_limiter = RedisSlidingWindow(
_redis_client, settings.rate_limit_requests * 5, settings.rate_limit_window_seconds
)
_auth_strict_limiter = RedisSlidingWindow(
_redis_client, settings.rate_limit_auth_requests, settings.rate_limit_auth_window_seconds
)
else:
_public_limiter = InMemorySlidingWindow(
settings.rate_limit_requests, settings.rate_limit_window_seconds
)
_auth_limiter = InMemorySlidingWindow(
settings.rate_limit_requests * 5, settings.rate_limit_window_seconds
)
_auth_strict_limiter = InMemorySlidingWindow(
settings.rate_limit_auth_requests, settings.rate_limit_auth_window_seconds
)
def _get_client_ip(request: Request) -> str:
"""Extract client IP, respecting X-Forwarded-For behind a reverse proxy."""
forwarded = request.headers.get("x-forwarded-for")
if forwarded:
return forwarded.split(",")[0].strip()
return request.client.host if request.client else "unknown"
def _get_rate_limit_key(request: Request) -> tuple[str, RateLimitBackend]:
"""Determine rate limit key and which limiter to use."""
if request.url.path.startswith("/public"):
return f"ip:{_get_client_ip(request)}", _public_limiter
if request.url.path.startswith("/auth/") and request.method == "POST":
return f"ip:{_get_client_ip(request)}", _auth_strict_limiter
auth_header = request.headers.get("authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:]
token_hash = hashlib.sha256(token.encode()).hexdigest()
return f"token:{token_hash}", _auth_limiter
return f"ip:{_get_client_ip(request)}", _public_limiter
class RateLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
if not settings.rate_limit_enabled or request.url.path == "/health":
return await call_next(request)
key, limiter = _get_rate_limit_key(request)
allowed, remaining, retry_after = await limiter.is_allowed(key)
if not allowed:
return JSONResponse(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
content={
"detail": "Rate limit exceeded",
"code": "RATE_LIMITED",
},
headers={
"Retry-After": str(retry_after),
"X-RateLimit-Limit": str(limiter.max_requests),
"X-RateLimit-Remaining": "0",
},
)
response = await call_next(request)
response.headers["X-RateLimit-Limit"] = str(limiter.max_requests)
response.headers["X-RateLimit-Remaining"] = str(remaining)
return response
def add_rate_limit_middleware(app: FastAPI) -> None:
app.add_middleware(RateLimitMiddleware)
-26
View File
@@ -1,26 +0,0 @@
"""SQLAlchemy ORM models — re-exports all models for convenience."""
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from cartsnitch_api.models.coupon import Coupon
from cartsnitch_api.models.price import PriceHistory
from cartsnitch_api.models.product import NormalizedProduct
from cartsnitch_api.models.purchase import Purchase, PurchaseItem
from cartsnitch_api.models.shrinkflation import ShrinkflationEvent
from cartsnitch_api.models.store import Store, StoreLocation
from cartsnitch_api.models.user import User, UserStoreAccount
__all__ = [
"Base",
"TimestampMixin",
"UUIDPrimaryKeyMixin",
"Store",
"StoreLocation",
"User",
"UserStoreAccount",
"Purchase",
"PurchaseItem",
"NormalizedProduct",
"PriceHistory",
"Coupon",
"ShrinkflationEvent",
]
-42
View File
@@ -1,42 +0,0 @@
"""Coupon model."""
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import TYPE_CHECKING
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import DiscountType
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from cartsnitch_api.models.product import NormalizedProduct
from cartsnitch_api.models.store import Store
class Coupon(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""A coupon or deal for a product at a store."""
__tablename__ = "coupons"
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
normalized_product_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("normalized_products.id")
)
title: Mapped[str] = mapped_column(String(300), nullable=False)
description: Mapped[str | None] = mapped_column(String(1000))
discount_type: Mapped[DiscountType] = mapped_column(String(20), nullable=False)
discount_value: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
min_purchase: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
valid_from: Mapped[date | None] = mapped_column(Date)
valid_to: Mapped[date | None] = mapped_column(Date)
requires_clip: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
coupon_code: Mapped[str | None] = mapped_column(String(100))
source_url: Mapped[str | None] = mapped_column(String(500))
scraped_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
# Relationships
store: Mapped["Store"] = relationship(back_populates="coupons")
normalized_product: Mapped["NormalizedProduct | None"] = relationship(back_populates="coupons")
-50
View File
@@ -1,50 +0,0 @@
"""PriceHistory model — tracks product prices over time."""
import uuid
from datetime import date
from decimal import Decimal
from typing import TYPE_CHECKING
from sqlalchemy import Date, ForeignKey, Index, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import PriceSource
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from cartsnitch_api.models.product import NormalizedProduct
from cartsnitch_api.models.purchase import PurchaseItem
from cartsnitch_api.models.store import Store
class PriceHistory(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""A single price observation for a product at a store on a date."""
__tablename__ = "price_history"
__table_args__ = (
Index(
"ix_price_history_product_store_date",
"normalized_product_id",
"store_id",
"observed_date",
),
)
normalized_product_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("normalized_products.id"), nullable=False
)
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
observed_date: Mapped[date] = mapped_column(Date, nullable=False)
regular_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
sale_price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
loyalty_price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
coupon_price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
source: Mapped[PriceSource] = mapped_column(String(20), nullable=False)
purchase_item_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("purchase_items.id"))
# Relationships
normalized_product: Mapped["NormalizedProduct"] = relationship(back_populates="price_histories")
store: Mapped["Store"] = relationship(back_populates="price_histories")
purchase_item: Mapped["PurchaseItem | None"] = relationship(
back_populates="price_history_entries"
)
-39
View File
@@ -1,39 +0,0 @@
"""NormalizedProduct model — the canonical product identity."""
from typing import TYPE_CHECKING
from sqlalchemy import JSON, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import ProductCategory, SizeUnit
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from cartsnitch_api.models.coupon import Coupon
from cartsnitch_api.models.price import PriceHistory
from cartsnitch_api.models.purchase import PurchaseItem
from cartsnitch_api.models.shrinkflation import ShrinkflationEvent
class NormalizedProduct(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Canonical product identity — matches products across retailers."""
__tablename__ = "normalized_products"
canonical_name: Mapped[str] = mapped_column(String(300), nullable=False)
category: Mapped[ProductCategory | None] = mapped_column(String(50))
subcategory: Mapped[str | None] = mapped_column(String(100))
brand: Mapped[str | None] = mapped_column(String(200))
size: Mapped[str | None] = mapped_column(String(50))
size_unit: Mapped[SizeUnit | None] = mapped_column(String(10))
upc_variants: Mapped[list[str] | None] = mapped_column(JSON, default=list)
# Relationships
purchase_items: Mapped[list["PurchaseItem"]] = relationship(back_populates="normalized_product")
price_histories: Mapped[list["PriceHistory"]] = relationship(
back_populates="normalized_product"
)
coupons: Mapped[list["Coupon"]] = relationship(back_populates="normalized_product")
shrinkflation_events: Mapped[list["ShrinkflationEvent"]] = relationship(
back_populates="normalized_product"
)
-91
View File
@@ -1,91 +0,0 @@
"""Purchase and PurchaseItem models."""
import uuid
from datetime import date, datetime
from decimal import Decimal
from typing import TYPE_CHECKING
from sqlalchemy import (
JSON,
Date,
DateTime,
ForeignKey,
Index,
Numeric,
String,
UniqueConstraint,
func,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from cartsnitch_api.models.price import PriceHistory
from cartsnitch_api.models.product import NormalizedProduct
from cartsnitch_api.models.store import Store, StoreLocation
from cartsnitch_api.models.user import User
class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""A single shopping trip / receipt."""
__tablename__ = "purchases"
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
store_id: Mapped[str] = mapped_column(ForeignKey("stores.id"), nullable=False)
store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id"))
receipt_id: Mapped[str] = mapped_column(String(200), nullable=False)
purchase_date: Mapped[date] = mapped_column(Date, nullable=False)
total: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
subtotal: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
tax: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
savings_total: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
source_url: Mapped[str | None] = mapped_column(String(500))
raw_data: Mapped[dict | None] = mapped_column(JSON)
ingested_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
server_default=func.now(),
nullable=False,
)
# Relationships
user: Mapped["User"] = relationship(back_populates="purchases")
store: Mapped["Store"] = relationship(back_populates="purchases")
store_location: Mapped["StoreLocation | None"] = relationship(back_populates="purchases")
items: Mapped[list["PurchaseItem"]] = relationship(back_populates="purchase")
__table_args__ = (
Index("ix_purchases_user_store", "user_id", "store_id"),
UniqueConstraint("user_id", "store_id", "receipt_id", name="uq_purchase_receipt"),
)
class PurchaseItem(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Individual line item on a receipt."""
__tablename__ = "purchase_items"
purchase_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("purchases.id"), nullable=False)
product_name_raw: Mapped[str] = mapped_column(String(300), nullable=False)
upc: Mapped[str | None] = mapped_column(String(20))
quantity: Mapped[Decimal] = mapped_column(Numeric(10, 3), nullable=False, default=1)
unit_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
extended_price: Mapped[Decimal] = mapped_column(Numeric(10, 2), nullable=False)
regular_price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
sale_price: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
coupon_discount: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
loyalty_discount: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
category_raw: Mapped[str | None] = mapped_column(String(100))
normalized_product_id: Mapped[uuid.UUID | None] = mapped_column(
ForeignKey("normalized_products.id")
)
# Relationships
purchase: Mapped["Purchase"] = relationship(back_populates="items")
normalized_product: Mapped["NormalizedProduct | None"] = relationship(
back_populates="purchase_items"
)
price_history_entries: Mapped[list["PriceHistory"]] = relationship(
back_populates="purchase_item"
)
@@ -1,41 +0,0 @@
"""ShrinkflationEvent model."""
import uuid
from datetime import date
from decimal import Decimal
from typing import TYPE_CHECKING
from sqlalchemy import Date, ForeignKey, Numeric, String
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import SizeUnit
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
if TYPE_CHECKING:
from cartsnitch_api.models.product import NormalizedProduct
class ShrinkflationEvent(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Detected shrinkflation event — product size changed while price held or rose."""
__tablename__ = "shrinkflation_events"
normalized_product_id: Mapped[uuid.UUID] = mapped_column(
ForeignKey("normalized_products.id"), nullable=False
)
detected_date: Mapped[date] = mapped_column(Date, nullable=False)
old_size: Mapped[str] = mapped_column(String(50), nullable=False)
new_size: Mapped[str] = mapped_column(String(50), nullable=False)
old_unit: Mapped[SizeUnit] = mapped_column(String(10), nullable=False)
new_unit: Mapped[SizeUnit] = mapped_column(String(10), nullable=False)
price_at_old_size: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
price_at_new_size: Mapped[Decimal | None] = mapped_column(Numeric(10, 2))
confidence: Mapped[Decimal] = mapped_column(
Numeric(3, 2), nullable=False, default=Decimal("1.00")
)
notes: Mapped[str | None] = mapped_column(String(1000))
# Relationships
normalized_product: Mapped["NormalizedProduct"] = relationship(
back_populates="shrinkflation_events"
)
-65
View File
@@ -1,65 +0,0 @@
"""User and UserStoreAccount models."""
import secrets
from datetime import datetime
from typing import TYPE_CHECKING
import sqlalchemy as sa
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import AccountStatus
from cartsnitch_api.models.base import Base, TimestampMixin, UUIDPrimaryKeyMixin
from cartsnitch_api.types import EncryptedJSON
if TYPE_CHECKING:
from cartsnitch_api.models.purchase import Purchase
from cartsnitch_api.models.store import Store
class User(TimestampMixin, Base):
"""Application user."""
__tablename__ = "users"
id: Mapped[str] = mapped_column(Text, primary_key=True)
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
display_name: Mapped[str | None] = mapped_column(String(100))
email_verified: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default="false"
)
image: Mapped[str | None] = mapped_column(Text, nullable=True)
email_inbound_token: Mapped[str] = mapped_column(
String(22),
nullable=False,
unique=True,
default=lambda: secrets.token_urlsafe(16),
server_default=sa.text(
"replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
),
)
# Relationships
store_accounts: Mapped[list["UserStoreAccount"]] = relationship(back_populates="user")
purchases: Mapped[list["Purchase"]] = relationship(back_populates="user")
class UserStoreAccount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
"""Link between a user and their retailer account credentials."""
__tablename__ = "user_store_accounts"
__table_args__ = (UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
store_id: Mapped[str] = mapped_column(ForeignKey("stores.id"), nullable=False)
session_data: Mapped[dict | None] = mapped_column(EncryptedJSON)
session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
status: Mapped[AccountStatus] = mapped_column(
String(20), nullable=False, default=AccountStatus.ACTIVE
)
# Relationships
user: Mapped["User"] = relationship(back_populates="store_accounts")
store: Mapped["Store"] = relationship(back_populates="user_accounts")
-44
View File
@@ -1,44 +0,0 @@
"""Alert routes: list alerts, manage settings."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import AlertResponse, AlertSettingsRequest, AlertSettingsResponse
from cartsnitch_api.services.alerts import AlertService
router = APIRouter(prefix="/alerts", tags=["alerts"])
@router.get("", response_model=list[AlertResponse])
async def list_alerts(
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AlertService(db)
return await svc.list_alerts(user_id)
@router.get("/settings", response_model=AlertSettingsResponse)
async def get_alert_settings(
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AlertService(db)
return await svc.get_settings(user_id)
@router.put("/settings")
async def update_alert_settings(
body: AlertSettingsRequest,
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
raise HTTPException(
status_code=status.HTTP_501_NOT_IMPLEMENTED,
detail="Alert settings persistence not yet implemented. "
"Use GET /alerts/settings for current defaults.",
)
-32
View File
@@ -1,32 +0,0 @@
"""Coupon routes: browse, relevant matches."""
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import CouponResponse
from cartsnitch_api.services.coupons import CouponService
router = APIRouter(prefix="/coupons", tags=["coupons"])
@router.get("", response_model=list[CouponResponse])
async def list_coupons(
store_id: UUID | None = Query(None),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = CouponService(db)
return await svc.list_coupons(store_id)
@router.get("/relevant", response_model=list[CouponResponse])
async def relevant_coupons(
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = CouponService(db)
return await svc.relevant_coupons(user_id)
-43
View File
@@ -1,43 +0,0 @@
"""Health check and error metrics endpoints."""
from fastapi import APIRouter, Depends
from sqlalchemy import text
from cartsnitch_api.auth.dependencies import verify_service_key
from cartsnitch_api.cache import get_redis
from cartsnitch_api.database import get_engine
from cartsnitch_api.middleware.error_handler import get_error_monitor
router = APIRouter(tags=["health"])
@router.get("/health")
async def health():
engine = get_engine()
db_ok = False
redis_ok = False
try:
async with engine.connect() as conn:
await conn.execute(text("SELECT 1"))
db_ok = True
except Exception:
pass
try:
r = get_redis()
if r:
await r.ping()
redis_ok = True
except Exception:
pass
status = "ok" if db_ok else "degraded"
return {"status": status, "db": db_ok, "redis": redis_ok}
@router.get("/internal/error-stats", dependencies=[Depends(verify_service_key)])
async def error_stats():
"""Error monitoring stats — internal only (requires X-Service-Key)."""
monitor = get_error_monitor()
return monitor.get_stats()
-47
View File
@@ -1,47 +0,0 @@
"""Price routes: trends, increases, comparison."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import (
PriceComparisonResponse,
PriceIncreaseResponse,
PriceTrendResponse,
)
from cartsnitch_api.services.prices import PriceService
router = APIRouter(prefix="/prices", tags=["prices"])
@router.get("/trends", response_model=list[PriceTrendResponse])
async def price_trends(
user_id: UUID = Depends(get_current_user),
category: str | None = Query(None),
db: AsyncSession = Depends(get_db),
):
svc = PriceService(db)
return await svc.get_trends(category)
@router.get("/increases", response_model=list[PriceIncreaseResponse])
async def price_increases(
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = PriceService(db)
return await svc.get_increases()
@router.get("/comparison", response_model=list[PriceComparisonResponse])
async def price_comparison(
product_ids: Annotated[list[UUID], Query()],
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = PriceService(db)
return await svc.get_comparison(product_ids)
-56
View File
@@ -1,56 +0,0 @@
"""Product routes: search/list, detail, price history."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import PriceTrendResponse, ProductDetailResponse, ProductResponse
from cartsnitch_api.services.products import ProductService
router = APIRouter(prefix="/products", tags=["products"])
@router.get("", response_model=list[ProductResponse])
async def list_products(
user_id: UUID = Depends(get_current_user),
q: str | None = Query(None),
category: str | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
svc = ProductService(db)
return await svc.list_products(q, category, page, page_size)
@router.get("/{product_id}", response_model=ProductDetailResponse)
async def get_product(
product_id: UUID,
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = ProductService(db)
try:
return await svc.get_product(product_id)
except LookupError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
) from None
@router.get("/{product_id}/prices", response_model=PriceTrendResponse)
async def get_product_prices(
product_id: UUID,
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = ProductService(db)
try:
return await svc.get_price_history(product_id)
except LookupError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
) from None
-57
View File
@@ -1,57 +0,0 @@
"""Public endpoints: price transparency data (no auth required)."""
from typing import Annotated
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import (
PublicInflationResponse,
PublicStoreComparisonResponse,
PublicTrendResponse,
)
from cartsnitch_api.services.public import PublicService
router = APIRouter(prefix="/public", tags=["public"])
@router.get("/trends/{product_id}", response_model=PublicTrendResponse)
async def public_price_trend(
product_id: UUID,
days: int = Query(90, ge=1, le=365),
db: AsyncSession = Depends(get_db),
):
svc = PublicService(db)
try:
return await svc.get_trend(product_id, days=days)
except LookupError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
) from None
@router.get("/store-comparison", response_model=PublicStoreComparisonResponse)
async def public_store_comparison(
product_ids: Annotated[list[UUID], Query(max_length=20)],
category: str | None = Query(None, max_length=100, pattern=r"^[a-zA-Z0-9 _-]+$"),
db: AsyncSession = Depends(get_db),
):
if not product_ids:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="At least one product_id is required",
)
svc = PublicService(db)
return await svc.get_store_comparison(product_ids, category=category)
@router.get("/inflation", response_model=PublicInflationResponse)
async def public_inflation(
category: str | None = Query(None, max_length=100, pattern=r"^[a-zA-Z0-9 _-]+$"),
period: str = Query("all-time", pattern=r"^(all-time|1y|6m|3m|1m)$"),
db: AsyncSession = Depends(get_db),
):
svc = PublicService(db)
return await svc.get_inflation(category=category, period=period)
@@ -1,49 +0,0 @@
"""Purchase routes: list, detail, stats."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, Query, status
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import PurchaseDetailResponse, PurchaseResponse, PurchaseStatsResponse
from cartsnitch_api.services.purchases import PurchaseService
router = APIRouter(prefix="/purchases", tags=["purchases"])
@router.get("", response_model=list[PurchaseResponse])
async def list_purchases(
user_id: UUID = Depends(get_current_user),
store_id: UUID | None = Query(None),
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
svc = PurchaseService(db)
return await svc.list_purchases(user_id, store_id, page, page_size)
@router.get("/stats", response_model=PurchaseStatsResponse)
async def purchase_stats(
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = PurchaseService(db)
return await svc.get_stats(user_id)
@router.get("/{purchase_id}", response_model=PurchaseDetailResponse)
async def get_purchase(
purchase_id: UUID,
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = PurchaseService(db)
try:
return await svc.get_purchase(purchase_id, user_id)
except LookupError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Purchase not found"
) from None
-42
View File
@@ -1,42 +0,0 @@
"""Scraping routes: trigger sync, check status (proxy to ReceiptWitness)."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from httpx import HTTPStatusError, RequestError
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.schemas import SyncStatusResponse, SyncTriggerResponse
from cartsnitch_api.services.receiptwitness import ReceiptWitnessClient
router = APIRouter(prefix="/scraping", tags=["scraping"])
@router.post("/{store_slug}/sync", response_model=SyncTriggerResponse)
async def trigger_sync(store_slug: str, user_id: UUID = Depends(get_current_user)):
client = ReceiptWitnessClient()
try:
result = await client.trigger_sync(str(user_id), store_slug)
return result
except HTTPStatusError as e:
raise HTTPException(
status_code=e.response.status_code,
detail="Sync service error",
) from e
except RequestError:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Unable to reach sync service",
) from None
@router.get("/status", response_model=list[SyncStatusResponse])
async def sync_status(user_id: UUID = Depends(get_current_user)):
client = ReceiptWitnessClient()
try:
return await client.get_sync_status(str(user_id))
except (HTTPStatusError, RequestError):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Unable to reach sync service",
) from None
-48
View File
@@ -1,48 +0,0 @@
"""Shopping routes: optimize list, saved lists."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from httpx import HTTPStatusError, RequestError
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.schemas import OptimizeRequest, OptimizeResponse, ShoppingListResponse
from cartsnitch_api.services.clipartist import ClipArtistClient
router = APIRouter(prefix="/shopping", tags=["shopping"])
@router.post("/optimize", response_model=OptimizeResponse)
async def optimize_shopping(body: OptimizeRequest, user_id: UUID = Depends(get_current_user)):
client = ClipArtistClient()
try:
result = await client.optimize(
user_id=str(user_id),
items=[item.model_dump() for item in body.items],
preferred_stores=(
[str(s) for s in body.preferred_stores] if body.preferred_stores else None
),
)
return result
except HTTPStatusError as e:
raise HTTPException(
status_code=e.response.status_code,
detail="Shopping optimization service error",
) from e
except RequestError:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Unable to reach shopping optimization service",
) from None
@router.get("/lists", response_model=list[ShoppingListResponse])
async def list_shopping_lists(user_id: UUID = Depends(get_current_user)):
client = ClipArtistClient()
try:
return await client.get_shopping_lists(str(user_id))
except (HTTPStatusError, RequestError):
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail="Unable to reach shopping service",
) from None
-61
View File
@@ -1,61 +0,0 @@
"""Store routes: list stores, manage user store connections."""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import ConnectStoreRequest, StoreAccountResponse, StoreResponse
from cartsnitch_api.services.stores import StoreService
router = APIRouter(tags=["stores"])
@router.get("/stores", response_model=list[StoreResponse])
async def list_stores(db: AsyncSession = Depends(get_db)):
svc = StoreService(db)
return await svc.list_stores()
@router.get("/me/stores", response_model=list[StoreAccountResponse])
async def list_user_stores(
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = StoreService(db)
return await svc.list_user_stores(user_id)
@router.post(
"/me/stores/{store_slug}/connect",
response_model=StoreAccountResponse,
status_code=status.HTTP_201_CREATED,
)
async def connect_store(
store_slug: str,
body: ConnectStoreRequest,
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = StoreService(db)
try:
return await svc.connect_store(user_id, store_slug, body.credentials)
except LookupError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e
except ValueError as e:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e
@router.delete("/me/stores/{store_slug}", status_code=status.HTTP_204_NO_CONTENT)
async def disconnect_store(
store_slug: str,
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = StoreService(db)
try:
await svc.disconnect_store(user_id, store_slug)
except LookupError as e:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=str(e)) from e
-32
View File
@@ -1,32 +0,0 @@
"""User routes: per-user account endpoints (email-in address, etc.)."""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.schemas import EmailInAddressResponse
from cartsnitch_api.services.auth import AuthService
router = APIRouter(tags=["user"])
@router.get("/me/email-in-address", response_model=EmailInAddressResponse)
async def get_email_in_address(
user_id: str = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AuthService(db)
try:
email_address = await svc.get_email_in_address(user_id)
return EmailInAddressResponse(
email_address=email_address,
instructions=(
"Forward your digital receipt emails to this address. "
"We currently support Meijer, Kroger, and Target receipt emails."
),
)
except LookupError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="User not found"
) from None
-276
View File
@@ -1,276 +0,0 @@
"""Pydantic v2 request/response schemas for all API endpoints."""
from datetime import datetime
from uuid import UUID
from pydantic import BaseModel, EmailStr, Field
# ---------- Auth ----------
# Registration, login, and session management are handled by Better-Auth (auth/ service).
# These schemas are for the profile management endpoints only.
class UpdateUserRequest(BaseModel):
email: EmailStr | None = None
display_name: str | None = Field(None, min_length=1, max_length=100)
class UserResponse(BaseModel):
id: str
email: str
display_name: str
created_at: datetime
class EmailInAddressResponse(BaseModel):
email_address: str
instructions: str
# ---------- Stores ----------
class StoreResponse(BaseModel):
id: UUID
name: str
slug: str
logo_url: str | None = None
supported: bool = True
class StoreAccountResponse(BaseModel):
store: StoreResponse
connected: bool
last_sync_at: datetime | None = None
sync_status: str | None = None
class ConnectStoreRequest(BaseModel):
credentials: dict | None = None
# ---------- Purchases ----------
class LineItemResponse(BaseModel):
id: UUID
product_id: UUID | None = None
name: str
quantity: float
unit_price: float
total_price: float
class PurchaseResponse(BaseModel):
id: UUID
store_id: UUID
store_name: str
purchased_at: datetime
total: float
item_count: int
class PurchaseDetailResponse(PurchaseResponse):
line_items: list[LineItemResponse]
class PurchaseStatsResponse(BaseModel):
total_spent: float
purchase_count: int
by_store: dict[str, float]
by_period: dict[str, float]
# ---------- Products ----------
class ProductResponse(BaseModel):
id: UUID
name: str
brand: str | None = None
category: str | None = None
upc: str | None = None
image_url: str | None = None
class ProductDetailResponse(ProductResponse):
prices_by_store: list["StorePriceResponse"]
class StorePriceResponse(BaseModel):
store_id: UUID
store_name: str
current_price: float
last_seen_at: datetime
# ---------- Prices ----------
class PriceTrendResponse(BaseModel):
product_id: UUID
product_name: str
data_points: list["PricePointResponse"]
class PricePointResponse(BaseModel):
date: datetime
price: float
store_id: UUID
store_name: str
class PriceIncreaseResponse(BaseModel):
product_id: UUID
product_name: str
store_name: str
old_price: float
new_price: float
increase_pct: float
detected_at: datetime
class PriceComparisonResponse(BaseModel):
product_id: UUID
product_name: str
prices: list[StorePriceResponse]
# ---------- Coupons ----------
class CouponResponse(BaseModel):
id: UUID
store_id: UUID
store_name: str
description: str
discount_value: float
discount_type: str
product_id: UUID | None = None
expires_at: datetime | None = None
# ---------- Shopping ----------
class ShoppingListItemRequest(BaseModel):
product_id: UUID | None = None
name: str
quantity: int = 1
class OptimizeRequest(BaseModel):
items: list[ShoppingListItemRequest]
preferred_stores: list[UUID] | None = None
class OptimizedStoreTrip(BaseModel):
store_id: UUID
store_name: str
items: list["OptimizedItemResponse"]
subtotal: float
coupons: list[CouponResponse]
savings: float
class OptimizedItemResponse(BaseModel):
name: str
price: float
product_id: UUID | None = None
class OptimizeResponse(BaseModel):
trips: list[OptimizedStoreTrip]
total_cost: float
total_savings: float
class ShoppingListResponse(BaseModel):
id: UUID
name: str
item_count: int
created_at: datetime
updated_at: datetime
# ---------- Alerts ----------
class AlertResponse(BaseModel):
id: UUID
alert_type: str
product_id: UUID
product_name: str
message: str
triggered_at: datetime
read: bool = False
class AlertSettingsRequest(BaseModel):
price_increase_threshold_pct: float | None = None
shrinkflation_enabled: bool | None = None
email_notifications: bool | None = None
class AlertSettingsResponse(BaseModel):
price_increase_threshold_pct: float
shrinkflation_enabled: bool
email_notifications: bool
# ---------- Scraping ----------
class SyncTriggerResponse(BaseModel):
job_id: UUID
status: str
message: str
class SyncStatusResponse(BaseModel):
store_slug: str
status: str
last_sync_at: datetime | None = None
items_synced: int | None = None
# ---------- Public ----------
class PublicTrendResponse(BaseModel):
product_id: UUID
product_name: str
data_points: list[PricePointResponse]
class PublicStoreComparisonResponse(BaseModel):
products: list[PriceComparisonResponse]
class PublicInflationResponse(BaseModel):
period: str
cartsnitch_index: float
cpi_baseline: float
categories: dict[str, float]
# ---------- Common ----------
class PaginatedResponse(BaseModel):
items: list
total: int
page: int
page_size: int
pages: int
class ErrorResponse(BaseModel):
detail: str
code: str | None = None
# Rebuild forward refs
ProductDetailResponse.model_rebuild()
PriceTrendResponse.model_rebuild()
OptimizedStoreTrip.model_rebuild()
-75
View File
@@ -1,75 +0,0 @@
"""Alert service — price and shrinkflation alerts for users.
Alerts are generated by StickerShock and ShrinkRay services and written to the DB.
This service reads them for the API gateway.
"""
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
class AlertService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list_alerts(self, user_id: UUID) -> list[dict]:
"""List shrinkflation events for products the user has purchased."""
from cartsnitch_api.models import Purchase, PurchaseItem, ShrinkflationEvent
# Get product IDs from user's purchases
items_result = await self.db.execute(
select(PurchaseItem.normalized_product_id)
.join(Purchase)
.where(
Purchase.user_id == user_id,
PurchaseItem.normalized_product_id.isnot(None),
)
.distinct()
)
product_ids = [row[0] for row in items_result.all()]
if not product_ids:
return []
result = await self.db.execute(
select(ShrinkflationEvent)
.where(ShrinkflationEvent.normalized_product_id.in_(product_ids))
.options(selectinload(ShrinkflationEvent.normalized_product))
.order_by(ShrinkflationEvent.detected_date.desc())
)
events = result.scalars().all()
return [
{
"id": e.id,
"alert_type": "shrinkflation",
"product_id": e.normalized_product_id,
"product_name": e.normalized_product.canonical_name,
"message": (
f"Size changed from {e.old_size}{e.old_unit} to {e.new_size}{e.new_unit}"
),
"triggered_at": e.detected_date,
"read": False,
}
for e in events
]
async def get_settings(self, user_id: UUID) -> dict:
# Alert settings would be stored in a user_settings table.
# For now, return defaults since the table doesn't exist yet in common lib.
return {
"price_increase_threshold_pct": 5.0,
"shrinkflation_enabled": True,
"email_notifications": False,
}
async def update_settings(self, user_id: UUID, **fields) -> dict:
# Would update user_settings table. Return merged defaults for now.
current = await self.get_settings(user_id)
for k, v in fields.items():
if v is not None and k in current:
current[k] = v
return current
-79
View File
@@ -1,79 +0,0 @@
"""Auth service — user profile management.
Registration, login, token management, and session handling are now
handled by the Better-Auth service (auth/). This service provides
user lookup and profile update operations for the API gateway.
"""
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
class AuthService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get_user(self, user_id: str) -> dict:
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise LookupError("User not found")
return {
"id": user.id,
"email": user.email,
"display_name": user.display_name,
"created_at": user.created_at,
}
async def update_user(self, user_id: str, **fields) -> dict:
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise LookupError("User not found")
if "display_name" in fields and fields["display_name"] is not None:
user.display_name = fields["display_name"]
if "email" in fields and fields["email"] is not None:
existing = await self.db.execute(
select(User).where(User.email == fields["email"], User.id != user_id)
)
if existing.scalar_one_or_none():
raise ValueError("Email already in use")
user.email = fields["email"]
await self.db.commit()
await self.db.refresh(user)
return {
"id": user.id,
"email": user.email,
"display_name": user.display_name,
"created_at": user.created_at,
}
async def delete_user(self, user_id: str) -> None:
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise LookupError("User not found")
await self.db.delete(user)
await self.db.commit()
async def get_email_in_address(self, user_id: str) -> str:
"""Return the per-user email-in address for receipt forwarding."""
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.id == user_id))
user = result.scalar_one_or_none()
if not user:
raise LookupError("User not found")
return f"receipts+{user.email_inbound_token}@receipts.cartsnitch.com"
@@ -1,52 +0,0 @@
"""HTTP client for ClipArtist internal API."""
from typing import Any, cast
import httpx
from cartsnitch_api.config import settings
class ClipArtistClient:
def __init__(self) -> None:
self.base_url = settings.clipartist_url
self.headers = {"X-Service-Key": settings.service_key}
async def optimize(
self,
user_id: str,
items: list[dict],
preferred_stores: list[str] | None = None,
) -> dict:
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.base_url}/optimize",
headers=self.headers,
json={
"user_id": user_id,
"items": items,
"preferred_stores": preferred_stores,
},
)
resp.raise_for_status()
return cast(dict[str, Any], resp.json())
async def get_shopping_lists(self, user_id: str) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.base_url}/shopping-lists",
headers=self.headers,
params={"user_id": user_id},
)
resp.raise_for_status()
return cast(list[dict[str, Any]], resp.json())
async def get_relevant_coupons(self, user_id: str) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.base_url}/coupons/relevant",
headers=self.headers,
params={"user_id": user_id},
)
resp.raise_for_status()
return cast(list[dict[str, Any]], resp.json())
@@ -1,76 +0,0 @@
"""Coupon service — browse coupons, find relevant ones."""
from datetime import date
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
class CouponService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list_coupons(self, store_id: UUID | None = None) -> list[dict]:
from cartsnitch_api.models import Coupon
today = date.today()
query = (
select(Coupon)
.where((Coupon.valid_to >= today) | (Coupon.valid_to.is_(None)))
.options(selectinload(Coupon.store))
.order_by(Coupon.valid_to.asc().nullslast())
)
if store_id:
query = query.where(Coupon.store_id == store_id)
result = await self.db.execute(query)
coupons = result.scalars().all()
return [self._to_dict(c) for c in coupons]
async def relevant_coupons(self, user_id: UUID) -> list[dict]:
"""Coupons for products the user has purchased."""
from cartsnitch_api.models import Coupon, PurchaseItem
today = date.today()
# Get product IDs from user's purchase history
from cartsnitch_api.models import Purchase
items_result = await self.db.execute(
select(PurchaseItem.normalized_product_id)
.join(Purchase)
.where(
Purchase.user_id == user_id,
PurchaseItem.normalized_product_id.isnot(None),
)
.distinct()
)
product_ids = [row[0] for row in items_result.all()]
if not product_ids:
return []
result = await self.db.execute(
select(Coupon)
.where(
Coupon.normalized_product_id.in_(product_ids),
(Coupon.valid_to >= today) | (Coupon.valid_to.is_(None)),
)
.options(selectinload(Coupon.store))
)
coupons = result.scalars().all()
return [self._to_dict(c) for c in coupons]
def _to_dict(self, c) -> dict:
return {
"id": c.id,
"store_id": c.store_id,
"store_name": c.store.name,
"description": c.description or c.title,
"discount_value": float(c.discount_value) if c.discount_value else 0,
"discount_type": c.discount_type,
"product_id": c.normalized_product_id,
"expires_at": c.valid_to,
}
-183
View File
@@ -1,183 +0,0 @@
"""Price service — trends, increases, comparison."""
from uuid import UUID
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from cartsnitch_api.services.queries import latest_price_per_store
class PriceService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get_trends(self, category: str | None = None) -> list[dict]:
from cartsnitch_api.models import NormalizedProduct, PriceHistory
query = (
select(PriceHistory)
.join(NormalizedProduct)
.options(
selectinload(PriceHistory.store),
selectinload(PriceHistory.normalized_product),
)
.order_by(PriceHistory.observed_date)
)
if category:
query = query.where(NormalizedProduct.category == category)
result = await self.db.execute(query)
prices = result.scalars().all()
# Group by product
by_product: dict[UUID, dict] = {}
for ph in prices:
pid = ph.normalized_product_id
if pid not in by_product:
by_product[pid] = {
"product_id": pid,
"product_name": ph.normalized_product.canonical_name,
"data_points": [],
}
by_product[pid]["data_points"].append(
{
"date": ph.observed_date,
"price": float(ph.regular_price),
"store_id": ph.store_id,
"store_name": ph.store.name,
}
)
return list(by_product.values())
async def get_increases(self) -> list[dict]:
"""Find products with recent significant price increases.
Uses a window function (lag) to compare each price observation with the
previous one per product+store, avoiding the N+1 query pattern.
"""
from cartsnitch_api.models import NormalizedProduct, PriceHistory, Store
# Use lag() window function to get previous price in a single query
prev_price = (
func.lag(PriceHistory.regular_price)
.over(
partition_by=[PriceHistory.normalized_product_id, PriceHistory.store_id],
order_by=PriceHistory.observed_date,
)
.label("prev_price")
)
row_num = (
func.row_number()
.over(
partition_by=[PriceHistory.normalized_product_id, PriceHistory.store_id],
order_by=PriceHistory.observed_date.desc(),
)
.label("rn")
)
inner = select(
PriceHistory.normalized_product_id,
PriceHistory.store_id,
PriceHistory.regular_price,
PriceHistory.observed_date,
prev_price,
row_num,
).subquery()
# Only keep the latest row (rn=1) where price increased
result = await self.db.execute(
select(
inner.c.normalized_product_id,
inner.c.store_id,
inner.c.regular_price,
inner.c.observed_date,
inner.c.prev_price,
NormalizedProduct.canonical_name,
Store.name.label("store_name"),
)
.join(NormalizedProduct, NormalizedProduct.id == inner.c.normalized_product_id)
.join(Store, Store.id == inner.c.store_id)
.where(
inner.c.rn == 1,
inner.c.prev_price.isnot(None),
inner.c.regular_price > inner.c.prev_price,
)
)
increases = []
for row in result.all():
old = float(row.prev_price)
new = float(row.regular_price)
increases.append(
{
"product_id": row.normalized_product_id,
"product_name": row.canonical_name,
"store_name": row.store_name,
"old_price": old,
"new_price": new,
"increase_pct": round((new - old) / old * 100, 2),
"detected_at": row.observed_date,
}
)
increases.sort(key=lambda x: x["increase_pct"], reverse=True)
return increases
async def get_comparison(self, product_ids: list[UUID]) -> list[dict]:
from cartsnitch_api.models import NormalizedProduct, PriceHistory
if not product_ids:
return []
# Fetch all requested products in one query
prod_result = await self.db.execute(
select(NormalizedProduct).where(NormalizedProduct.id.in_(product_ids))
)
products_by_id = {p.id: p for p in prod_result.scalars().all()}
# Latest prices for all requested products in one query
subq = latest_price_per_store(product_ids)
prices_result = await self.db.execute(
select(PriceHistory)
.join(
subq,
and_(
PriceHistory.store_id == subq.c.store_id,
PriceHistory.observed_date == subq.c.max_date,
PriceHistory.normalized_product_id == subq.c.normalized_product_id,
),
)
.where(PriceHistory.normalized_product_id.in_(product_ids))
.options(selectinload(PriceHistory.store))
)
all_prices = prices_result.scalars().all()
# Group prices by product
prices_by_product: dict[UUID, list] = {pid: [] for pid in product_ids}
for ph in all_prices:
prices_by_product.setdefault(ph.normalized_product_id, []).append(ph)
comparisons = []
for pid in product_ids:
product = products_by_id.get(pid)
if not product:
continue
comparisons.append(
{
"product_id": pid,
"product_name": product.canonical_name,
"prices": [
{
"store_id": ph.store_id,
"store_name": ph.store.name,
"current_price": float(ph.regular_price),
"last_seen_at": ph.observed_date,
}
for ph in prices_by_product.get(pid, [])
],
}
)
return comparisons
-124
View File
@@ -1,124 +0,0 @@
"""Product service — catalog, detail, price history."""
from uuid import UUID
from sqlalchemy import and_, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from cartsnitch_api.services.queries import latest_price_per_store
class ProductService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list_products(
self,
q: str | None = None,
category: str | None = None,
page: int = 1,
page_size: int = 20,
) -> list[dict]:
from cartsnitch_api.models import NormalizedProduct
query = select(NormalizedProduct)
if q:
# Escape SQL LIKE wildcards in user input
safe_q = q.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
query = query.where(NormalizedProduct.canonical_name.ilike(f"%{safe_q}%"))
if category:
query = query.where(NormalizedProduct.category == category)
query = query.order_by(NormalizedProduct.canonical_name)
query = query.offset((page - 1) * page_size).limit(page_size)
result = await self.db.execute(query)
products = result.scalars().all()
return [
{
"id": p.id,
"name": p.canonical_name,
"brand": p.brand,
"category": p.category,
"upc": (p.upc_variants[0] if p.upc_variants else None),
"image_url": None,
}
for p in products
]
async def get_product(self, product_id: UUID) -> dict:
from cartsnitch_api.models import NormalizedProduct, PriceHistory
result = await self.db.execute(
select(NormalizedProduct).where(NormalizedProduct.id == product_id)
)
product = result.scalar_one_or_none()
if not product:
raise LookupError("Product not found")
# Get latest price per store
subq = latest_price_per_store([product_id])
prices_result = await self.db.execute(
select(PriceHistory)
.join(
subq,
and_(
PriceHistory.store_id == subq.c.store_id,
PriceHistory.observed_date == subq.c.max_date,
PriceHistory.normalized_product_id == subq.c.normalized_product_id,
),
)
.where(PriceHistory.normalized_product_id == product_id)
.options(selectinload(PriceHistory.store))
)
prices = prices_result.scalars().all()
return {
"id": product.id,
"name": product.canonical_name,
"brand": product.brand,
"category": product.category,
"upc": (product.upc_variants[0] if product.upc_variants else None),
"image_url": None,
"prices_by_store": [
{
"store_id": ph.store_id,
"store_name": ph.store.name,
"current_price": float(ph.regular_price),
"last_seen_at": ph.observed_date,
}
for ph in prices
],
}
async def get_price_history(self, product_id: UUID) -> dict:
from cartsnitch_api.models import NormalizedProduct, PriceHistory
result = await self.db.execute(
select(NormalizedProduct).where(NormalizedProduct.id == product_id)
)
product = result.scalar_one_or_none()
if not product:
raise LookupError("Product not found")
prices_result = await self.db.execute(
select(PriceHistory)
.where(PriceHistory.normalized_product_id == product_id)
.options(selectinload(PriceHistory.store))
.order_by(PriceHistory.observed_date)
)
prices = prices_result.scalars().all()
return {
"product_id": product.id,
"product_name": product.canonical_name,
"data_points": [
{
"date": ph.observed_date,
"price": float(ph.regular_price),
"store_id": ph.store_id,
"store_name": ph.store.name,
}
for ph in prices
],
}
-148
View File
@@ -1,148 +0,0 @@
"""Public service — unauthenticated price transparency endpoints."""
from datetime import date, timedelta
from uuid import UUID
from sqlalchemy import and_, func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from cartsnitch_api.services.queries import latest_price_per_store
class PublicService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get_trend(self, product_id: UUID, days: int = 90) -> dict:
from cartsnitch_api.models import NormalizedProduct, PriceHistory
result = await self.db.execute(
select(NormalizedProduct).where(NormalizedProduct.id == product_id)
)
product = result.scalar_one_or_none()
if not product:
raise LookupError("Product not found")
date_threshold = date.today() - timedelta(days=days)
prices_result = await self.db.execute(
select(PriceHistory)
.where(
PriceHistory.normalized_product_id == product_id,
PriceHistory.observed_date >= date_threshold,
)
.options(selectinload(PriceHistory.store))
.order_by(PriceHistory.observed_date)
)
prices = prices_result.scalars().all()
return {
"product_id": product.id,
"product_name": product.canonical_name,
"data_points": [
{
"date": ph.observed_date,
"price": float(ph.regular_price),
"store_id": ph.store_id,
"store_name": ph.store.name,
}
for ph in prices
],
}
async def get_store_comparison(
self, product_ids: list[UUID], category: str | None = None
) -> dict:
from cartsnitch_api.models import NormalizedProduct, PriceHistory
if not product_ids:
return {"products": []}
product_query = select(NormalizedProduct).where(NormalizedProduct.id.in_(product_ids))
if category:
product_query = product_query.where(NormalizedProduct.category == category)
prod_result = await self.db.execute(product_query)
products_by_id = {p.id: p for p in prod_result.scalars().all()}
if not products_by_id:
return {"products": []}
filtered_product_ids = list(products_by_id.keys())
subq = latest_price_per_store(filtered_product_ids)
prices_result = await self.db.execute(
select(PriceHistory)
.join(
subq,
and_(
PriceHistory.store_id == subq.c.store_id,
PriceHistory.observed_date == subq.c.max_date,
PriceHistory.normalized_product_id == subq.c.normalized_product_id,
),
)
.where(PriceHistory.normalized_product_id.in_(filtered_product_ids))
.options(selectinload(PriceHistory.store))
)
all_prices = prices_result.scalars().all()
prices_by_product: dict[UUID, list] = {}
for ph in all_prices:
prices_by_product.setdefault(ph.normalized_product_id, []).append(ph)
products = []
for pid in filtered_product_ids:
product = products_by_id.get(pid)
if not product:
continue
products.append(
{
"product_id": pid,
"product_name": product.canonical_name,
"prices": [
{
"store_id": ph.store_id,
"store_name": ph.store.name,
"current_price": float(ph.regular_price),
"last_seen_at": ph.observed_date,
}
for ph in prices_by_product.get(pid, [])
],
}
)
return {"products": products}
async def get_inflation(self, category: str | None = None, period: str = "all-time") -> dict:
"""Aggregate price change stats. Compares average prices across periods."""
from cartsnitch_api.models import NormalizedProduct, PriceHistory
date_threshold = None
if period != "all-time":
days_map = {"1y": 365, "6m": 180, "3m": 90, "1m": 30}
days = days_map.get(period, 365)
date_threshold = date.today() - timedelta(days=days)
query = select(
NormalizedProduct.category,
func.avg(PriceHistory.regular_price),
).join(NormalizedProduct)
if category:
query = query.where(NormalizedProduct.category == category)
if date_threshold:
query = query.where(PriceHistory.observed_date >= date_threshold)
query = query.group_by(NormalizedProduct.category)
result = await self.db.execute(query)
categories = {}
for row in result.all():
cat, avg_price = row
if cat:
categories[cat] = float(avg_price) if avg_price else 0.0
return {
"period": period,
"cartsnitch_index": sum(categories.values()) / max(len(categories), 1),
"cpi_baseline": 100.0,
"categories": categories,
}
@@ -1,116 +0,0 @@
"""Purchase service — list, detail, stats."""
from uuid import UUID
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
class PurchaseService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list_purchases(
self,
user_id: UUID,
store_id: UUID | None = None,
page: int = 1,
page_size: int = 20,
) -> list[dict]:
from cartsnitch_api.models import Purchase, PurchaseItem, Store
# Count items per purchase in a single subquery instead of N+1
item_counts = (
select(
PurchaseItem.purchase_id,
func.count().label("item_count"),
)
.group_by(PurchaseItem.purchase_id)
.subquery()
)
query = (
select(Purchase, item_counts.c.item_count, Store.name.label("store_name"))
.join(Store, Store.id == Purchase.store_id)
.outerjoin(item_counts, item_counts.c.purchase_id == Purchase.id)
.where(Purchase.user_id == user_id)
)
if store_id:
query = query.where(Purchase.store_id == store_id)
query = query.order_by(Purchase.purchase_date.desc())
query = query.offset((page - 1) * page_size).limit(page_size)
result = await self.db.execute(query)
return [
{
"id": p.id,
"store_id": p.store_id,
"store_name": store_name,
"purchased_at": p.purchase_date,
"total": float(p.total),
"item_count": item_count or 0,
}
for p, item_count, store_name in result.all()
]
async def get_purchase(self, purchase_id: UUID, user_id: UUID) -> dict:
from cartsnitch_api.models import Purchase
result = await self.db.execute(
select(Purchase)
.where(Purchase.id == purchase_id, Purchase.user_id == user_id)
.options(selectinload(Purchase.store), selectinload(Purchase.items))
)
purchase = result.scalar_one_or_none()
if not purchase:
raise LookupError("Purchase not found")
return {
"id": purchase.id,
"store_id": purchase.store_id,
"store_name": purchase.store.name,
"purchased_at": purchase.purchase_date,
"total": float(purchase.total),
"item_count": len(purchase.items),
"line_items": [
{
"id": item.id,
"product_id": item.normalized_product_id,
"name": item.product_name_raw,
"quantity": float(item.quantity),
"unit_price": float(item.unit_price),
"total_price": float(item.extended_price),
}
for item in purchase.items
],
}
async def get_stats(self, user_id: UUID) -> dict:
from cartsnitch_api.models import Purchase
result = await self.db.execute(
select(Purchase)
.where(Purchase.user_id == user_id)
.options(selectinload(Purchase.store))
)
purchases = result.scalars().all()
total_spent = sum(float(p.total) for p in purchases)
by_store: dict[str, float] = {}
by_period: dict[str, float] = {}
for p in purchases:
store_name = p.store.name
by_store[store_name] = by_store.get(store_name, 0) + float(p.total)
period = p.purchase_date.strftime("%Y-%m")
by_period[period] = by_period.get(period, 0) + float(p.total)
return {
"total_spent": total_spent,
"purchase_count": len(purchases),
"by_store": by_store,
"by_period": by_period,
}
@@ -1,23 +0,0 @@
"""Shared query helpers for service layer."""
from uuid import UUID
from sqlalchemy import func, select
def latest_price_per_store(product_ids: list[UUID] | None = None):
"""Subquery returning the latest observed_date per product+store.
Optionally filtered to a list of product IDs. Returns a subquery with
columns: normalized_product_id, store_id, max_date.
"""
from cartsnitch_api.models import PriceHistory
query = select(
PriceHistory.normalized_product_id,
PriceHistory.store_id,
func.max(PriceHistory.observed_date).label("max_date"),
).group_by(PriceHistory.normalized_product_id, PriceHistory.store_id)
if product_ids is not None:
query = query.where(PriceHistory.normalized_product_id.in_(product_ids))
return query.subquery()
@@ -1,33 +0,0 @@
"""HTTP client for ReceiptWitness internal API."""
from typing import Any, cast
import httpx
from cartsnitch_api.config import settings
class ReceiptWitnessClient:
def __init__(self) -> None:
self.base_url = settings.receiptwitness_url
self.headers = {"X-Service-Key": settings.service_key}
async def trigger_sync(self, user_id: str, store_slug: str) -> dict:
async with httpx.AsyncClient() as client:
resp = await client.post(
f"{self.base_url}/sync/{store_slug}",
headers=self.headers,
json={"user_id": user_id},
)
resp.raise_for_status()
return cast(dict[str, Any], resp.json())
async def get_sync_status(self, user_id: str) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.base_url}/sync/status",
headers=self.headers,
params={"user_id": user_id},
)
resp.raise_for_status()
return cast(list[dict[str, Any]], resp.json())
@@ -1,23 +0,0 @@
"""HTTP client for ShrinkRay internal API."""
from typing import Any, cast
import httpx
from cartsnitch_api.config import settings
class ShrinkRayClient:
def __init__(self) -> None:
self.base_url = settings.shrinkray_url
self.headers = {"X-Service-Key": settings.service_key}
async def get_shrinkflation_alerts(self, user_id: str) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.base_url}/alerts",
headers=self.headers,
params={"user_id": user_id},
)
resp.raise_for_status()
return cast(list[dict[str, Any]], resp.json())
@@ -1,32 +0,0 @@
"""HTTP client for StickerShock internal API."""
from typing import Any, cast
import httpx
from cartsnitch_api.config import settings
class StickerShockClient:
def __init__(self) -> None:
self.base_url = settings.stickershock_url
self.headers = {"X-Service-Key": settings.service_key}
async def get_price_increases(self, params: dict | None = None) -> list[dict]:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.base_url}/increases",
headers=self.headers,
params=params,
)
resp.raise_for_status()
return cast(list[dict[str, Any]], resp.json())
async def get_inflation_data(self) -> dict:
async with httpx.AsyncClient() as client:
resp = await client.get(
f"{self.base_url}/inflation",
headers=self.headers,
)
resp.raise_for_status()
return cast(dict[str, Any], resp.json())
-129
View File
@@ -1,129 +0,0 @@
"""Store service — list stores, manage user store account connections."""
import json
from uuid import UUID
from cryptography.fernet import Fernet
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from cartsnitch_api.config import settings
def _get_fernet() -> Fernet:
return Fernet(settings.fernet_key.encode())
class StoreService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def list_stores(self) -> list[dict]:
from cartsnitch_api.models import Store
result = await self.db.execute(select(Store).order_by(Store.name))
stores = result.scalars().all()
return [
{
"id": s.id,
"name": s.name,
"slug": s.slug,
"logo_url": s.logo_url,
"supported": True,
}
for s in stores
]
async def list_user_stores(self, user_id: UUID) -> list[dict]:
from cartsnitch_api.models import UserStoreAccount
result = await self.db.execute(
select(UserStoreAccount)
.where(UserStoreAccount.user_id == user_id)
.options(selectinload(UserStoreAccount.store))
)
accounts = result.scalars().all()
return [
{
"store": {
"id": a.store.id,
"name": a.store.name,
"slug": a.store.slug,
"logo_url": a.store.logo_url,
"supported": True,
},
"connected": a.status == "active",
"last_sync_at": a.last_sync_at,
"sync_status": a.status,
}
for a in accounts
]
async def connect_store(self, user_id: UUID, store_slug: str, credentials: dict | None) -> dict:
from cartsnitch_api.models import Store, UserStoreAccount
result = await self.db.execute(select(Store).where(Store.slug == store_slug))
store = result.scalar_one_or_none()
if not store:
raise LookupError(f"Store '{store_slug}' not found")
existing = await self.db.execute(
select(UserStoreAccount).where(
UserStoreAccount.user_id == user_id,
UserStoreAccount.store_id == store.id,
)
)
if existing.scalar_one_or_none():
raise ValueError("Store account already connected")
encrypted_data = None
if credentials:
fernet = _get_fernet()
encrypted_data = {
"encrypted": fernet.encrypt(json.dumps(credentials).encode()).decode()
}
account = UserStoreAccount(
user_id=user_id,
store_id=store.id,
session_data=encrypted_data,
status="active",
)
self.db.add(account)
await self.db.commit()
await self.db.refresh(account)
return {
"store": {
"id": store.id,
"name": store.name,
"slug": store.slug,
"logo_url": store.logo_url,
"supported": True,
},
"connected": True,
"last_sync_at": None,
"sync_status": "active",
}
async def disconnect_store(self, user_id: UUID, store_slug: str) -> None:
from cartsnitch_api.models import Store, UserStoreAccount
result = await self.db.execute(select(Store).where(Store.slug == store_slug))
store = result.scalar_one_or_none()
if not store:
raise LookupError(f"Store '{store_slug}' not found")
result = await self.db.execute(
select(UserStoreAccount).where(
UserStoreAccount.user_id == user_id,
UserStoreAccount.store_id == store.id,
)
)
account = result.scalar_one_or_none()
if not account:
raise LookupError("Store account not connected")
await self.db.delete(account)
await self.db.commit()
-36
View File
@@ -1,36 +0,0 @@
"""Custom SQLAlchemy column types."""
import json
from cryptography.fernet import Fernet
from sqlalchemy import Text
from sqlalchemy.types import TypeDecorator
from cartsnitch_api.config import settings
def _get_fernet() -> Fernet:
return Fernet(settings.fernet_key.encode())
class EncryptedJSON(TypeDecorator):
"""SQLAlchemy type that transparently encrypts/decrypts JSON using Fernet.
Stores data as a Fernet-encrypted text blob in the database.
On read, decrypts and deserialises back to a Python dict/list.
"""
impl = Text
cache_ok = True
def process_bind_param(self, value, dialect):
if value is None:
return None
plaintext = json.dumps(value).encode()
return _get_fernet().encrypt(plaintext).decode()
def process_result_value(self, value, dialect):
if value is None:
return None
decrypted = _get_fernet().decrypt(value.encode())
return json.loads(decrypted)
-215
View File
@@ -1,215 +0,0 @@
"""Shared test fixtures with in-memory SQLite database.
Session-based auth: tests create users and sessions directly in the DB,
matching the Better-Auth session validation flow.
"""
import secrets
import uuid
from datetime import UTC, datetime, timedelta
import pytest
from httpx import ASGITransport, AsyncClient
from sqlalchemy import create_engine, event, text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import sessionmaker
from cartsnitch_api.config import settings as cartsnitch_settings
from cartsnitch_api.database import get_db
from cartsnitch_api.main import create_app
from cartsnitch_api.models import Base
TEST_JWT_SECRET = secrets.token_urlsafe(32)
TEST_SERVICE_KEY = secrets.token_urlsafe(32)
TEST_FERNET_KEY = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
@pytest.fixture(autouse=True)
def setup_test_settings():
original_jwt = cartsnitch_settings.jwt_secret_key
original_service = cartsnitch_settings.service_key
original_fernet = cartsnitch_settings.fernet_key
cartsnitch_settings.jwt_secret_key = TEST_JWT_SECRET
cartsnitch_settings.service_key = TEST_SERVICE_KEY
cartsnitch_settings.fernet_key = TEST_FERNET_KEY
yield
cartsnitch_settings.jwt_secret_key = original_jwt
cartsnitch_settings.service_key = original_service
cartsnitch_settings.fernet_key = original_fernet
TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:"
@pytest.fixture(autouse=True)
def disable_rate_limiting():
"""Disable rate limiting for all tests to prevent 429 interference."""
cartsnitch_settings.rate_limit_enabled = False
yield
cartsnitch_settings.rate_limit_enabled = True
@pytest.fixture
def engine():
"""Sync in-memory SQLite engine for model unit tests."""
eng = create_engine("sqlite:///:memory:")
Base.metadata.create_all(eng)
yield eng
eng.dispose()
@pytest.fixture
def session(engine):
"""Sync SQLAlchemy session for model unit tests."""
factory = sessionmaker(bind=engine)
with factory() as sess:
yield sess
@pytest.fixture
async def db_engine():
engine = create_async_engine(TEST_DATABASE_URL, echo=False)
@event.listens_for(engine.sync_engine, "connect")
def set_sqlite_pragma(dbapi_connection, connection_record):
cursor = dbapi_connection.cursor()
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Create Better-Auth tables (not managed by SQLAlchemy models)
await conn.execute(
text("""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
user_id TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
ip_address TEXT,
user_agent TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
""")
)
await conn.execute(
text("""
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
account_id TEXT NOT NULL,
provider_id TEXT NOT NULL,
access_token TEXT,
refresh_token TEXT,
access_token_expires_at TIMESTAMP,
refresh_token_expires_at TIMESTAMP,
scope TEXT,
id_token TEXT,
password TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
""")
)
await conn.execute(
text("""
CREATE TABLE IF NOT EXISTS verifications (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
value TEXT NOT NULL,
expires_at TIMESTAMP NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
""")
)
yield engine
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)
await engine.dispose()
@pytest.fixture
async def db_session(db_engine):
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
yield session
@pytest.fixture
async def client(db_engine):
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
async def override_get_db():
async with factory() as session:
yield session
app = create_app()
app.dependency_overrides[get_db] = override_get_db
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as ac:
yield ac
app.dependency_overrides.clear()
async def _create_test_user_and_session(
client: AsyncClient, db_engine, **user_overrides
) -> tuple[dict, str]:
"""Create a test user and a valid session directly in the DB.
Returns (user_dict, session_token). Better-Auth stores the raw token
in the DB, so we insert it as-is.
"""
user_id = str(uuid.uuid4())
email = user_overrides.get("email", "test@example.com")
display_name = user_overrides.get("display_name", "Test User")
session_token = secrets.token_urlsafe(32)
session_id = str(uuid.uuid4())
now = datetime.now(UTC).isoformat()
expires = (datetime.now(UTC) + timedelta(days=7)).isoformat()
async with db_engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
"VALUES (:id, :email, :hashed_password, :display_name, :email_verified, :created_at, :updated_at)"
),
{
"id": user_id,
"email": email,
"hashed_password": "not-used-with-better-auth",
"display_name": display_name,
"email_verified": False,
"created_at": now,
"updated_at": now,
},
)
await conn.execute(
text(
"INSERT INTO sessions (id, token, user_id, expires_at, created_at, updated_at) "
"VALUES (:id, :token, :user_id, :expires_at, :created_at, :updated_at)"
),
{
"id": session_id,
"token": session_token,
"user_id": user_id,
"expires_at": expires,
"created_at": now,
"updated_at": now,
},
)
return {"id": user_id, "email": email, "display_name": display_name}, session_token
@pytest.fixture
async def auth_headers(client, db_engine):
"""Create a test user with a valid session and return auth headers."""
_, session_token = await _create_test_user_and_session(client, db_engine)
return {"Cookie": f"better-auth.session_token={session_token}"}
-173
View File
@@ -1,173 +0,0 @@
"""Integration tests for auth profile endpoints.
Registration, login, and session management are handled by the Better-Auth
service. These tests cover the profile endpoints (GET/PATCH/DELETE /auth/me)
which validate sessions via the shared sessions table.
"""
import pytest
@pytest.mark.asyncio
async def test_get_me(client, auth_headers):
resp = await client.get("/auth/me", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["email"] == "test@example.com"
assert data["display_name"] == "Test User"
assert "id" in data
assert "created_at" in data
@pytest.mark.asyncio
async def test_get_me_unauthorized(client):
resp = await client.get("/auth/me")
assert resp.status_code in (401, 403)
@pytest.mark.asyncio
async def test_get_me_invalid_session(client):
resp = await client.get(
"/auth/me",
headers={"Cookie": "better-auth.session_token=invalid-token"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
async def test_get_me_with_bearer_token(client, db_engine):
"""Session tokens can also be passed as Bearer tokens for API clients."""
from tests.conftest import _create_test_user_and_session
_, session_token = await _create_test_user_and_session(
client, db_engine, email="bearer@example.com", display_name="Bearer User"
)
resp = await client.get(
"/auth/me",
headers={"Authorization": f"Bearer {session_token}"},
)
assert resp.status_code == 200
assert resp.json()["email"] == "bearer@example.com"
@pytest.mark.asyncio
async def test_update_me(client, auth_headers):
resp = await client.patch(
"/auth/me",
headers=auth_headers,
json={"display_name": "Updated Name"},
)
assert resp.status_code == 200
assert resp.json()["display_name"] == "Updated Name"
@pytest.mark.asyncio
async def test_delete_me(client, auth_headers):
resp = await client.delete("/auth/me", headers=auth_headers)
assert resp.status_code == 204
# Session is still valid but user is gone
resp = await client.get("/auth/me", headers=auth_headers)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_get_me_compound_cookie(client, db_engine):
"""Compound cookie value (token.sessionId) must be parsed to extract the token part."""
from tests.conftest import _create_test_user_and_session
_, session_token = await _create_test_user_and_session(
client, db_engine, email="compound@example.com", display_name="Compound User"
)
compound = f"{session_token}.B0atkJCFxK1rZlwWPMK97nVO2LnyDun7"
resp = await client.get(
"/auth/me",
headers={"Cookie": f"better-auth.session_token={compound}"},
)
assert resp.status_code == 200
assert resp.json()["email"] == "compound@example.com"
@pytest.mark.asyncio
async def test_get_me_raw_token_cookie(client, db_engine):
"""Raw token (no dot) in cookie must still work — regression guard."""
from tests.conftest import _create_test_user_and_session
_, session_token = await _create_test_user_and_session(
client, db_engine, email="rawcookie@example.com", display_name="Raw Cookie User"
)
resp = await client.get(
"/auth/me",
headers={"Cookie": f"better-auth.session_token={session_token}"},
)
assert resp.status_code == 200
assert resp.json()["email"] == "rawcookie@example.com"
@pytest.mark.asyncio
async def test_get_me_compound_bearer(client, db_engine):
"""Compound Bearer token (token.sessionId) must be parsed to extract the token part."""
from tests.conftest import _create_test_user_and_session
_, session_token = await _create_test_user_and_session(
client, db_engine, email="compoundbearer@example.com", display_name="Compound Bearer User"
)
compound = f"{session_token}.B0atkJCFxK1rZlwWPMK97nVO2LnyDun7"
resp = await client.get(
"/auth/me",
headers={"Authorization": f"Bearer {compound}"},
)
assert resp.status_code == 200
assert resp.json()["email"] == "compoundbearer@example.com"
@pytest.mark.asyncio
async def test_expired_session_rejected(client, db_engine):
"""Expired sessions must be rejected."""
import secrets
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import text
user_id = str(uuid.uuid4())
session_token = secrets.token_urlsafe(32)
now = datetime.now(UTC).isoformat()
expired = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
async with db_engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
"VALUES (:id, :email, :hp, :dn, :ev, :ca, :ua)"
),
{
"id": user_id,
"email": "expired@example.com",
"hp": "unused",
"dn": "Expired User",
"ev": False,
"ca": now,
"ua": now,
},
)
await conn.execute(
text(
"INSERT INTO sessions (id, token, user_id, expires_at, created_at, updated_at) "
"VALUES (:id, :token, :uid, :ea, :ca, :ua)"
),
{
"id": str(uuid.uuid4()),
"token": session_token,
"uid": user_id,
"ea": expired,
"ca": now,
"ua": now,
},
)
resp = await client.get(
"/auth/me",
headers={"Cookie": f"better-auth.session_token={session_token}"},
)
assert resp.status_code == 401
-50
View File
@@ -1,50 +0,0 @@
"""Tests for Redis/DragonflyDB caching lifecycle."""
import pytest
from cartsnitch_api.cache import CacheClient, close_redis, get_redis, init_redis
@pytest.mark.asyncio
async def test_init_redis_creates_client():
"""Test that init_redis creates the Redis client."""
await init_redis()
try:
r = get_redis()
assert r is not None
await r.ping()
finally:
await close_redis()
@pytest.mark.asyncio
async def test_close_redis_clears_client():
"""Test that close_redis properly closes and clears the client."""
await init_redis()
await close_redis()
assert get_redis() is None
@pytest.mark.asyncio
async def test_cache_client_get_returns_none_when_not_connected():
"""Test that CacheClient.get returns None gracefully when Redis is down."""
client = CacheClient()
# Without init_redis, get should return None
result = await client.get("test-key")
assert result is None
@pytest.mark.asyncio
async def test_cache_client_set_does_not_raise_when_not_connected():
"""Test that CacheClient.set does not raise when Redis is down."""
client = CacheClient()
# Without init_redis, set should not raise
await client.set("test-key", "test-value", ttl_seconds=60)
@pytest.mark.asyncio
async def test_cache_client_delete_does_not_raise_when_not_connected():
"""Test that CacheClient.delete does not raise when Redis is down."""
client = CacheClient()
# Without init_redis, delete should not raise
await client.delete("test-key")
-48
View File
@@ -1,48 +0,0 @@
"""Tests for Settings config, specifically the database_url env var fallback."""
import os
from cartsnitch_api.config import Settings
def test_database_url_prefers_cartsnitch_prefix():
"""CARTSNITCH_DATABASE_URL takes precedence over DATABASE_URL."""
env = {
"CARTSNITCH_DATABASE_URL": "postgresql+asyncpg://user1:pass1@host1:5432/db1",
"DATABASE_URL": "postgresql://user2:pass2@host2:5432/db2",
}
settings = Settings(**env)
assert settings.database_url == "postgresql+asyncpg://user1:pass1@host1:5432/db1"
def test_database_url_falls_back_to_database_url():
"""When CARTSNITCH_DATABASE_URL is absent, DATABASE_URL is accepted."""
env = {
"DATABASE_URL": "postgresql://user:pass@dbhost:5432/mydb",
}
settings = Settings(**env)
assert settings.database_url == "postgresql+asyncpg://user:pass@dbhost:5432/mydb"
def test_database_url_normalizes_plain_postgresql_prefix():
"""DATABASE_URL with plain postgresql:// is normalized to postgresql+asyncpg://."""
env = {
"DATABASE_URL": "postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
}
settings = Settings(**env)
assert settings.database_url == "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
def test_database_url_preserves_asyncpg_prefix():
"""CARTSNITCH_DATABASE_URL with postgresql+asyncpg:// is left unchanged."""
env = {
"CARTSNITCH_DATABASE_URL": "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
}
settings = Settings(**env)
assert settings.database_url == "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
def test_database_url_default():
"""When neither env var is set, the hardcoded default is used."""
settings = Settings()
assert settings.database_url == "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
-62
View File
@@ -1,62 +0,0 @@
"""Tests for database initialization and lifecycle."""
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from cartsnitch_api.database import (
close_db,
create_db_engine,
get_engine,
init_db,
)
@pytest.mark.asyncio
async def test_create_db_engine_creates_engine_with_pool_settings():
"""Test that create_db_engine creates engine with correct pool settings."""
engine = create_db_engine()
assert engine is not None
pool = engine.pool
assert pool.size() == 10
assert pool._max_overflow == 20
await engine.dispose()
@pytest.mark.asyncio
async def test_init_db_sets_engine_and_factory():
"""Test that init_db properly initializes the engine and session factory."""
await init_db()
try:
eng = get_engine()
assert eng is not None
from cartsnitch_api import database
assert database.async_session_factory is not None
finally:
await close_db()
@pytest.mark.asyncio
async def test_close_db_disposes_engine():
"""Test that close_db properly disposes the engine."""
await init_db()
await close_db()
assert get_engine() is None
from cartsnitch_api import database
assert database.async_session_factory is None
@pytest.mark.asyncio
async def test_get_db_yields_session_after_init():
"""Test that get_db yields working sessions after init_db."""
await init_db()
try:
from cartsnitch_api.database import get_db
gen = get_db()
session = await gen.__anext__()
assert isinstance(session, AsyncSession)
await gen.aclose()
finally:
await close_db()
-256
View File
@@ -1,256 +0,0 @@
"""Shared fixtures for E2E integration tests.
Seeds a realistic dataset with stores, products, price history,
purchases, coupons, and shrinkflation events so E2E flows can
exercise cross-resource queries against real data.
"""
from datetime import date, timedelta
from decimal import Decimal
from uuid import UUID
import pytest
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from cartsnitch_api.models import (
Coupon,
NormalizedProduct,
PriceHistory,
Purchase,
PurchaseItem,
ShrinkflationEvent,
Store,
)
# Shared test constants
ZERO_UUID = "00000000-0000-0000-0000-000000000000"
BAD_UUID = "not-a-uuid"
# Fixed anchor date for deterministic tests
ANCHOR_DATE = date(2026, 3, 15)
@pytest.fixture
async def seed_data(db_engine, auth_headers):
"""Seed a full dataset and return identifiers for test assertions."""
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
# -- Stores --
meijer = Store(name="Meijer", slug="meijer")
kroger = Store(name="Kroger", slug="kroger")
target = Store(name="Target", slug="target")
session.add_all([meijer, kroger, target])
await session.flush()
# -- Products --
cheerios = NormalizedProduct(
canonical_name="Cheerios 18oz",
category="pantry",
brand="General Mills",
size="18",
size_unit="oz",
upc_variants=["016000275263"],
)
milk = NormalizedProduct(
canonical_name="Whole Milk 1gal",
category="dairy",
brand="Meijer",
size="1",
size_unit="gal",
)
chicken = NormalizedProduct(
canonical_name="Chicken Breast 1lb",
category="meat",
brand=None,
size="1",
size_unit="lb",
)
session.add_all([cheerios, milk, chicken])
await session.flush()
# -- Price history (multiple dates, multiple stores) --
today = ANCHOR_DATE
prices = []
# Cheerios at Meijer: price increase over time
for i, price_val in enumerate([Decimal("3.99"), Decimal("4.29"), Decimal("4.79")]):
prices.append(
PriceHistory(
normalized_product_id=cheerios.id,
store_id=meijer.id,
observed_date=today - timedelta(days=60 - i * 30),
regular_price=price_val,
source="receipt",
)
)
# Cheerios at Kroger: stable price
for i in range(3):
prices.append(
PriceHistory(
normalized_product_id=cheerios.id,
store_id=kroger.id,
observed_date=today - timedelta(days=60 - i * 30),
regular_price=Decimal("4.49"),
source="catalog",
)
)
# Milk at Meijer
prices.append(
PriceHistory(
normalized_product_id=milk.id,
store_id=meijer.id,
observed_date=today - timedelta(days=7),
regular_price=Decimal("3.29"),
source="receipt",
)
)
# Milk at Kroger
prices.append(
PriceHistory(
normalized_product_id=milk.id,
store_id=kroger.id,
observed_date=today - timedelta(days=5),
regular_price=Decimal("3.49"),
source="catalog",
)
)
# Chicken at Target
prices.append(
PriceHistory(
normalized_product_id=chicken.id,
store_id=target.id,
observed_date=today - timedelta(days=3),
regular_price=Decimal("5.99"),
source="catalog",
)
)
session.add_all(prices)
await session.flush()
# -- Get the user_id from the session token in auth_headers --
cookie_str = auth_headers.get("Cookie", "")
session_token = cookie_str.split("=", 1)[1] if "=" in cookie_str else ""
result = await session.execute(
text("SELECT user_id FROM sessions WHERE token = :token"),
{"token": session_token},
)
row = result.first()
user_id = UUID(row[0])
purchase1 = Purchase(
user_id=user_id,
store_id=meijer.id,
receipt_id="meijer-2026-001",
purchase_date=today - timedelta(days=10),
total=Decimal("23.45"),
subtotal=Decimal("21.50"),
tax=Decimal("1.95"),
)
purchase2 = Purchase(
user_id=user_id,
store_id=kroger.id,
receipt_id="kroger-2026-001",
purchase_date=today - timedelta(days=5),
total=Decimal("15.78"),
subtotal=Decimal("14.50"),
tax=Decimal("1.28"),
)
session.add_all([purchase1, purchase2])
await session.flush()
# -- Purchase Items --
item1 = PurchaseItem(
purchase_id=purchase1.id,
product_name_raw="Cheerios 18oz Box",
quantity=Decimal("1"),
unit_price=Decimal("4.79"),
extended_price=Decimal("4.79"),
normalized_product_id=cheerios.id,
)
item2 = PurchaseItem(
purchase_id=purchase1.id,
product_name_raw="Meijer Whole Milk 1gal",
quantity=Decimal("2"),
unit_price=Decimal("3.29"),
extended_price=Decimal("6.58"),
normalized_product_id=milk.id,
)
item3 = PurchaseItem(
purchase_id=purchase2.id,
product_name_raw="KRO CHEERIOS 18OZ",
quantity=Decimal("1"),
unit_price=Decimal("4.49"),
extended_price=Decimal("4.49"),
normalized_product_id=cheerios.id,
)
session.add_all([item1, item2, item3])
await session.flush()
# -- Coupons --
coupon1 = Coupon(
store_id=meijer.id,
normalized_product_id=cheerios.id,
title="$1 off Cheerios",
description="Save $1 on any Cheerios 18oz or larger",
discount_type="fixed",
discount_value=Decimal("1.00"),
valid_from=today - timedelta(days=7),
valid_to=today + timedelta(days=30),
)
coupon2 = Coupon(
store_id=kroger.id,
normalized_product_id=None,
title="10% off dairy",
description="10% off all dairy products",
discount_type="percent",
discount_value=Decimal("10.00"),
valid_from=today - timedelta(days=3),
valid_to=today + timedelta(days=14),
)
session.add_all([coupon1, coupon2])
await session.flush()
# -- Shrinkflation events --
shrink = ShrinkflationEvent(
normalized_product_id=cheerios.id,
detected_date=today - timedelta(days=15),
old_size="20",
new_size="18",
old_unit="oz",
new_unit="oz",
price_at_old_size=Decimal("3.99"),
price_at_new_size=Decimal("4.29"),
confidence=Decimal("0.95"),
notes="Size reduced from 20oz to 18oz while price increased",
)
session.add(shrink)
await session.commit()
for obj in [
meijer,
kroger,
target,
cheerios,
milk,
chicken,
purchase1,
purchase2,
item1,
item2,
item3,
coupon1,
coupon2,
shrink,
]:
await session.refresh(obj)
return {
"headers": auth_headers,
"user_id": user_id,
"stores": {"meijer": meijer, "kroger": kroger, "target": target},
"products": {"cheerios": cheerios, "milk": milk, "chicken": chicken},
"purchases": {"meijer_trip": purchase1, "kroger_trip": purchase2},
"items": {"cheerios_meijer": item1, "milk_meijer": item2, "cheerios_kroger": item3},
"coupons": {"cheerios_coupon": coupon1, "dairy_coupon": coupon2},
"shrinkflation": {"cheerios_shrink": shrink},
}
-162
View File
@@ -1,162 +0,0 @@
"""E2E: Auth and session validation flows.
Registration and login are handled by the Better-Auth service.
These tests validate session token handling at the API gateway level.
"""
import pytest
from tests.conftest import _create_test_user_and_session
@pytest.mark.asyncio
class TestSessionValidation:
"""Session edge cases and error responses."""
async def test_invalid_session_token_rejected(self, client, db_engine):
resp = await client.get(
"/auth/me",
headers={"Cookie": "better-auth.session_token=not-a-real-token"},
)
assert resp.status_code == 401
async def test_missing_auth(self, client, db_engine):
resp = await client.get("/auth/me")
assert resp.status_code in (401, 403)
async def test_bearer_token_also_works(self, client, db_engine):
"""Session tokens passed as Bearer tokens should also be accepted."""
_, session_token = await _create_test_user_and_session(
client, db_engine, email="bearer@e2e.com", display_name="Bearer E2E"
)
resp = await client.get(
"/auth/me",
headers={"Authorization": f"Bearer {session_token}"},
)
assert resp.status_code == 200
assert resp.json()["email"] == "bearer@e2e.com"
async def test_deleted_user_session_returns_not_found(self, client, db_engine):
"""After deleting a user, their session should result in 404 for profile."""
_, session_token = await _create_test_user_and_session(
client, db_engine, email="delete-me@e2e.com", display_name="Delete Me"
)
headers = {"Cookie": f"better-auth.session_token={session_token}"}
delete_resp = await client.delete("/auth/me", headers=headers)
assert delete_resp.status_code == 204
me = await client.get("/auth/me", headers=headers)
assert me.status_code == 404
async def test_expired_session_rejected(self, client, db_engine):
"""Expired sessions must be rejected."""
import secrets
import uuid
from datetime import UTC, datetime, timedelta
from sqlalchemy import text
user_id = str(uuid.uuid4())
session_token = secrets.token_urlsafe(32)
now = datetime.now(UTC).isoformat()
expired = (datetime.now(UTC) - timedelta(hours=1)).isoformat()
async with db_engine.begin() as conn:
await conn.execute(
text(
"INSERT INTO users (id, email, hashed_password, display_name, email_verified, created_at, updated_at) "
"VALUES (:id, :email, :hp, :dn, :ev, :ca, :ua)"
),
{
"id": user_id,
"email": "expired@e2e.com",
"hp": "unused",
"dn": "Expired User",
"ev": False,
"ca": now,
"ua": now,
},
)
await conn.execute(
text(
"INSERT INTO sessions (id, token, user_id, expires_at, created_at, updated_at) "
"VALUES (:id, :token, :uid, :ea, :ca, :ua)"
),
{
"id": str(uuid.uuid4()),
"token": session_token,
"uid": user_id,
"ea": expired,
"ca": now,
"ua": now,
},
)
resp = await client.get(
"/auth/me",
headers={"Cookie": f"better-auth.session_token={session_token}"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
class TestAuthProtectedEndpoints:
"""Verify auth is enforced on all user-specific endpoints."""
@pytest.mark.parametrize(
"method,path",
[
("GET", "/purchases"),
("GET", "/products"),
("GET", "/prices/trends"),
("GET", "/prices/increases"),
("GET", "/coupons"),
("GET", "/alerts"),
("GET", "/me/stores"),
],
)
async def test_endpoints_require_auth(self, client, db_engine, method, path):
resp = await client.request(method, path)
assert resp.status_code in (401, 403), f"{method} {path} should require auth"
@pytest.mark.asyncio
class TestCrossUserDataIsolation:
"""Verify that users cannot access other users' data."""
async def test_user_b_cannot_access_user_a_purchases(self, client, db_engine, seed_data):
"""A second user cannot see User A's purchases."""
purchase_id = str(seed_data["purchases"]["meijer_trip"].id)
_, session_token = await _create_test_user_and_session(
client, db_engine, email="userb@e2e.com", display_name="User B"
)
user_b_headers = {"Cookie": f"better-auth.session_token={session_token}"}
resp = await client.get(f"/purchases/{purchase_id}", headers=user_b_headers)
assert resp.status_code in (403, 404), (
"User B should not be able to access User A's purchase"
)
async def test_user_b_purchase_list_is_empty(self, client, db_engine, seed_data):
"""A new user should see no purchases."""
_, session_token = await _create_test_user_and_session(
client, db_engine, email="userc@e2e.com", display_name="User C"
)
user_c_headers = {"Cookie": f"better-auth.session_token={session_token}"}
resp = await client.get("/purchases", headers=user_c_headers)
assert resp.status_code == 200
assert len(resp.json()) == 0, "New user should have no purchases"
async def test_user_b_stores_isolated(self, client, db_engine, seed_data):
"""User B's connected stores should be independent from User A."""
_, session_token = await _create_test_user_and_session(
client, db_engine, email="userd@e2e.com", display_name="User D"
)
user_d_headers = {"Cookie": f"better-auth.session_token={session_token}"}
resp = await client.get("/me/stores", headers=user_d_headers)
assert resp.status_code == 200
assert len(resp.json()) == 0, "New user should have no connected stores"
@@ -1,114 +0,0 @@
"""E2E: Cross-resource flows — store connect → purchases → prices → coupons → alerts."""
import pytest
@pytest.mark.asyncio
class TestStoreConnectToPurchaseFlow:
"""Connect a store, then verify purchases and related data are accessible."""
async def test_connect_store_then_list(self, client, seed_data):
headers = seed_data["headers"]
# Connect to Meijer
resp = await client.post("/me/stores/meijer/connect", json={}, headers=headers)
assert resp.status_code in (200, 201)
# Verify store appears in user's connected stores
stores = await client.get("/me/stores", headers=headers)
assert stores.status_code == 200
slugs = [s["store"]["slug"] for s in stores.json()]
assert "meijer" in slugs
async def test_disconnect_store(self, client, seed_data):
headers = seed_data["headers"]
await client.post("/me/stores/kroger/connect", json={}, headers=headers)
resp = await client.delete("/me/stores/kroger", headers=headers)
assert resp.status_code in (200, 204)
# Verify store no longer in connected list
stores = await client.get("/me/stores", headers=headers)
slugs = [s["store"]["slug"] for s in stores.json()]
assert "kroger" not in slugs
@pytest.mark.asyncio
class TestPurchaseToPriceFlow:
"""Verify purchase data links to price comparison data."""
async def test_purchase_items_link_to_products(self, client, seed_data):
"""Items from purchases reference products that have price data."""
headers = seed_data["headers"]
purchase_id = str(seed_data["purchases"]["meijer_trip"].id)
# Get purchase detail
purchase = await client.get(f"/purchases/{purchase_id}", headers=headers)
assert purchase.status_code == 200
items = purchase.json()["line_items"]
# Get product detail for an item that has a product_id
product_ids = [li["product_id"] for li in items if li.get("product_id")]
assert len(product_ids) >= 1
for pid in product_ids:
product = await client.get(f"/products/{pid}", headers=headers)
assert product.status_code == 200
assert len(product.json()["prices_by_store"]) >= 1
@pytest.mark.asyncio
class TestCouponFlow:
"""Verify coupon listing and relevance filtering."""
async def test_list_all_coupons(self, client, seed_data):
headers = seed_data["headers"]
resp = await client.get("/coupons", headers=headers)
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 2
descriptions = [c["description"] for c in data]
assert any("Cheerios" in d for d in descriptions)
async def test_filter_coupons_by_store(self, client, seed_data):
headers = seed_data["headers"]
meijer_id = str(seed_data["stores"]["meijer"].id)
resp = await client.get("/coupons", params={"store_id": meijer_id}, headers=headers)
assert resp.status_code == 200
data = resp.json()
assert all(c["store_name"] == "Meijer" for c in data)
async def test_relevant_coupons_for_user(self, client, seed_data):
"""User bought Cheerios, so the Cheerios coupon should be relevant."""
headers = seed_data["headers"]
resp = await client.get("/coupons/relevant", headers=headers)
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 1, "Expected at least one relevant coupon for user with purchases"
descriptions = [c["description"] for c in data]
assert any("Cheerios" in d for d in descriptions)
@pytest.mark.asyncio
class TestAlertFlow:
"""Verify alert listing with seeded data."""
async def test_list_alerts(self, client, seed_data):
"""User bought Cheerios which has a shrinkflation event — may appear as alert."""
headers = seed_data["headers"]
resp = await client.get("/alerts", headers=headers)
assert resp.status_code == 200
data = resp.json()
assert isinstance(data, list)
# If alerts are generated synchronously, verify shrinkflation alert content
if len(data) > 0:
alert_types = [a["alert_type"] for a in data]
product_names = [a["product_name"] for a in data]
assert any(t in ("shrinkflation", "price_increase") for t in alert_types)
assert any("Cheerios" in name for name in product_names)
async def test_alert_settings_default(self, client, seed_data):
headers = seed_data["headers"]
resp = await client.get("/alerts/settings", headers=headers)
assert resp.status_code == 200
data = resp.json()
assert "price_increase_threshold_pct" in data
assert "shrinkflation_enabled" in data
@@ -1,61 +0,0 @@
"""Tests for GET /api/v1/me/email-in-address endpoint."""
import pytest
from httpx import AsyncClient
@pytest.mark.asyncio
async def test_get_email_in_address_authenticated(client: AsyncClient, auth_headers: dict):
"""Authenticated user gets their email-in address."""
response = await client.get(
"/api/v1/me/email-in-address",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert "email_address" in data
assert data["email_address"].startswith("receipts+")
assert data["email_address"].endswith("@receipts.cartsnitch.com")
assert len(data["email_address"]) > len("receipts+@receipts.cartsnitch.com")
assert "instructions" in data
assert "Meijer" in data["instructions"]
assert "Kroger" in data["instructions"]
assert "Target" in data["instructions"]
@pytest.mark.asyncio
async def test_get_email_in_address_unauthenticated(client: AsyncClient):
"""Unauthenticated request returns 401."""
response = await client.get("/api/v1/me/email-in-address")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_get_email_in_address_invalid_token(client: AsyncClient):
"""Invalid JWT token returns 401."""
response = await client.get(
"/api/v1/me/email-in-address",
headers={"Authorization": "Bearer invalid-token-xyz"},
)
assert response.status_code == 401
@pytest.mark.asyncio
async def test_email_address_format(client: AsyncClient, auth_headers: dict):
"""Email address format is receipts+{22-char-urlsafe-token}@receipts.cartsnitch.com."""
response = await client.get(
"/api/v1/me/email-in-address",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
email = data["email_address"]
# Format: receipts+<22-char-urlsafe-token>@receipts.cartsnitch.com
assert email.startswith("receipts+")
assert email.endswith("@receipts.cartsnitch.com")
# token_urlsafe(16) produces 22 chars
middle = email[len("receipts+") : -len("@receipts.cartsnitch.com")]
assert len(middle) == 22
assert "@" not in middle
-127
View File
@@ -1,127 +0,0 @@
"""E2E: Error responses for bad input across all endpoint categories."""
import pytest
from tests.test_e2e.conftest import BAD_UUID, ZERO_UUID
@pytest.mark.asyncio
class TestRegistrationErrors:
"""Validation errors during user registration."""
async def test_short_password(self, client, db_engine):
resp = await client.post(
"/auth/register",
json={"email": "short@example.com", "password": "short", "display_name": "Test"},
)
assert resp.status_code == 422
async def test_invalid_email(self, client, db_engine):
resp = await client.post(
"/auth/register",
json={"email": "not-an-email", "password": "securepass123", "display_name": "Test"},
)
assert resp.status_code == 422
async def test_missing_fields(self, client, db_engine):
resp = await client.post("/auth/register", json={})
assert resp.status_code == 422
async def test_empty_display_name(self, client, db_engine):
resp = await client.post(
"/auth/register",
json={"email": "empty@example.com", "password": "securepass123", "display_name": ""},
)
assert resp.status_code == 422
async def test_duplicate_email(self, client, db_engine):
payload = {
"email": "dupe@example.com",
"password": "securepass123",
"display_name": "First",
}
first = await client.post("/auth/register", json=payload)
assert first.status_code == 201
second = await client.post("/auth/register", json=payload)
assert second.status_code == 409
@pytest.mark.asyncio
class TestLoginErrors:
"""Login failure modes."""
async def test_wrong_password(self, client, db_engine):
await client.post(
"/auth/register",
json={
"email": "login-err@example.com",
"password": "correctpass1",
"display_name": "Login",
},
)
resp = await client.post(
"/auth/login",
json={"email": "login-err@example.com", "password": "wrongpass123"},
)
assert resp.status_code == 401
async def test_nonexistent_user(self, client, db_engine):
resp = await client.post(
"/auth/login",
json={"email": "nobody@example.com", "password": "doesntmatter"},
)
assert resp.status_code == 401
@pytest.mark.asyncio
class TestNotFoundErrors:
"""404 responses for missing resources."""
async def test_product_not_found(self, client, seed_data):
resp = await client.get(f"/products/{ZERO_UUID}", headers=seed_data["headers"])
assert resp.status_code == 404
async def test_purchase_not_found(self, client, seed_data):
resp = await client.get(f"/purchases/{ZERO_UUID}", headers=seed_data["headers"])
assert resp.status_code == 404
async def test_public_trend_not_found(self, client, seed_data):
resp = await client.get(f"/public/trends/{ZERO_UUID}")
assert resp.status_code == 404
@pytest.mark.asyncio
class TestMalformedInput:
"""Invalid UUID formats and bad query params."""
async def test_invalid_uuid_product(self, client, seed_data):
resp = await client.get(f"/products/{BAD_UUID}", headers=seed_data["headers"])
assert resp.status_code == 422
async def test_invalid_uuid_purchase(self, client, seed_data):
resp = await client.get(f"/purchases/{BAD_UUID}", headers=seed_data["headers"])
assert resp.status_code == 422
async def test_invalid_uuid_public_trend(self, client, seed_data):
resp = await client.get(f"/public/trends/{BAD_UUID}")
assert resp.status_code == 422
@pytest.mark.asyncio
class TestStoreConnectionErrors:
"""Store connection edge cases."""
async def test_connect_nonexistent_store(self, client, seed_data):
resp = await client.post(
"/me/stores/nonexistent-store/connect",
json={},
headers=seed_data["headers"],
)
assert resp.status_code == 404
async def test_connect_store_twice(self, client, seed_data):
headers = seed_data["headers"]
first = await client.post("/me/stores/meijer/connect", json={}, headers=headers)
assert first.status_code in (200, 201)
second = await client.post("/me/stores/meijer/connect", json={}, headers=headers)
assert second.status_code == 409
-102
View File
@@ -1,102 +0,0 @@
"""E2E: Price history queries returning correct data."""
import pytest
@pytest.mark.asyncio
class TestPriceTrends:
"""Verify price trend aggregation against seeded history."""
async def test_trends_returns_all_products(self, client, seed_data):
resp = await client.get("/prices/trends", headers=seed_data["headers"])
assert resp.status_code == 200
data = resp.json()
product_names = [t["product_name"] for t in data]
assert "Cheerios 18oz" in product_names
assert "Whole Milk 1gal" in product_names
async def test_trends_filter_by_category(self, client, seed_data):
resp = await client.get(
"/prices/trends", params={"category": "dairy"}, headers=seed_data["headers"]
)
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 1
# Only dairy products should appear
for trend in data:
assert trend["product_name"] == "Whole Milk 1gal"
async def test_trends_contain_data_points(self, client, seed_data):
resp = await client.get("/prices/trends", headers=seed_data["headers"])
data = resp.json()
cheerios_trend = next(t for t in data if t["product_name"] == "Cheerios 18oz")
assert len(cheerios_trend["data_points"]) >= 3
@pytest.mark.asyncio
class TestPriceIncreases:
"""Detect price increases from seeded price history."""
async def test_increases_detected(self, client, seed_data):
resp = await client.get("/prices/increases", headers=seed_data["headers"])
assert resp.status_code == 200
data = resp.json()
# Cheerios at Meijer went from 3.99 → 4.29 → 4.79
cheerios_increases = [inc for inc in data if inc["product_name"] == "Cheerios 18oz"]
assert len(cheerios_increases) >= 1
# Verify the increase data makes sense
for inc in cheerios_increases:
assert inc["new_price"] > inc["old_price"]
assert inc["increase_pct"] > 0
assert inc["store_name"] == "Meijer"
async def test_stable_prices_not_flagged(self, client, seed_data):
"""Kroger Cheerios price is stable at $4.49 — should not appear as increase."""
resp = await client.get("/prices/increases", headers=seed_data["headers"])
data = resp.json()
kroger_increases = [
inc
for inc in data
if inc["product_name"] == "Cheerios 18oz" and inc["store_name"] == "Kroger"
]
assert len(kroger_increases) == 0
@pytest.mark.asyncio
class TestPriceComparison:
"""Compare prices across stores for specific products."""
async def test_compare_cheerios_across_stores(self, client, seed_data):
cheerios_id = str(seed_data["products"]["cheerios"].id)
resp = await client.get(
"/prices/comparison",
params={"product_ids": cheerios_id},
headers=seed_data["headers"],
)
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 1
cheerios_cmp = data[0]
assert cheerios_cmp["product_name"] == "Cheerios 18oz"
store_names = [p["store_name"] for p in cheerios_cmp["prices"]]
assert "Meijer" in store_names
assert "Kroger" in store_names
async def test_compare_requires_product_ids(self, client, seed_data):
"""product_ids is required — omitting it must return 422."""
resp = await client.get("/prices/comparison", headers=seed_data["headers"])
assert resp.status_code == 422
async def test_compare_multiple_products(self, client, seed_data):
cheerios_id = str(seed_data["products"]["cheerios"].id)
milk_id = str(seed_data["products"]["milk"].id)
resp = await client.get(
"/prices/comparison",
params=[("product_ids", cheerios_id), ("product_ids", milk_id)],
headers=seed_data["headers"],
)
assert resp.status_code == 200
data = resp.json()
names = [c["product_name"] for c in data]
assert "Cheerios 18oz" in names
assert "Whole Milk 1gal" in names
@@ -1,82 +0,0 @@
"""E2E: Product search/lookup endpoints with real DB fixtures."""
import pytest
from tests.test_e2e.conftest import ZERO_UUID
@pytest.mark.asyncio
class TestProductSearch:
"""Search and filter products against seeded data."""
async def test_list_all_products(self, client, seed_data):
resp = await client.get("/products", headers=seed_data["headers"])
assert resp.status_code == 200
products = resp.json()
names = [p["name"] for p in products]
assert "Cheerios 18oz" in names
assert "Whole Milk 1gal" in names
assert "Chicken Breast 1lb" in names
async def test_search_by_name(self, client, seed_data):
resp = await client.get("/products", params={"q": "cheerios"}, headers=seed_data["headers"])
assert resp.status_code == 200
products = resp.json()
assert len(products) >= 1
assert all("cheerios" in p["name"].lower() for p in products)
async def test_search_by_category(self, client, seed_data):
resp = await client.get(
"/products", params={"category": "dairy"}, headers=seed_data["headers"]
)
assert resp.status_code == 200
products = resp.json()
assert len(products) >= 1
assert all(p["category"] == "dairy" for p in products)
async def test_search_no_results(self, client, seed_data):
resp = await client.get(
"/products", params={"q": "nonexistentxyz"}, headers=seed_data["headers"]
)
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
class TestProductLookup:
"""Detailed product lookups with cross-store pricing."""
async def test_get_product_detail_with_prices(self, client, seed_data):
cheerios_id = str(seed_data["products"]["cheerios"].id)
resp = await client.get(f"/products/{cheerios_id}", headers=seed_data["headers"])
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "Cheerios 18oz"
assert data["brand"] == "General Mills"
assert data["category"] == "pantry"
# Should have prices from both Meijer and Kroger
store_names = [p["store_name"] for p in data["prices_by_store"]]
assert "Meijer" in store_names
assert "Kroger" in store_names
async def test_product_prices_reflect_latest(self, client, seed_data):
"""The latest Meijer price for Cheerios should be 4.79 (the increase)."""
cheerios_id = str(seed_data["products"]["cheerios"].id)
resp = await client.get(f"/products/{cheerios_id}", headers=seed_data["headers"])
data = resp.json()
meijer_price = next(p for p in data["prices_by_store"] if p["store_name"] == "Meijer")
assert meijer_price["current_price"] == 4.79
async def test_product_not_found(self, client, seed_data):
resp = await client.get(f"/products/{ZERO_UUID}", headers=seed_data["headers"])
assert resp.status_code == 404
async def test_product_price_history(self, client, seed_data):
cheerios_id = str(seed_data["products"]["cheerios"].id)
resp = await client.get(f"/products/{cheerios_id}/prices", headers=seed_data["headers"])
assert resp.status_code == 200
data = resp.json()
assert len(data["data_points"]) >= 3 # At least the 3 Meijer observations
# Verify chronological ordering exists
prices = [dp["price"] for dp in data["data_points"]]
assert len(prices) >= 3
@@ -1,59 +0,0 @@
"""E2E: Public price transparency endpoints (no auth required)."""
import uuid
import pytest
@pytest.mark.asyncio
class TestPublicTrends:
"""Public price trend endpoint — no auth, real data."""
async def test_public_trend_returns_data(self, client, seed_data):
cheerios_id = str(seed_data["products"]["cheerios"].id)
resp = await client.get(f"/public/trends/{cheerios_id}")
assert resp.status_code == 200
data = resp.json()
assert data["product_name"] == "Cheerios 18oz"
assert len(data["data_points"]) >= 3
async def test_public_trend_no_auth_needed(self, client, seed_data):
"""Confirm no Authorization header is required."""
cheerios_id = str(seed_data["products"]["cheerios"].id)
resp = await client.get(f"/public/trends/{cheerios_id}")
assert resp.status_code == 200
@pytest.mark.asyncio
class TestPublicStoreComparison:
"""Public store comparison endpoint."""
async def test_store_comparison(self, client, seed_data):
cheerios_id = str(seed_data["products"]["cheerios"].id)
resp = await client.get(
"/public/store-comparison",
params=[("product_ids", cheerios_id)],
)
assert resp.status_code == 200
data = resp.json()
assert "products" in data
assert len(data["products"]) >= 1
async def test_store_comparison_rejects_more_than_20_ids(self, client):
"""max_length=20 guard: 21 product IDs must return 422."""
too_many = [("product_ids", str(uuid.uuid4())) for _ in range(21)]
resp = await client.get("/public/store-comparison", params=too_many)
assert resp.status_code == 422
@pytest.mark.asyncio
class TestPublicInflation:
"""Public inflation index endpoint."""
async def test_inflation_returns_index(self, client, seed_data):
resp = await client.get("/public/inflation")
assert resp.status_code == 200
data = resp.json()
assert "cartsnitch_index" in data
assert "cpi_baseline" in data
assert "categories" in data
-87
View File
@@ -1,87 +0,0 @@
"""E2E: Purchase listing, detail, and stats against real DB fixtures."""
import pytest
from tests.test_e2e.conftest import ZERO_UUID
@pytest.mark.asyncio
class TestPurchaseList:
"""List and filter a user's purchases."""
async def test_list_user_purchases(self, client, seed_data):
resp = await client.get("/purchases", headers=seed_data["headers"])
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 2
store_names = [p["store_name"] for p in data]
assert "Meijer" in store_names
assert "Kroger" in store_names
async def test_filter_purchases_by_store(self, client, seed_data):
meijer_id = str(seed_data["stores"]["meijer"].id)
resp = await client.get(
"/purchases", params={"store_id": meijer_id}, headers=seed_data["headers"]
)
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 1
assert all(p["store_name"] == "Meijer" for p in data)
async def test_purchases_require_auth(self, client, seed_data):
resp = await client.get("/purchases")
assert resp.status_code in (401, 403)
@pytest.mark.asyncio
class TestPurchaseDetail:
"""Retrieve individual purchase with line items."""
async def test_get_purchase_detail(self, client, seed_data):
purchase_id = str(seed_data["purchases"]["meijer_trip"].id)
resp = await client.get(f"/purchases/{purchase_id}", headers=seed_data["headers"])
assert resp.status_code == 200
data = resp.json()
assert data["store_name"] == "Meijer"
assert data["total"] == 23.45
assert len(data["line_items"]) == 2
item_names = [li["name"] for li in data["line_items"]]
assert "Cheerios 18oz Box" in item_names
assert "Meijer Whole Milk 1gal" in item_names
async def test_line_item_amounts_correct(self, client, seed_data):
purchase_id = str(seed_data["purchases"]["meijer_trip"].id)
resp = await client.get(f"/purchases/{purchase_id}", headers=seed_data["headers"])
data = resp.json()
cheerios_item = next(li for li in data["line_items"] if "Cheerios" in li["name"])
assert cheerios_item["unit_price"] == 4.79
assert cheerios_item["quantity"] == 1.0
assert cheerios_item["total_price"] == 4.79
async def test_purchase_not_found(self, client, seed_data):
resp = await client.get(
f"/purchases/{ZERO_UUID}",
headers=seed_data["headers"],
)
assert resp.status_code == 404
@pytest.mark.asyncio
class TestPurchaseStats:
"""Verify spending aggregation across purchases."""
async def test_purchase_stats_totals(self, client, seed_data):
resp = await client.get("/purchases/stats", headers=seed_data["headers"])
assert resp.status_code == 200
data = resp.json()
assert data["purchase_count"] == 2
# 23.45 + 15.78 = 39.23
assert abs(data["total_spent"] - 39.23) < 0.01
async def test_purchase_stats_by_store(self, client, seed_data):
resp = await client.get("/purchases/stats", headers=seed_data["headers"])
data = resp.json()
assert "Meijer" in data["by_store"]
assert "Kroger" in data["by_store"]
assert abs(data["by_store"]["Meijer"] - 23.45) < 0.01
assert abs(data["by_store"]["Kroger"] - 15.78) < 0.01
-130
View File
@@ -1,130 +0,0 @@
"""Tests for EncryptedJSON TypeDecorator and session_data encryption."""
import json
import pytest
from cryptography.fernet import Fernet
from pydantic import ValidationError
from sqlalchemy import column, create_engine, table, text
from sqlalchemy.orm import sessionmaker
from cartsnitch_api.config import settings
from cartsnitch_api.models import Base
from cartsnitch_api.models.store import Store
from cartsnitch_api.models.user import User, UserStoreAccount
@pytest.fixture
def engine():
eng = create_engine("sqlite:///:memory:")
Base.metadata.create_all(eng)
yield eng
eng.dispose()
@pytest.fixture
def session(engine):
factory = sessionmaker(bind=engine)
with factory() as sess:
yield sess
@pytest.fixture
def store(session):
s = Store(name="Test Store", slug="test-store")
session.add(s)
session.commit()
session.refresh(s)
return s
@pytest.fixture
def user(session):
u = User(email="alice@example.com", hashed_password="fakehash")
session.add(u)
session.commit()
session.refresh(u)
return u
class TestEncryptedJSONType:
"""Unit tests for the EncryptedJSON TypeDecorator."""
def test_round_trip(self, session, user, store):
"""Data written via the ORM comes back as the original dict."""
original = {"token": "abc123", "cookies": {"session_id": "xyz"}}
account = UserStoreAccount(user_id=user.id, store_id=store.id, session_data=original)
session.add(account)
session.commit()
loaded = session.get(UserStoreAccount, account.id)
assert loaded.session_data == original
def test_stored_value_is_encrypted(self, session, user, store):
"""The raw value in the DB should be a Fernet token, not plaintext JSON."""
original = {"secret": "do-not-leak"}
account = UserStoreAccount(user_id=user.id, store_id=store.id, session_data=original)
session.add(account)
session.commit()
# Use a raw table construct to bypass TypeDecorator on read
raw_table = table("user_store_accounts", column("id"), column("session_data"))
raw = session.execute(raw_table.select().where(raw_table.c.id == str(account.id))).first()
# If UUID matching fails with str, try bytes format
if raw is None:
raw = session.execute(
text("SELECT session_data FROM user_store_accounts LIMIT 1")
).scalar_one()
else:
raw = raw[1]
assert raw != json.dumps(original)
assert raw.startswith("gAAAAA")
# Verify we can decrypt the raw value manually
f = Fernet(settings.fernet_key.encode())
decrypted = json.loads(f.decrypt(raw.encode()))
assert decrypted == original
def test_null_round_trip(self, session, user, store):
"""NULL session_data stays NULL."""
account = UserStoreAccount(user_id=user.id, store_id=store.id, session_data=None)
session.add(account)
session.commit()
loaded = session.get(UserStoreAccount, account.id)
assert loaded.session_data is None
def test_empty_dict_round_trip(self, session, user, store):
"""Empty dict round-trips correctly."""
account = UserStoreAccount(user_id=user.id, store_id=store.id, session_data={})
session.add(account)
session.commit()
loaded = session.get(UserStoreAccount, account.id)
assert loaded.session_data == {}
def test_update_session_data(self, session, user, store):
"""Updating session_data re-encrypts the new value."""
account = UserStoreAccount(user_id=user.id, store_id=store.id, session_data={"v": 1})
session.add(account)
session.commit()
account.session_data = {"v": 2, "new_field": True}
session.commit()
loaded = session.get(UserStoreAccount, account.id)
assert loaded.session_data == {"v": 2, "new_field": True}
class TestEncryptionKeyValidation:
"""Test that invalid/missing keys are caught at startup."""
def test_invalid_fernet_key_rejected(self, monkeypatch):
"""Settings validation rejects a bad key."""
monkeypatch.setenv("CARTSNITCH_FERNET_KEY", "not-a-valid-key")
with pytest.raises(ValidationError):
from cartsnitch_api.config import Settings
Settings()
-19
View File
@@ -1,19 +0,0 @@
"""Conftest for middleware tests — re-enables rate limiting after global disable."""
import pytest
from cartsnitch_api.config import settings as cartsnitch_settings
@pytest.fixture(autouse=True)
def enable_rate_limiting():
"""Re-enable rate limiting after the global disable_rate_limiting fixture runs.
The root conftest disables rate limiting for all tests to prevent 429
interference. Middleware tests need it active to verify headers and
enforcement. This fixture runs after the root fixture (more local = later
in setup order) so True is the effective value during the test body.
"""
cartsnitch_settings.rate_limit_enabled = True
yield
cartsnitch_settings.rate_limit_enabled = False
@@ -1,54 +0,0 @@
"""Tests for structured error responses and error monitoring."""
import pytest
@pytest.mark.asyncio
async def test_404_returns_structured_error(client):
"""Non-existent route should return structured error."""
resp = await client.get("/nonexistent")
assert resp.status_code == 404
body = resp.json()
assert "detail" in body
assert "code" in body
assert body["code"] == "NOT_FOUND"
@pytest.mark.asyncio
async def test_validation_error_returns_422_with_field_errors(client):
"""Invalid request body should return structured validation errors."""
resp = await client.post(
"/auth/register",
json={"email": "not-an-email", "password": "short", "display_name": ""},
)
assert resp.status_code == 422
body = resp.json()
assert body["code"] == "VALIDATION_ERROR"
assert "errors" in body
assert isinstance(body["errors"], list)
assert len(body["errors"]) > 0
# Each error should have field, message, type
for err in body["errors"]:
assert "field" in err
assert "message" in err
assert "type" in err
@pytest.mark.asyncio
async def test_error_stats_requires_service_key(client):
"""Error stats endpoint should require X-Service-Key."""
resp = await client.get("/internal/error-stats")
assert resp.status_code == 422 # Missing required header
@pytest.mark.asyncio
async def test_error_stats_with_valid_key(client):
"""Error stats endpoint returns monitoring data with valid key."""
resp = await client.get(
"/internal/error-stats",
headers={"X-Service-Key": "change-me-in-production"},
)
assert resp.status_code == 200
body = resp.json()
assert "error_counts" in body
assert "recent_5xx_count" in body
@@ -1,191 +0,0 @@
"""Tests for rate limiting middleware."""
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from cartsnitch_api.config import settings
from cartsnitch_api.middleware.rate_limit import (
InMemorySlidingWindow,
RedisSlidingWindow,
_get_client_ip,
_get_rate_limit_key,
)
class TestInMemorySlidingWindow:
def test_allows_within_limit(self):
limiter = InMemorySlidingWindow(max_requests=5, window_seconds=60)
for i in range(5):
allowed, remaining, retry = limiter.is_allowed("test-key")
assert allowed is True
assert remaining == 4 - i
def test_blocks_over_limit(self):
limiter = InMemorySlidingWindow(max_requests=3, window_seconds=60)
for _ in range(3):
limiter.is_allowed("test-key")
allowed, remaining, retry = limiter.is_allowed("test-key")
assert allowed is False
assert remaining == 0
assert retry > 0
def test_separate_keys(self):
limiter = InMemorySlidingWindow(max_requests=2, window_seconds=60)
limiter.is_allowed("key-a")
limiter.is_allowed("key-a")
allowed_a, _, _ = limiter.is_allowed("key-a")
assert allowed_a is False
allowed_b, remaining, _ = limiter.is_allowed("key-b")
assert allowed_b is True
assert remaining == 1
def test_resets_after_window_expires(self):
limiter = InMemorySlidingWindow(max_requests=2, window_seconds=1)
for _ in range(2):
limiter.is_allowed("test-key")
allowed, remaining, _ = limiter.is_allowed("test-key")
assert allowed is False
time.sleep(1.1)
allowed, remaining, _ = limiter.is_allowed("test-key")
assert allowed is True
assert remaining == 1
class TestGetClientIp:
def test_x_forwarded_for_single(self):
req = MagicMock()
req.headers = {"x-forwarded-for": "192.168.1.1"}
req.client = None
assert _get_client_ip(req) == "192.168.1.1"
def test_x_forwarded_for_multiple(self):
req = MagicMock()
req.headers = {"x-forwarded-for": "192.168.1.1, 10.0.0.1, 172.16.0.1"}
req.client = None
assert _get_client_ip(req) == "192.168.1.1"
def test_x_forwarded_for_with_port(self):
req = MagicMock()
req.headers = {"x-forwarded-for": "192.168.1.1:8080"}
req.client = None
assert _get_client_ip(req) == "192.168.1.1"
def test_no_forwarded_header(self):
req = MagicMock()
req.headers = {}
req.client.host = "127.0.0.1"
assert _get_client_ip(req) == "127.0.0.1"
def test_no_client(self):
req = MagicMock()
req.headers = {}
req.client = None
assert _get_client_ip(req) == "unknown"
class TestGetRateLimitKey:
def _make_request(
self,
path: str = "/purchases",
method: str = "GET",
auth_header: str = "",
headers: dict | None = None,
) -> MagicMock:
req = MagicMock()
req.url.path = path
req.method = method
req.headers = dict(headers) if headers else {}
if auth_header:
req.headers["authorization"] = auth_header
return req
def test_public_path_uses_public_limiter(self):
req = self._make_request("/public/inflation")
key, limiter = _get_rate_limit_key(req)
assert key.startswith("ip:")
assert limiter.max_requests == settings.rate_limit_requests
def test_auth_post_path_uses_strict_limiter(self):
req = self._make_request("/auth/login", method="POST")
key, limiter = _get_rate_limit_key(req)
assert key.startswith("ip:")
assert limiter.max_requests == settings.rate_limit_auth_requests
assert limiter.window_seconds == settings.rate_limit_auth_window_seconds
def test_auth_get_path_uses_auth_limiter(self):
req = self._make_request("/auth/me", method="GET")
key, limiter = _get_rate_limit_key(req)
assert key.startswith("ip:")
assert limiter.max_requests == settings.rate_limit_requests * 5
def test_authenticated_token_uses_auth_limiter(self):
req = self._make_request("/purchases", auth_header="Bearer token123")
key, limiter = _get_rate_limit_key(req)
assert key.startswith("token:")
assert limiter.max_requests == settings.rate_limit_requests * 5
def test_distinct_tokens_produce_distinct_keys(self):
req1 = self._make_request("/purchases", auth_header="Bearer token_alpha_12345")
req2 = self._make_request("/purchases", auth_header="Bearer token_beta_67890")
key1, _ = _get_rate_limit_key(req1)
key2, _ = _get_rate_limit_key(req2)
assert key1 != key2
def test_same_token_produces_same_key(self):
req1 = self._make_request("/purchases", auth_header="Bearer same_token_value_abc")
req2 = self._make_request("/purchases", auth_header="Bearer same_token_value_abc")
key1, _ = _get_rate_limit_key(req1)
key2, _ = _get_rate_limit_key(req2)
assert key1 == key2
def test_key_does_not_contain_raw_token_suffix(self):
raw_token = "my_secret_jwt_token_xyz"
req = self._make_request("/purchases", auth_header=f"Bearer {raw_token}")
key, _ = _get_rate_limit_key(req)
assert raw_token[-16:] not in key
assert raw_token not in key
class TestRedisSlidingWindowFallback:
@pytest.mark.asyncio
async def test_fallback_on_redis_connection_error(self):
mock_redis = AsyncMock()
mock_redis.pipeline.return_value = AsyncMock()
pipe_mock = AsyncMock()
pipe_mock.execute.side_effect = Exception("Connection refused")
mock_redis.pipeline.return_value = pipe_mock
limiter = RedisSlidingWindow(mock_redis, max_requests=5, window_seconds=60)
allowed, remaining, retry = await limiter.is_allowed("test-key")
assert allowed is True
assert remaining == 4
@pytest.mark.asyncio
async def test_fallback_on_redis_error_during_pipeline(self):
mock_redis = AsyncMock()
pipe_mock = AsyncMock()
pipe_mock.execute.side_effect = Exception("Redis error")
mock_redis.pipeline.return_value = pipe_mock
limiter = RedisSlidingWindow(mock_redis, max_requests=3, window_seconds=60)
allowed, remaining, retry = await limiter.is_allowed("test-key")
assert allowed is True
@pytest.mark.asyncio
async def test_rate_limit_returns_429(client):
resp = await client.get("/public/inflation")
assert "x-ratelimit-limit" in resp.headers
assert "x-ratelimit-remaining" in resp.headers
@pytest.mark.asyncio
async def test_health_skips_rate_limit(client):
resp = await client.get("/health")
assert resp.status_code == 200
assert "x-ratelimit-limit" not in resp.headers
-376
View File
@@ -1,376 +0,0 @@
"""Tests for SQLAlchemy ORM models."""
import uuid
from datetime import UTC, date, datetime
from decimal import Decimal
import pytest
from sqlalchemy import inspect
from cartsnitch_api.constants import (
AccountStatus,
DiscountType,
PriceSource,
ProductCategory,
SizeUnit,
StoreSlug,
)
from cartsnitch_api.models import (
Coupon,
NormalizedProduct,
PriceHistory,
Purchase,
PurchaseItem,
ShrinkflationEvent,
Store,
StoreLocation,
User,
UserStoreAccount,
)
class TestTableCreation:
"""Verify all expected tables are created."""
def test_all_tables_exist(self, engine):
inspector = inspect(engine)
table_names = set(inspector.get_table_names())
expected = {
"stores",
"store_locations",
"users",
"user_store_accounts",
"purchases",
"purchase_items",
"normalized_products",
"price_history",
"coupons",
"shrinkflation_events",
}
assert expected.issubset(table_names)
def test_ten_tables_total(self, engine):
inspector = inspect(engine)
assert len(inspector.get_table_names()) == 10
class TestUUIDPrimaryKeys:
"""All models use UUID PKs."""
def test_store_uuid_pk(self, session):
store = Store(
id=uuid.uuid4(),
name="Meijer",
slug=StoreSlug.MEIJER,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(store)
session.commit()
assert isinstance(store.id, uuid.UUID)
def test_user_uuid_pk(self, session):
user = User(
id=uuid.uuid4(),
email="test@example.com",
hashed_password="hashed",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(user)
session.commit()
assert isinstance(user.id, uuid.UUID)
class TestStoreModel:
def test_store_slug_enum(self, session):
store = Store(
id=uuid.uuid4(),
name="Kroger",
slug=StoreSlug.KROGER,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(store)
session.commit()
assert store.slug == StoreSlug.KROGER
def test_store_unique_slug(self, session):
s1 = Store(
id=uuid.uuid4(),
name="Target",
slug=StoreSlug.TARGET,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
s2 = Store(
id=uuid.uuid4(),
name="Target Duplicate",
slug=StoreSlug.TARGET,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(s1)
session.commit()
session.add(s2)
with pytest.raises(Exception): # noqa: B017
session.commit()
session.rollback()
class TestStoreLocationModel:
def test_store_location_fields(self, session):
store = Store(
id=uuid.uuid4(),
name="Meijer",
slug=StoreSlug.MEIJER,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(store)
session.flush()
loc = StoreLocation(
id=uuid.uuid4(),
store_id=store.id,
address="123 Main St",
city="Ann Arbor",
state="MI",
zip="48104",
lat=42.2808,
lng=-83.7430,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(loc)
session.commit()
assert loc.city == "Ann Arbor"
assert loc.lat == pytest.approx(42.2808)
class TestUserStoreAccountModel:
def test_account_status_enum(self, session):
user = User(
id=uuid.uuid4(),
email="test@test.com",
hashed_password="hashed",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
store = Store(
id=uuid.uuid4(),
name="Kroger",
slug=StoreSlug.KROGER,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add_all([user, store])
session.flush()
acct = UserStoreAccount(
id=uuid.uuid4(),
user_id=user.id,
store_id=store.id,
status=AccountStatus.ACTIVE,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(acct)
session.commit()
assert acct.status == AccountStatus.ACTIVE
def test_unique_user_store_constraint(self, session):
"""One account per user per store."""
user = User(
id=uuid.uuid4(),
email="unique@test.com",
hashed_password="hashed",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
store = Store(
id=uuid.uuid4(),
name="Target",
slug=StoreSlug.TARGET,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add_all([user, store])
session.flush()
a1 = UserStoreAccount(
id=uuid.uuid4(),
user_id=user.id,
store_id=store.id,
status=AccountStatus.ACTIVE,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
a2 = UserStoreAccount(
id=uuid.uuid4(),
user_id=user.id,
store_id=store.id,
status=AccountStatus.EXPIRED,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(a1)
session.commit()
session.add(a2)
with pytest.raises(Exception): # noqa: B017
session.commit()
session.rollback()
class TestPurchaseModel:
def test_purchase_with_items(self, session):
user = User(
id=uuid.uuid4(),
email="buyer@test.com",
hashed_password="hashed",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
store = Store(
id=uuid.uuid4(),
name="Meijer",
slug=StoreSlug.MEIJER,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add_all([user, store])
session.flush()
purchase = Purchase(
id=uuid.uuid4(),
user_id=user.id,
store_id=store.id,
receipt_id="RCP-001",
purchase_date=date(2026, 3, 15),
total=Decimal("42.50"),
ingested_at=datetime.now(UTC),
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(purchase)
session.flush()
item = PurchaseItem(
id=uuid.uuid4(),
purchase_id=purchase.id,
product_name_raw="Meijer Whole Milk 1 Gallon",
upc="0041250000001",
quantity=Decimal("1"),
unit_price=Decimal("3.49"),
extended_price=Decimal("3.49"),
)
session.add(item)
session.commit()
assert item.product_name_raw == "Meijer Whole Milk 1 Gallon"
assert item.unit_price == Decimal("3.49")
class TestNormalizedProductModel:
def test_product_with_upc_variants(self, session):
product = NormalizedProduct(
id=uuid.uuid4(),
canonical_name="Whole Milk, 1 Gallon",
category=ProductCategory.DAIRY,
brand="Store Brand",
size="128",
size_unit=SizeUnit.FL_OZ,
upc_variants=["0041250000001", "0041250000002"],
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(product)
session.commit()
assert product.category == ProductCategory.DAIRY
assert product.size_unit == SizeUnit.FL_OZ
class TestPriceHistoryModel:
def test_price_source_enum(self, session):
store = Store(
id=uuid.uuid4(),
name="Kroger",
slug=StoreSlug.KROGER,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
product = NormalizedProduct(
id=uuid.uuid4(),
canonical_name="Eggs, Large, 12ct",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add_all([store, product])
session.flush()
ph = PriceHistory(
id=uuid.uuid4(),
normalized_product_id=product.id,
store_id=store.id,
observed_date=date(2026, 3, 15),
regular_price=Decimal("4.99"),
sale_price=Decimal("3.99"),
source=PriceSource.RECEIPT,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(ph)
session.commit()
assert ph.source == PriceSource.RECEIPT
assert ph.regular_price == Decimal("4.99")
class TestCouponModel:
def test_coupon_discount_types(self, session):
store = Store(
id=uuid.uuid4(),
name="Target",
slug=StoreSlug.TARGET,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(store)
session.flush()
coupon = Coupon(
id=uuid.uuid4(),
store_id=store.id,
title="$2 off eggs",
discount_type=DiscountType.FIXED,
discount_value=Decimal("2.00"),
requires_clip=True,
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(coupon)
session.commit()
assert coupon.discount_type == DiscountType.FIXED
assert coupon.discount_value == Decimal("2.00")
class TestShrinkflationEventModel:
def test_shrinkflation_event(self, session):
product = NormalizedProduct(
id=uuid.uuid4(),
canonical_name="Cereal, Honey Oats",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(product)
session.flush()
event = ShrinkflationEvent(
id=uuid.uuid4(),
normalized_product_id=product.id,
detected_date=date(2026, 3, 10),
old_size="18",
new_size="15.4",
old_unit=SizeUnit.OZ,
new_unit=SizeUnit.OZ,
price_at_old_size=Decimal("4.99"),
price_at_new_size=Decimal("4.99"),
confidence=Decimal("0.95"),
notes="Size reduced by 14.4%, price unchanged",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(event)
session.commit()
assert event.confidence == Decimal("0.95")
assert event.old_unit == SizeUnit.OZ
-93
View File
@@ -1,93 +0,0 @@
"""Verify all expected routes are present in the OpenAPI spec."""
import pytest
from httpx import ASGITransport, AsyncClient
from cartsnitch_api.main import app
EXPECTED_ROUTES = [
# Auth (7)
("post", "/auth/register"),
("post", "/auth/login"),
("post", "/auth/refresh"),
("get", "/auth/me"),
("patch", "/auth/me"),
("delete", "/auth/me"),
("get", "/auth/me/email-in-address"),
# Stores (4)
("get", "/stores"),
("get", "/me/stores"),
("post", "/me/stores/{store_slug}/connect"),
("delete", "/me/stores/{store_slug}"),
# Purchases (3)
("get", "/purchases"),
("get", "/purchases/stats"),
("get", "/purchases/{purchase_id}"),
# Products (3)
("get", "/products"),
("get", "/products/{product_id}"),
("get", "/products/{product_id}/prices"),
# Prices (3)
("get", "/prices/trends"),
("get", "/prices/increases"),
("get", "/prices/comparison"),
# Coupons (2)
("get", "/coupons"),
("get", "/coupons/relevant"),
# Shopping (2)
("post", "/shopping/optimize"),
("get", "/shopping/lists"),
# Alerts (3)
("get", "/alerts"),
("get", "/alerts/settings"),
("put", "/alerts/settings"),
# Scraping (2)
("post", "/scraping/{store_slug}/sync"),
("get", "/scraping/status"),
# Public (3)
("get", "/public/trends/{product_id}"),
("get", "/public/store-comparison"),
("get", "/public/inflation"),
# Health (1)
("get", "/health"),
]
@pytest.mark.asyncio
async def test_all_routes_in_openapi():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/openapi.json")
assert resp.status_code == 200
spec = resp.json()
paths = spec["paths"]
registered = set()
for path, methods in paths.items():
for method in methods:
if method in ("get", "post", "put", "delete", "patch"):
registered.add((method, path))
missing = []
for method, path in EXPECTED_ROUTES:
if (method, path) not in registered:
missing.append(f"{method.upper()} {path}")
assert not missing, "Missing routes in OpenAPI spec:\n" + "\n".join(missing)
@pytest.mark.asyncio
async def test_route_count():
transport = ASGITransport(app=app)
async with AsyncClient(transport=transport, base_url="http://test") as client:
resp = await client.get("/openapi.json")
spec = resp.json()
paths = spec["paths"]
count = 0
for _path, methods in paths.items():
for method in methods:
if method in ("get", "post", "put", "delete", "patch"):
count += 1
assert count == 34, f"Expected 34 routes, found {count}"
-35
View File
@@ -1,35 +0,0 @@
"""Integration tests for alert endpoints."""
import pytest
@pytest.mark.asyncio
async def test_list_alerts_empty(client, auth_headers):
"""No purchases means no alerts."""
resp = await client.get("/alerts", headers=auth_headers)
assert resp.status_code == 200
assert resp.json() == []
@pytest.mark.asyncio
async def test_get_alert_settings(client, auth_headers):
resp = await client.get("/alerts/settings", headers=auth_headers)
assert resp.status_code == 200
data = resp.json()
assert data["price_increase_threshold_pct"] == 5.0
assert data["shrinkflation_enabled"] is True
assert data["email_notifications"] is False
@pytest.mark.asyncio
async def test_update_alert_settings_returns_501(client, auth_headers):
resp = await client.put(
"/alerts/settings",
headers=auth_headers,
json={
"price_increase_threshold_pct": 10.0,
"shrinkflation_enabled": False,
"email_notifications": True,
},
)
assert resp.status_code == 501
-58
View File
@@ -1,58 +0,0 @@
"""Integration tests for coupon endpoints."""
from datetime import date
from decimal import Decimal
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from cartsnitch_api.models import Coupon, Store
@pytest.fixture
async def coupon_data(db_engine, auth_headers):
"""Seed stores and coupons."""
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
store = Store(name="Target", slug="target")
session.add(store)
await session.commit()
await session.refresh(store)
coupon = Coupon(
store_id=store.id,
title="$2 off laundry",
description="$2 off any laundry detergent",
discount_value=Decimal("2.00"),
discount_type="fixed",
valid_from=date(2026, 1, 1),
valid_to=date(2026, 12, 31),
)
session.add(coupon)
await session.commit()
return {"store": store, "coupon": coupon, "headers": auth_headers}
@pytest.mark.asyncio
async def test_list_coupons(client, coupon_data):
resp = await client.get("/coupons", headers=coupon_data["headers"])
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 1
@pytest.mark.asyncio
async def test_list_coupons_by_store(client, coupon_data):
store_id = str(coupon_data["store"].id)
resp = await client.get(f"/coupons?store_id={store_id}", headers=coupon_data["headers"])
assert resp.status_code == 200
assert len(resp.json()) >= 1
@pytest.mark.asyncio
async def test_relevant_coupons_empty(client, auth_headers):
"""No purchases means no relevant coupons."""
resp = await client.get("/coupons/relevant", headers=auth_headers)
assert resp.status_code == 200
assert resp.json() == []
-77
View File
@@ -1,77 +0,0 @@
"""Tests for health check endpoint."""
import pytest
from unittest.mock import AsyncMock, patch
from cartsnitch_api.database import init_db, close_db
@pytest.mark.asyncio
async def test_health_returns_db_and_redis_fields(client):
"""Test that health endpoint returns db and redis status fields."""
from cartsnitch_api.cache import init_redis, close_redis
await init_db()
await init_redis()
try:
response = await client.get("/health")
assert response.status_code == 200
data = response.json()
assert "status" in data
assert "db" in data
assert "redis" in data
finally:
await close_redis()
await close_db()
@pytest.mark.asyncio
async def test_health_returns_degraded_when_db_down():
"""Test that health returns degraded when database is down."""
from cartsnitch_api.database import _engine
from cartsnitch_api.routes.health import health
# Simulate engine is None (DB not initialized)
with patch("cartsnitch_api.routes.health.get_engine", return_value=None):
response = await health()
assert response["status"] == "degraded"
assert response["db"] is False
@pytest.mark.asyncio
async def test_health_returns_ok_when_db_up(client):
"""Test that health returns ok when database is up."""
from cartsnitch_api.database import init_db, close_db
from cartsnitch_api.cache import init_redis, close_redis
await init_db()
await init_redis()
try:
response = await client.get("/health")
assert response.status_code == 200
data = response.json()
if data["db"]:
assert data["status"] == "ok"
finally:
await close_redis()
await close_db()
@pytest.mark.asyncio
async def test_health_redis_down_does_not_make_unhealthy(client):
"""Test that Redis being down does not make health return unhealthy."""
from cartsnitch_api.database import init_db, close_db
await init_db()
try:
response = await client.get("/health")
data = response.json()
# Redis being down should not make status "degraded"
# Only DB failure makes it degraded
if not data["db"]:
assert data["status"] == "degraded"
finally:
await close_db()
-90
View File
@@ -1,90 +0,0 @@
"""Integration tests for price endpoints."""
from datetime import date
from decimal import Decimal
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from cartsnitch_api.models import NormalizedProduct, PriceHistory, Store
@pytest.fixture
async def price_data(db_engine, auth_headers):
"""Seed products with price history showing an increase."""
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
store = Store(name="Walmart", slug="walmart")
product = NormalizedProduct(
canonical_name="Tide Pods 42ct",
category="household",
brand="Tide",
)
session.add_all([store, product])
await session.commit()
await session.refresh(store)
await session.refresh(product)
# Two price points — second is higher (increase)
ph1 = PriceHistory(
normalized_product_id=product.id,
store_id=store.id,
observed_date=date(2026, 2, 1),
regular_price=Decimal("12.99"),
source="receipt",
)
ph2 = PriceHistory(
normalized_product_id=product.id,
store_id=store.id,
observed_date=date(2026, 3, 1),
regular_price=Decimal("14.49"),
source="receipt",
)
session.add_all([ph1, ph2])
await session.commit()
return {"product": product, "store": store, "headers": auth_headers}
@pytest.mark.asyncio
async def test_price_trends(client, price_data):
resp = await client.get("/prices/trends", headers=price_data["headers"])
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 1
assert data[0]["product_name"] == "Tide Pods 42ct"
assert len(data[0]["data_points"]) == 2
@pytest.mark.asyncio
async def test_price_trends_by_category(client, price_data):
resp = await client.get("/prices/trends?category=household", headers=price_data["headers"])
assert resp.status_code == 200
assert len(resp.json()) == 1
resp = await client.get("/prices/trends?category=nonexistent", headers=price_data["headers"])
assert resp.status_code == 200
assert len(resp.json()) == 0
@pytest.mark.asyncio
async def test_price_increases(client, price_data):
resp = await client.get("/prices/increases", headers=price_data["headers"])
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 1
increase = data[0]
assert increase["old_price"] == 12.99
assert increase["new_price"] == 14.49
assert increase["increase_pct"] > 0
@pytest.mark.asyncio
async def test_price_comparison(client, price_data):
pid = str(price_data["product"].id)
resp = await client.get(f"/prices/comparison?product_ids={pid}", headers=price_data["headers"])
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 1
assert data[0]["product_name"] == "Tide Pods 42ct"
assert len(data[0]["prices"]) >= 1
-94
View File
@@ -1,94 +0,0 @@
"""Integration tests for product endpoints."""
import uuid
from datetime import date
from decimal import Decimal
import pytest
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from cartsnitch_api.models import NormalizedProduct, PriceHistory, Store
@pytest.fixture
async def product_data(db_engine, auth_headers):
"""Seed products and price history."""
factory = async_sessionmaker(db_engine, class_=AsyncSession, expire_on_commit=False)
async with factory() as session:
store = Store(name="Meijer", slug="meijer")
product = NormalizedProduct(
canonical_name="Cheerios 18oz",
category="pantry",
brand="General Mills",
upc_variants=["016000275263"],
)
session.add_all([store, product])
await session.commit()
await session.refresh(store)
await session.refresh(product)
ph1 = PriceHistory(
normalized_product_id=product.id,
store_id=store.id,
observed_date=date(2026, 3, 1),
regular_price=Decimal("4.99"),
source="receipt",
)
ph2 = PriceHistory(
normalized_product_id=product.id,
store_id=store.id,
observed_date=date(2026, 3, 10),
regular_price=Decimal("5.49"),
source="receipt",
)
session.add_all([ph1, ph2])
await session.commit()
return {"product": product, "store": store, "headers": auth_headers}
@pytest.mark.asyncio
async def test_list_products(client, product_data):
resp = await client.get("/products", headers=product_data["headers"])
assert resp.status_code == 200
data = resp.json()
assert len(data) >= 1
assert data[0]["name"] == "Cheerios 18oz"
@pytest.mark.asyncio
async def test_search_products(client, product_data):
resp = await client.get("/products?q=Cheerios", headers=product_data["headers"])
assert resp.status_code == 200
assert len(resp.json()) == 1
resp = await client.get("/products?q=nonexistent", headers=product_data["headers"])
assert resp.status_code == 200
assert len(resp.json()) == 0
@pytest.mark.asyncio
async def test_get_product_detail(client, product_data):
pid = str(product_data["product"].id)
resp = await client.get(f"/products/{pid}", headers=product_data["headers"])
assert resp.status_code == 200
data = resp.json()
assert data["name"] == "Cheerios 18oz"
assert data["brand"] == "General Mills"
assert len(data["prices_by_store"]) >= 1
@pytest.mark.asyncio
async def test_get_product_not_found(client, auth_headers):
resp = await client.get(f"/products/{uuid.uuid4()}", headers=auth_headers)
assert resp.status_code == 404
@pytest.mark.asyncio
async def test_get_product_prices(client, product_data):
pid = str(product_data["product"].id)
resp = await client.get(f"/products/{pid}/prices", headers=product_data["headers"])
assert resp.status_code == 200
data = resp.json()
assert data["product_name"] == "Cheerios 18oz"
assert len(data["data_points"]) == 2

Some files were not shown because too many files have changed in this diff Show More