Compare commits

..

1 Commits

Author SHA1 Message Date
Barcode Betty cd28d1535a fix(api): include alembic.ini and alembic/ in API Docker image 2026-03-30 20:48:18 +00:00
130 changed files with 1858 additions and 7627 deletions
+39 -384
View File
@@ -2,9 +2,9 @@ name: CI
on:
push:
branches: [main, dev, uat]
branches: [main]
pull_request:
branches: [main, dev, uat]
branches: [main]
concurrency:
group: ci-${{ github.ref }}
@@ -13,13 +13,11 @@ concurrency:
permissions:
contents: write
packages: write
security-events: write
env:
REGISTRY: git.farh.net
REGISTRY: ghcr.io
IMAGE_NAME: cartsnitch/cartsnitch
RECEIPTWITNESS_IMAGE_NAME: cartsnitch/receiptwitness
API_IMAGE_NAME: cartsnitch/api
AUTH_IMAGE_NAME: cartsnitch/auth
jobs:
lint:
@@ -48,62 +46,11 @@ jobs:
- name: Run tests
run: npx vitest run
audit:
runs-on: runners-cartsnitch
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- name: Check for vulnerabilities
run: npm audit --audit-level=high
e2e:
runs-on: runners-cartsnitch
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npx playwright test
lighthouse:
runs-on: runners-cartsnitch
needs: [test]
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
cache: npm
- run: npm ci
- run: npm run build
- name: Install Chromium for Lighthouse
run: |
npm install -g playwright
npx playwright install --with-deps chromium
- name: Start preview server
run: |
npm run preview &
npx wait-on http://localhost:4173/ --timeout 30000
- name: Run Lighthouse CI
run: |
CHROME_PATH=$(find /home/runner/.cache/ms-playwright -name chrome -type f 2>/dev/null | head -1)
npm install -g @lhci/cli
CHROME_PATH="$CHROME_PATH" lhci autorun --chrome-flags="--headless=new --no-sandbox --disable-gpu --disable-dev-shm-usage"
build-and-push:
runs-on: runners-cartsnitch
if: github.event_name == 'push'
needs: [lint, test, e2e]
needs: [lint, test]
outputs:
calver_tag: ${{ steps.calver.outputs.version }}
sha_tag: sha-${{ github.sha }}
steps:
- uses: actions/checkout@v4
with:
@@ -126,20 +73,13 @@ jobs:
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "CalVer tag: $VERSION"
- name: Log in to Docker Hub
if: github.event_name == 'push'
- name: Log in to GHCR
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to Gitea Container Registry
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: git.farh.net
username: cartsnitch
password: ${{ secrets.GITEA_TOKEN }}
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
@@ -147,63 +87,32 @@ jobs:
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-,format=long
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 Docker image
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
load: true
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
target: prod
cache-from: type=gha
cache-to: type=gha,mode=max
- name: 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-receiptwitness:
build-and-push-auth:
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:
@@ -215,176 +124,47 @@ jobs:
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
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 Gitea Container Registry
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: git.farh.net
username: cartsnitch
password: ${{ secrets.GITEA_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
- name: Log in to GHCR
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 }}
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Log in to Gitea Container Registry
if: github.event_name == 'push'
uses: docker/login-action@v3
with:
registry: git.farh.net
username: cartsnitch
password: ${{ secrets.GITEA_TOKEN }}
- name: Extract metadata (API)
- name: Extract metadata (auth)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }}
images: ${{ env.REGISTRY }}/${{ env.AUTH_IMAGE_NAME }}
tags: |
type=sha,prefix=sha-,format=long
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 Docker image
- name: Build and push auth Docker image
uses: docker/build-push-action@v6
with:
context: ./api
file: ./api/Dockerfile
load: true
context: ./auth
file: ./auth/Dockerfile
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
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-receiptwitness, build-and-push-api]
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
needs: [build-and-push, build-and-push-auth]
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
steps:
- name: Generate GitHub App token
id: app-token
@@ -409,50 +189,11 @@ jobs:
- 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'
- name: Update dev overlay image tag
run: |
cd infra/apps/overlays/dev
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ steps.frontend_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 }}
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }}
kustomize edit set image ghcr.io/cartsnitch/auth:${{ needs.build-and-push-auth.outputs.calver_tag }}
- name: Commit and push to infra
run: |
@@ -460,91 +201,5 @@ jobs:
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, 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-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 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, receiptwitness, and api images"
git pull --rebase origin main
git commit -m "ci(dev): update cartsnitch and auth images to ${{ needs.build-and-push.outputs.calver_tag }}"
git push origin main
-108
View File
@@ -1,108 +0,0 @@
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
+1 -4
View File
@@ -1,6 +1,6 @@
# Stage 1: Build
FROM node:20-alpine AS build
RUN apk update && apk upgrade --no-cache
WORKDIR /app
COPY package.json package-lock.json ./
@@ -11,9 +11,6 @@ RUN npm run build
# 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
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
-314
View File
@@ -1,315 +1 @@
# CartSnitch
**Grocery price intelligence — know what you're paying, every time.**
CartSnitch is a self-hosted grocery price intelligence platform that connects to your store loyalty accounts, tracks prices across retailers, monitors shrinkflation, and helps you find the best deals.
---
## Project Overview
CartSnitch solves the problem of **grocery price opacity**. Most shoppers don't know if they're getting a good deal, whether prices have spiked since their last visit, or if the "sale" is actually a worse price than a competitor. CartSnitch makes prices transparent.
**Core features:**
- Connect Meijer, Kroger, Target loyalty accounts
- View purchase history across all stores in one timeline
- Track per-item price charts across stores over time
- Receive shrinkflation and price increase alerts
- Browse active coupons and deals
- Generate optimized shopping lists with store-split plans
- Public price transparency dashboards
---
## Architecture
CartSnitch is a polyglot microservices platform. The monorepo contains the frontend PWA and core services.
```
┌─────────────────────────────────────────────────────────────────┐
│ CartSnitch PWA │
│ (React, mobile-first PWA) │
└──────────┬────────────────────┬────────────────────┬───────────┘
│ │ │
▼ ▼ ▼
┌──────────────────┐ ┌─────────────────┐ ┌─────────────────────┐
│ Auth Service │ │ API Gateway │ │ ReceiptWitness │
│ (Better-Auth) │ │ (Python/FastAPI)│ │ (Python/Scrapers) │
│ Session mgmt │ │ REST + proxy │ │ Purchase ingestion │
└────────┬─────────┘ └────────┬────────┘ └──────────┬──────────┘
│ │ │
└──────────────────────┼────────────────────────┘
┌────────────────────────┐
│ CloudNativePG (PGSQL) │
│ Shared database │
└────────────────────────┘
```
### Services in This Repo
| Directory | Service | Description |
|-----------|---------|-------------|
| `/` (root) | Frontend | React PWA, mobile-first |
| `auth/` | Auth | Better-Auth service — session management, email/password, OAuth |
| `api/` | API Gateway | Frontend-facing REST API, Python/FastAPI |
| `common/` | Common | Shared Python models, Pydantic schemas, Alembic migrations |
| `receiptwitness/` | ReceiptWitness | Purchase data ingestion via retailer scrapers |
### Other CartSnitch Repos
| Repo | Service |
|------|---------|
| `cartsnitch/stickershock` | Price increase detection & CPI comparison |
| `cartsnitch/shrinkray` | Shrinkflation monitoring |
| `cartsnitch/clipartist` | Coupon/deal watching |
| `cartsnitch/infra` | Kubernetes manifests, Flux kustomizations |
---
## Tech Stack
### Frontend
- **React 18+** with TypeScript
- **Vite** — build tool
- **Tailwind CSS v4** — mobile-first responsive design
- **Workbox** — service worker, offline caching, PWA manifest
- **Recharts** — price trend visualizations
- **TanStack Query** — data fetching and caching
- **React Router v7** — client-side routing
- **Zustand** — lightweight state management
### Backend Services
- **Better-Auth** — authentication (session management, email/password, OAuth)
- **Node.js** (API Gateway)
- **Python/FastAPI** (API Gateway, ReceiptWitness)
- **PostgreSQL** via CloudNativePG
- **DragonflyDB** for caching
### Infrastructure
- **Kubernetes** (k3s-compatible)
- **Flux CD** — GitOps deployment
- **GitHub Actions** — CI/CD
- **CalVer** (`YYYY.MM.DD[.N]`) — image tagging
- **Bitnami Sealed Secrets** — secret management
- **Authentik** — OIDC/OAuth2 provider
---
## Getting Started
### Prerequisites
- Node.js 20+
- npm or pnpm
- PostgreSQL (local or containerized)
- Docker (for running services locally)
### Local Development
1. **Clone the repo**
```bash
git clone https://github.com/cartsnitch/cartsnitch.git
cd cartsnitch
```
2. **Install dependencies**
```bash
npm install
```
3. **Set up environment variables**
```bash
cp .env.example .env
# Edit .env with your local settings
```
4. **Start the frontend dev server**
```bash
npm run dev
```
The PWA will be available at `http://localhost:5173`.
5. **Run tests**
```bash
npm test
```
6. **Build for production**
```bash
npm run build
```
### Running Backend Services Locally
The frontend PWA communicates with three backend services. For full local development, you'll need to run each service:
```bash
# Auth service (Better-Auth)
cd auth
npm install
npm run dev
# API Gateway (separate repo: cartsnitch/api)
# See api/README.md
# ReceiptWitness (separate repo: cartsnitch/receiptwitness)
# See receiptwitness/README.md
```
### Environment Variables
| Variable | Description | Default |
|----------|-------------|---------|
| `VITE_API_URL` | API Gateway base URL | `http://localhost:3000` |
| `VITE_AUTH_URL` | Auth service base URL | `http://localhost:3001` |
---
## Contributing
We welcome contributions. Please follow the workflow below.
### Branching Strategy
- Branch from `dev`
- Use prefix: `feature/`, `fix/`, `docs/`, `chore/`
- Examples: `feature/shopping-list-optimization`, `fix/price-chart-zoom`
### Commit Convention
We use [Conventional Commits](https://www.conventionalcommits.org/):
```
feat: add shopping list export
fix: correct price chart date formatting
docs: update API documentation
chore: update dependencies
```
### Pull Request Workflow
1. Open a PR against `dev`
2. CI must pass (lint, type check, tests, e2e)
3. QA reviews and approves
4. CTO merges to `dev`
5. Dev deploys automatically
6. CTO promotes `dev → uat`
7. UAT and security review
8. CEO merges `uat → main`
9. Production deploys automatically
**Never push directly to `main`, `dev`, or `uat`.**
### Code Standards
- ESLint for linting
- TypeScript strict mode
- Mobile-first responsive design
- Accessibility (WCAG 2.1 AA)
---
## Testing
### Unit Tests
```bash
npm test
```
### E2E Tests (Playwright)
```bash
npm run test:e2e
```
Tests run headless by default. For headed mode:
```bash
npm run test:e2e:headed
```
### Lighthouse CI
Performance audits run automatically in CI. To run locally:
```bash
npm run build
npm run preview
# In another terminal:
npx lighthouse http://localhost:4173 --output=html --output-path=./report/lighthouse.html
```
---
## CI/CD Pipeline
All branches (`main`, `dev`, `uat`) run through GitHub Actions on every push.
### Pipeline Stages
| Job | Trigger | Purpose |
|-----|---------|---------|
| `lint` | Every push | ESLint + TypeScript type check |
| `test` | Every push | Unit tests via Vitest |
| `audit` | Every push | Security vulnerability scan |
| `e2e` | Every push | Playwright end-to-end tests |
| `lighthouse` | After test | Performance budget check |
| `build-and-push` | On push to main/dev/uat | Build and push Docker images to GHCR |
| `deploy-dev` | On push to dev or main | Update `cartsnitch/infra` → auto-deploy to dev |
| `deploy-uat` | On push to uat or main | Update `cartsnitch/infra` → auto-deploy to uat |
### Image Tagging
- **Production (`main`):** CalVer tag (`YYYY.MM.DD[.N]`) + `latest`
- **Development (`dev`):** SHA tag (`sha-<short-sha>`)
### Deployment Environments
| Environment | Namespace | URL | Trigger |
|-------------|-----------|-----|---------|
| Dev | `cartsnitch-dev` | `cartsnitch.dev.farh.net` | Push to `dev` branch |
| UAT | `cartsnitch-uat` | `cartsnitch.uat.farh.net` | Push to `uat` branch |
| Production | `cartsnitch` | `cartsnitch.farh.net` | Push to `main` branch |
---
## Deployment
### Infrastructure
The infrastructure repository ([cartsnitch/infra](https://github.com/cartsnitch/infra)) contains Kubernetes manifests and Flux Kustomize overlays.
### Flux GitOps Flow
1. CI builds and pushes a new Docker image
2. CI opens a PR to `cartsnitch/infra` updating the image tag
3. On merge, Flux reconciles the manifests and rolls out the new image
### Forcing a Rollout
To force pods to pick up a new `:latest` image:
```bash
kubectl rollout restart deployment/<name> -n <namespace>
```
### Secrets
Secrets are managed via **Bitnami Sealed Secrets**. No plain Kubernetes secrets are used.
---
## Related Projects
- [StickerShock](https://github.com/cartsnitch/stickershock) — Price increase detection
- [ShrinkRay](https://github.com/cartsnitch/shrinkray) — Shrinkflation monitoring
- [ClipArtist](https://github.com/cartsnitch/clipartist) — Coupon/deal optimization
- [Infra](https://github.com/cartsnitch/infra) — Kubernetes infrastructure
---
## License
MIT &copy; 2025 CartSnitch
+9 -11
View File
@@ -1,27 +1,25 @@
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 \
RUN apt-get update && 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/
COPY api/pyproject.toml ./
COPY api/src/ ./src/
COPY api/alembic.ini ./
COPY api/alembic/ ./alembic/
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/
COPY api/src/ ./src/
COPY api/alembic.ini ./
COPY api/alembic/ ./alembic/
USER 1000
EXPOSE 8000
@@ -29,4 +27,4 @@ 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"]
CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
+2 -14
View File
@@ -18,7 +18,7 @@ if not db_url:
"CARTSNITCH_DATABASE_URL_SYNC must be set. "
"Example: postgresql://user:pass@localhost:5432/cartsnitch"
)
config.set_main_option("sqlalchemy.url", db_url.replace("%", "%%"))
config.set_main_option("sqlalchemy.url", db_url)
target_metadata = Base.metadata
@@ -31,7 +31,6 @@ def run_migrations_offline() -> None:
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
version_table_column_width=128,
)
with context.begin_transaction():
context.run_migrations()
@@ -45,20 +44,9 @@ def run_migrations_online() -> None:
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata, version_table_column_width=128)
context.configure(connection=connection, target_metadata=target_metadata)
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():
@@ -33,21 +33,6 @@ def _is_fernet_token(value: str) -> bool:
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",
@@ -58,6 +43,7 @@ def upgrade() -> None:
postgresql_using="session_data::text",
)
conn = op.get_bind()
rows = conn.execute(
text("SELECT id, session_data FROM user_store_accounts WHERE session_data IS NOT NULL")
).fetchall()
+65 -78
View File
@@ -21,94 +21,81 @@ 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))
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"))
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"])
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"])
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"),
)
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 each user with a hashed_password, create a 'credential' account row
conn = op.get_bind()
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},
)
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"))
op.drop_table("verifications")
op.drop_table("accounts")
op.drop_index("ix_sessions_user_id", table_name="sessions")
op.drop_index("ix_sessions_token", table_name="sessions")
op.drop_table("sessions")
op.drop_column("users", "image")
op.drop_column("users", "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(),
)
+11 -19
View File
@@ -5,6 +5,8 @@ Sessions are verified by querying the shared sessions table directly.
"""
from datetime import UTC, datetime
from uuid import UUID
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import text
@@ -19,15 +21,12 @@ 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:
async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
"""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.
Returns the user_id (as UUID) if the session is valid and not expired.
"""
result = await db.execute(
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
@@ -52,14 +51,14 @@ async def _validate_session_token(token: str, db: AsyncSession) -> str:
detail="Session expired",
)
return str(user_id)
return UUID(str(user_id))
async def get_current_user(
request: Request,
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
db: AsyncSession = Depends(get_db),
) -> str:
) -> UUID:
"""Extract and validate the session token from cookie or Authorization header.
Checks in order:
@@ -68,19 +67,14 @@ async def get_current_user(
"""
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
)
# 1. Check session cookie
cookie_token = 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
token = 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
token = credentials.credentials
if not token:
raise HTTPException(
@@ -88,9 +82,7 @@ async def get_current_user(
detail="Authentication required",
)
user_id = await _validate_session_token(token, db)
request.state.user_id = user_id
return user_id
return await _validate_session_token(token, db)
async def verify_service_key(x_service_key: str = Header()) -> None:
+5 -6
View File
@@ -5,14 +5,13 @@ the Better-Auth service (auth/). This router provides user profile
endpoints that query our own user data from the shared database.
"""
from uuid import UUID
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.models import User
from cartsnitch_api.schemas import (
UpdateUserRequest,
UserResponse,
@@ -24,7 +23,7 @@ router = APIRouter(prefix="/auth", tags=["auth"])
@router.get("/me", response_model=UserResponse)
async def get_me(
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AuthService(db)
@@ -39,7 +38,7 @@ async def get_me(
@router.patch("/me", response_model=UserResponse)
async def update_me(
body: UpdateUserRequest,
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AuthService(db)
@@ -55,7 +54,7 @@ async def update_me(
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
async def delete_me(
user_id: str = Depends(get_current_user),
user_id: UUID = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
svc = AuthService(db)
+8 -90
View File
@@ -1,108 +1,26 @@
"""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.
"""Stub for Redis/DragonflyDB caching.
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()
self.url = settings.redis_url
async def get(self, key: str) -> str | None:
if not self._client:
return None
return await self._client.get(key)
# TODO: implement with redis-py async
return None
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)
# TODO: implement with redis-py async
pass
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()
# TODO: implement with redis-py async
pass
+8 -39
View File
@@ -1,25 +1,23 @@
import base64
from pydantic import AliasChoices, Field, model_validator
from pydantic import 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"),
)
database_url: str = "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
redis_url: str = "redis://localhost:6379/0"
jwt_secret_key: str
jwt_secret_key: str = "change-me-in-production"
jwt_algorithm: str = "HS256"
jwt_access_token_expire_minutes: int = 15
jwt_refresh_token_expire_days: int = 7
service_key: str
fernet_key: str
service_key: str = "change-me-in-production"
# Valid Fernet key for local dev — MUST be overridden in production
fernet_key: str = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
auth_service_url: str = "http://auth:3001"
@@ -32,31 +30,11 @@ class Settings(BaseSettings):
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())'"
)
def validate_fernet_key(self):
"""Validate fernet_key is a valid 32-byte url-safe base64 key at startup."""
try:
decoded = base64.urlsafe_b64decode(self.fernet_key.encode())
if len(decoded) != 32:
@@ -71,14 +49,5 @@ class Settings(BaseSettings):
) 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()
+3 -47
View File
@@ -1,60 +1,16 @@
"""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
engine = create_async_engine(settings.database_url, echo=False)
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def get_db() -> AsyncGenerator[AsyncSession, None]:
if async_session_factory is None:
raise RuntimeError("Database not initialized. Call init_db() first.")
"""FastAPI dependency that yields an async DB session."""
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}")
+12 -27
View File
@@ -2,15 +2,12 @@
from contextlib import asynccontextmanager
from fastapi import APIRouter, FastAPI
from fastapi import FastAPI
from cartsnitch_api.auth.routes import router as auth_router
from cartsnitch_api.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
@@ -21,19 +18,13 @@ 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()
# TODO: initialize DB session pool, Redis connection, service clients
yield
await close_redis()
await close_db()
# TODO: cleanup connections
def create_app() -> FastAPI:
@@ -48,7 +39,6 @@ def create_app() -> FastAPI:
add_cors_middleware(app)
add_error_monitor_middleware(app)
add_rate_limit_middleware(app)
add_audit_middleware(app)
# Exception handlers
add_error_handlers(app)
@@ -56,20 +46,15 @@ def create_app() -> FastAPI:
# 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)
app.include_router(stores_router)
app.include_router(purchases_router)
app.include_router(products_router)
app.include_router(prices_router)
app.include_router(coupons_router)
app.include_router(shopping_router)
app.include_router(alerts_router)
app.include_router(scraping_router)
app.include_router(public_router)
return 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)
+2 -2
View File
@@ -11,6 +11,6 @@ def add_cors_middleware(app: FastAPI) -> None:
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"],
allow_methods=["*"],
allow_headers=["*"],
)
+19 -101
View File
@@ -4,32 +4,18 @@ 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:
class _SlidingWindowCounter:
"""Thread-safe in-memory sliding window rate limiter."""
def __init__(self, max_requests: int, window_seconds: int) -> None:
@@ -38,12 +24,13 @@ class InMemorySlidingWindow:
self._hits: dict[str, list[float]] = defaultdict(list)
self._lock = Lock()
async def is_allowed(self, key: str) -> tuple[bool, int, int]:
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:
# Prune expired entries
self._hits[key] = [t for t in self._hits[key] if t > cutoff]
current_count = len(self._hits[key])
@@ -56,84 +43,15 @@ class InMemorySlidingWindow:
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
)
# Module-level counters — one for public (per-IP), one for auth (per-token)
_public_limiter = _SlidingWindowCounter(
max_requests=settings.rate_limit_requests,
window_seconds=settings.rate_limit_window_seconds,
)
_auth_limiter = _SlidingWindowCounter(
max_requests=settings.rate_limit_requests * 5, # 300/min for authenticated users
window_seconds=settings.rate_limit_window_seconds,
)
def _get_client_ip(request: Request) -> str:
@@ -144,30 +62,30 @@ def _get_client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
def _get_rate_limit_key(request: Request) -> tuple[str, RateLimitBackend]:
def _get_rate_limit_key(request: Request) -> tuple[str, _SlidingWindowCounter]:
"""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
# For authenticated endpoints, use Bearer token as key if present
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
# Use last 16 chars of token as key to avoid storing full tokens
return f"token:{token[-16:]}", _auth_limiter
# Fallback to IP for unauthenticated non-public endpoints
return f"ip:{_get_client_ip(request)}", _public_limiter
class RateLimitMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next):
# Skip rate limiting when disabled (e.g. in tests) or for health checks
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)
allowed, remaining, retry_after = limiter.is_allowed(key)
if not allowed:
return JSONResponse(
+2 -2
View File
@@ -32,8 +32,8 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "purchases"
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
store_id: Mapped[str] = mapped_column(ForeignKey("stores.id"), nullable=False)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
store_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)
+6 -21
View File
@@ -1,11 +1,10 @@
"""User and UserStoreAccount models."""
import secrets
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
import sqlalchemy as sa
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_api.constants import AccountStatus
@@ -17,28 +16,14 @@ if TYPE_CHECKING:
from cartsnitch_api.models.store import Store
class User(TimestampMixin, Base):
class User(UUIDPrimaryKeyMixin, 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)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
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")
@@ -51,8 +36,8 @@ class UserStoreAccount(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "user_store_accounts"
__table_args__ = (UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),)
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
store_id: Mapped[str] = mapped_column(ForeignKey("stores.id"), nullable=False)
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
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))
+1 -24
View File
@@ -1,11 +1,8 @@
"""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"])
@@ -13,27 +10,7 @@ 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}
return {"status": "ok"}
@router.get("/internal/error-stats", dependencies=[Depends(verify_service_key)])
+5 -14
View File
@@ -18,14 +18,10 @@ 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),
):
async def public_price_trend(product_id: UUID, db: AsyncSession = Depends(get_db)):
svc = PublicService(db)
try:
return await svc.get_trend(product_id, days=days)
return await svc.get_trend(product_id)
except LookupError:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
@@ -35,7 +31,6 @@ async def public_price_trend(
@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:
@@ -44,14 +39,10 @@ async def public_store_comparison(
detail="At least one product_id is required",
)
svc = PublicService(db)
return await svc.get_store_comparison(product_ids, category=category)
return await svc.get_store_comparison(product_ids)
@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),
):
async def public_inflation(db: AsyncSession = Depends(get_db)):
svc = PublicService(db)
return await svc.get_inflation(category=category, period=period)
return await svc.get_inflation()
-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
+1 -6
View File
@@ -16,17 +16,12 @@ class UpdateUserRequest(BaseModel):
class UserResponse(BaseModel):
id: str
id: UUID
email: str
display_name: str
created_at: datetime
class EmailInAddressResponse(BaseModel):
email_address: str
instructions: str
# ---------- Stores ----------
+5 -14
View File
@@ -5,6 +5,8 @@ handled by the Better-Auth service (auth/). This service provides
user lookup and profile update operations for the API gateway.
"""
from uuid import UUID
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -13,7 +15,7 @@ class AuthService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get_user(self, user_id: str) -> dict:
async def get_user(self, user_id: UUID) -> dict:
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.id == user_id))
@@ -28,7 +30,7 @@ class AuthService:
"created_at": user.created_at,
}
async def update_user(self, user_id: str, **fields) -> dict:
async def update_user(self, user_id: UUID, **fields) -> dict:
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.id == user_id))
@@ -56,7 +58,7 @@ class AuthService:
"created_at": user.created_at,
}
async def delete_user(self, user_id: str) -> None:
async def delete_user(self, user_id: UUID) -> None:
from cartsnitch_api.models import User
result = await self.db.execute(select(User).where(User.id == user_id))
@@ -66,14 +68,3 @@ class AuthService:
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"
+23 -42
View File
@@ -1,6 +1,5 @@
"""Public service — unauthenticated price transparency endpoints."""
from datetime import date, timedelta
from uuid import UUID
from sqlalchemy import and_, func, select
@@ -14,7 +13,7 @@ class PublicService:
def __init__(self, db: AsyncSession) -> None:
self.db = db
async def get_trend(self, product_id: UUID, days: int = 90) -> dict:
async def get_trend(self, product_id: UUID) -> dict:
from cartsnitch_api.models import NormalizedProduct, PriceHistory
result = await self.db.execute(
@@ -24,13 +23,9 @@ class PublicService:
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,
)
.where(PriceHistory.normalized_product_id == product_id)
.options(selectinload(PriceHistory.store))
.order_by(PriceHistory.observed_date)
)
@@ -50,25 +45,20 @@ class PublicService:
],
}
async def get_store_comparison(
self, product_ids: list[UUID], category: str | None = None
) -> dict:
async def get_store_comparison(self, product_ids: list[UUID]) -> 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)
# Fetch all 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()}
if not products_by_id:
return {"products": []}
filtered_product_ids = list(products_by_id.keys())
subq = latest_price_per_store(filtered_product_ids)
# 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(
@@ -79,17 +69,18 @@ class PublicService:
PriceHistory.normalized_product_id == subq.c.normalized_product_id,
),
)
.where(PriceHistory.normalized_product_id.in_(filtered_product_ids))
.where(PriceHistory.normalized_product_id.in_(product_ids))
.options(selectinload(PriceHistory.store))
)
all_prices = prices_result.scalars().all()
# Group by product
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:
for pid in product_ids:
product = products_by_id.get(pid)
if not product:
continue
@@ -111,29 +102,19 @@ class PublicService:
return {"products": products}
async def get_inflation(self, category: str | None = None, period: str = "all-time") -> dict:
async def get_inflation(self) -> 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)
# Get average prices grouped by category for recent vs older data
result = await self.db.execute(
select(
NormalizedProduct.category,
func.avg(PriceHistory.regular_price),
)
.join(NormalizedProduct)
.group_by(NormalizedProduct.category)
)
categories = {}
for row in result.all():
cat, avg_price = row
@@ -141,7 +122,7 @@ class PublicService:
categories[cat] = float(avg_price) if avg_price else 0.0
return {
"period": period,
"period": "all-time",
"cartsnitch_index": sum(categories.values()) / max(len(categories), 1),
"cpi_baseline": 100.0,
"categories": categories,
+8 -36
View File
@@ -19,25 +19,6 @@ 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:"
@@ -79,8 +60,7 @@ async def db_engine():
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("""
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
token TEXT NOT NULL UNIQUE,
@@ -91,10 +71,8 @@ async def db_engine():
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
""")
)
await conn.execute(
text("""
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS accounts (
id TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
@@ -110,10 +88,8 @@ async def db_engine():
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
""")
)
await conn.execute(
text("""
"""))
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS verifications (
id TEXT PRIMARY KEY,
identifier TEXT NOT NULL,
@@ -122,8 +98,7 @@ async def db_engine():
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
)
""")
)
"""))
yield engine
@@ -158,13 +133,10 @@ async def client(db_engine):
app.dependency_overrides.clear()
async def _create_test_user_and_session(
client: AsyncClient, db_engine, **user_overrides
) -> tuple[dict, str]:
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.
Returns (user_dict, session_token).
"""
user_id = str(uuid.uuid4())
email = user_overrides.get("email", "test@example.com")
@@ -71,56 +71,6 @@ async def test_delete_me(client, 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."""
-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()
@@ -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
+18 -154
View File
@@ -1,184 +1,47 @@
"""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,
)
from cartsnitch_api.middleware.rate_limit import _SlidingWindowCounter
class TestInMemorySlidingWindow:
class TestSlidingWindowCounter:
def test_allows_within_limit(self):
limiter = InMemorySlidingWindow(max_requests=5, window_seconds=60)
counter = _SlidingWindowCounter(max_requests=5, window_seconds=60)
for i in range(5):
allowed, remaining, retry = limiter.is_allowed("test-key")
allowed, remaining, retry = counter.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)
counter = _SlidingWindowCounter(max_requests=3, window_seconds=60)
for _ in range(3):
limiter.is_allowed("test-key")
counter.is_allowed("test-key")
allowed, remaining, retry = limiter.is_allowed("test-key")
allowed, remaining, retry = counter.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")
counter = _SlidingWindowCounter(max_requests=2, window_seconds=60)
# Fill key-a
counter.is_allowed("key-a")
counter.is_allowed("key-a")
allowed_a, _, _ = counter.is_allowed("key-a")
assert allowed_a is False
allowed_b, remaining, _ = limiter.is_allowed("key-b")
# key-b should still be allowed
allowed_b, remaining, _ = counter.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):
"""Public endpoint should return 429 after limit exceeded."""
# The default limit is 60/min — we won't hit it in normal tests,
# but we verify the middleware adds rate limit headers.
resp = await client.get("/public/inflation")
assert "x-ratelimit-limit" in resp.headers
assert "x-ratelimit-remaining" in resp.headers
@@ -186,6 +49,7 @@ async def test_rate_limit_returns_429(client):
@pytest.mark.asyncio
async def test_health_skips_rate_limit(client):
"""Health endpoint should not have rate limit headers."""
resp = await client.get("/health")
assert resp.status_code == 200
assert "x-ratelimit-limit" not in resp.headers
+2 -3
View File
@@ -6,14 +6,13 @@ from httpx import ASGITransport, AsyncClient
from cartsnitch_api.main import app
EXPECTED_ROUTES = [
# Auth (7)
# Auth (6)
("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"),
@@ -90,4 +89,4 @@ async def test_route_count():
if method in ("get", "post", "put", "delete", "patch"):
count += 1
assert count == 34, f"Expected 34 routes, found {count}"
assert count == 33, f"Expected 33 routes, found {count}"
-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()
-94
View File
@@ -71,97 +71,3 @@ async def test_public_inflation(client, public_data):
data = resp.json()
assert "categories" in data
assert "cartsnitch_index" in data
@pytest.mark.asyncio
async def test_trend_invalid_uuid(client):
resp = await client.get("/public/trends/not-a-uuid")
assert resp.status_code == 422
assert "detail" in resp.json()
assert "stack" not in resp.json()
@pytest.mark.asyncio
async def test_trend_days_zero(client, public_data):
pid = str(public_data["product"].id)
resp = await client.get(f"/public/trends/{pid}?days=0")
assert resp.status_code == 422
assert "detail" in resp.json()
assert "stack" not in resp.json()
@pytest.mark.asyncio
async def test_trend_days_negative(client, public_data):
pid = str(public_data["product"].id)
resp = await client.get(f"/public/trends/{pid}?days=-1")
assert resp.status_code == 422
assert "detail" in resp.json()
assert "stack" not in resp.json()
@pytest.mark.asyncio
async def test_trend_days_over_max(client, public_data):
pid = str(public_data["product"].id)
resp = await client.get(f"/public/trends/{pid}?days=999")
assert resp.status_code == 422
assert "detail" in resp.json()
assert "stack" not in resp.json()
@pytest.mark.asyncio
async def test_trend_days_valid(client, public_data):
pid = str(public_data["product"].id)
resp = await client.get(f"/public/trends/{pid}?days=30")
assert resp.status_code == 200
assert "product_name" in resp.json()
@pytest.mark.asyncio
async def test_store_comparison_empty_list(client):
resp = await client.get("/public/store-comparison")
assert resp.status_code == 400
assert "detail" in resp.json()
@pytest.mark.asyncio
async def test_store_comparison_category_xss(client, public_data):
pid = str(public_data["product"].id)
resp = await client.get(
f"/public/store-comparison?product_ids={pid}&category=<script>alert(1)</script>"
)
assert resp.status_code == 422
assert "detail" in resp.json()
assert "stack" not in resp.json()
@pytest.mark.asyncio
async def test_store_comparison_category_sql_injection(client, public_data):
pid = str(public_data["product"].id)
resp = await client.get(f"/public/store-comparison?product_ids={pid}&category='; DROP TABLE--")
assert resp.status_code == 422
assert "detail" in resp.json()
assert "stack" not in resp.json()
@pytest.mark.asyncio
async def test_inflation_invalid_period(client, public_data):
resp = await client.get("/public/inflation?period=10years")
assert resp.status_code == 422
assert "detail" in resp.json()
assert "stack" not in resp.json()
@pytest.mark.asyncio
async def test_inflation_valid_periods(client, public_data):
for period in ["all-time", "1y", "6m", "3m", "1m"]:
resp = await client.get(f"/public/inflation?period={period}")
assert resp.status_code == 200, f"period={period} failed"
@pytest.mark.asyncio
async def test_inflation_category_too_long(client, public_data):
long_category = "x" * 200
resp = await client.get(f"/public/inflation?category={long_category}")
assert resp.status_code == 422
assert "detail" in resp.json()
assert "stack" not in resp.json()
-4
View File
@@ -9,7 +9,3 @@ DATABASE_URL=postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch
# Port the auth service listens on
PORT=3001
# Resend email provider for transactional email
RESEND_API_KEY=re_your_api_key_here
FROM_EMAIL=CartSnitch <noreply@cartsnitch.com>
-2
View File
@@ -1,5 +1,4 @@
FROM node:22-alpine AS builder
RUN apk update && apk upgrade --no-cache
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci
@@ -8,7 +7,6 @@ COPY src/ src/
RUN npm run build
FROM node:22-alpine
RUN apk update && apk upgrade --no-cache
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json* ./
+599 -79
View File
@@ -8,13 +8,12 @@
"name": "@cartsnitch/auth",
"version": "0.1.0",
"dependencies": {
"bcrypt": "^6.0.0",
"bcrypt": "^5.1.1",
"better-auth": "^1.2.0",
"pg": "^8.13.0",
"resend": "^6.11.0"
"pg": "^8.13.0"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/bcrypt": "^5.0.2",
"@types/node": "^22.0.0",
"@types/pg": "^8.11.0",
"tsx": "^4.19.0",
@@ -591,6 +590,26 @@
"node": ">=18"
}
},
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
"integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
"license": "BSD-3-Clause",
"dependencies": {
"detect-libc": "^2.0.0",
"https-proxy-agent": "^5.0.0",
"make-dir": "^3.1.0",
"node-fetch": "^2.6.7",
"nopt": "^5.0.0",
"npmlog": "^5.0.1",
"rimraf": "^3.0.2",
"semver": "^7.3.5",
"tar": "^6.1.11"
},
"bin": {
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@noble/ciphers": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@noble/ciphers/-/ciphers-2.1.1.tgz",
@@ -634,12 +653,6 @@
"node": ">=14"
}
},
"node_modules/@stablelib/base64": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz",
"integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==",
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
@@ -647,9 +660,9 @@
"license": "MIT"
},
"node_modules/@types/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-/oJGukuH3D2+D+3H4JWLaAsJ/ji86dhRidzZ/Od7H/i8g+aCmvkeCc6Ni/f9uxGLSQVCRZkX2/lqEFG2BvWtlQ==",
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
"integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -678,18 +691,71 @@
"pg-types": "^2.2.0"
}
},
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"license": "ISC"
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/aproba": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
"integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
"license": "ISC"
},
"node_modules/are-we-there-yet": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
"integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"dependencies": {
"delegates": "^1.0.0",
"readable-stream": "^3.6.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT"
},
"node_modules/bcrypt": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
"integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"node-addon-api": "^8.3.0",
"node-gyp-build": "^4.8.4"
"@mapbox/node-pre-gyp": "^1.0.11",
"node-addon-api": "^5.0.0"
},
"engines": {
"node": ">= 18"
"node": ">= 10.0.0"
}
},
"node_modules/better-auth": {
@@ -817,12 +883,90 @@
}
}
},
"node_modules/brace-expansion": {
"version": "1.1.13",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz",
"integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==",
"license": "MIT",
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
"license": "ISC",
"engines": {
"node": ">=10"
}
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
"license": "ISC",
"bin": {
"color-support": "bin.js"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
"license": "ISC"
},
"node_modules/debug": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
},
"engines": {
"node": ">=6.0"
},
"peerDependenciesMeta": {
"supports-color": {
"optional": true
}
}
},
"node_modules/defu": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/defu/-/defu-6.1.4.tgz",
"integrity": "sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==",
"license": "MIT"
},
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.27.4",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
@@ -865,11 +1009,35 @@
"@esbuild/win32-x64": "0.27.4"
}
},
"node_modules/fast-sha256": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz",
"integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==",
"license": "Unlicense"
"node_modules/fs-minipass": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
"license": "ISC",
"dependencies": {
"minipass": "^3.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/fs-minipass/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC"
},
"node_modules/fsevents": {
"version": "2.3.3",
@@ -886,6 +1054,27 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/gauge": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
"integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"dependencies": {
"aproba": "^1.0.3 || ^2.0.0",
"color-support": "^1.1.2",
"console-control-strings": "^1.0.0",
"has-unicode": "^2.0.1",
"object-assign": "^4.1.1",
"signal-exit": "^3.0.0",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1",
"wide-align": "^1.1.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/get-tsconfig": {
"version": "4.13.7",
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
@@ -899,6 +1088,72 @@
"url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/has-unicode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
"license": "ISC"
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"engines": {
"node": ">=8"
}
},
"node_modules/jose": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/jose/-/jose-6.2.2.tgz",
@@ -917,6 +1172,94 @@
"node": ">=20.0.0"
}
},
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
"license": "MIT",
"dependencies": {
"semver": "^6.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/make-dir/node_modules/semver": {
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"license": "ISC",
"engines": {
"node": ">=8"
}
},
"node_modules/minizlib": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
"license": "MIT",
"dependencies": {
"minipass": "^3.0.0",
"yallist": "^4.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/minizlib/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"license": "ISC",
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
"license": "MIT",
"bin": {
"mkdirp": "bin/cmd.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT"
},
"node_modules/nanostores": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/nanostores/-/nanostores-1.2.0.tgz",
@@ -933,23 +1276,84 @@
}
},
"node_modules/node-addon-api": {
"version": "8.7.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.7.0.tgz",
"integrity": "sha512-9MdFxmkKaOYVTV+XVRG8ArDwwQ77XIgIPyKASB1k3JPq3M8fGQQQE3YpMOrKm6g//Ktx8ivZr8xo1Qmtqub+GA==",
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz",
"integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==",
"license": "MIT"
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "^18 || ^20 || >= 21"
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"node_modules/nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
"integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
"license": "ISC",
"dependencies": {
"abbrev": "1"
},
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
"nopt": "bin/nopt.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/npmlog": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
"integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"dependencies": {
"are-we-there-yet": "^2.0.0",
"console-control-strings": "^1.1.0",
"gauge": "^3.0.0",
"set-blocking": "^2.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"dependencies": {
"wrappy": "1"
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pg": {
@@ -1041,12 +1445,6 @@
"split2": "^4.1.0"
}
},
"node_modules/postal-mime": {
"version": "2.7.4",
"resolved": "https://registry.npmjs.org/postal-mime/-/postal-mime-2.7.4.tgz",
"integrity": "sha512-0WdnFQYUrPGGTFu1uOqD2s7omwua8xaeYGdO6rb88oD5yJ/4pPHDA4sdWqfD8wQVfCny563n/HQS7zTFft+f/g==",
"license": "MIT-0"
},
"node_modules/postgres-array": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
@@ -1086,25 +1484,18 @@
"node": ">=0.10.0"
}
},
"node_modules/resend": {
"version": "6.11.0",
"resolved": "https://registry.npmjs.org/resend/-/resend-6.11.0.tgz",
"integrity": "sha512-S9gxOccfwc+E6Cr3q28Gu8NkiIjYlYPlj9rqk4zkIuzlEoh8sWu/IvJSg7U7t+o3g0Ov2IOCzcneUaCi/M/WdQ==",
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"dependencies": {
"postal-mime": "2.7.4",
"svix": "1.90.0"
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">=20"
},
"peerDependencies": {
"@react-email/render": "*"
},
"peerDependenciesMeta": {
"@react-email/render": {
"optional": true
}
"node": ">= 6"
}
},
"node_modules/resolve-pkg-maps": {
@@ -1117,18 +1508,78 @@
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"license": "ISC",
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rou3": {
"version": "0.7.12",
"resolved": "https://registry.npmjs.org/rou3/-/rou3-0.7.12.tgz",
"integrity": "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg==",
"license": "MIT"
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.4",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz",
"integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.0.tgz",
"integrity": "sha512-kjnC1DXBHcxaOaOXBHBeRtltsDG2nUiUni+jP92M9gYdW12rsmx92UsfpH7o5tDRs7I1ZZPSQJQGv3UaRfCiuw==",
"license": "MIT"
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC"
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
@@ -1138,26 +1589,65 @@
"node": ">= 10.x"
}
},
"node_modules/standardwebhooks": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"dependencies": {
"@stablelib/base64": "^1.0.0",
"fast-sha256": "^1.3.0"
"safe-buffer": "~5.2.0"
}
},
"node_modules/svix": {
"version": "1.90.0",
"resolved": "https://registry.npmjs.org/svix/-/svix-1.90.0.tgz",
"integrity": "sha512-ljkZuyy2+IBEoESkIpn8sLM+sxJHQcPxlZFxU+nVDhltNfUMisMBzWX/UR8SjEnzoI28ZjCzMbmYAPwSTucoMw==",
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"dependencies": {
"standardwebhooks": "1.0.0",
"uuid": "^10.0.0"
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
"minipass": "^5.0.0",
"minizlib": "^2.1.1",
"mkdirp": "^1.0.3",
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/tsx": {
"version": "4.21.0",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
@@ -1199,19 +1689,43 @@
"dev": true,
"license": "MIT"
},
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wide-align": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
"integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
"license": "ISC",
"dependencies": {
"string-width": "^1.0.2 || 2 || 3 || 4"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC"
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -1221,6 +1735,12 @@
"node": ">=0.4"
}
},
"node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
"node_modules/zod": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz",
+4 -6
View File
@@ -7,20 +7,18 @@
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js",
"generate": "npx @better-auth/cli generate",
"test": "node --test src/__tests__/*.test.ts"
"generate": "npx @better-auth/cli generate"
},
"dependencies": {
"bcrypt": "^6.0.0",
"better-auth": "^1.2.0",
"pg": "^8.13.0",
"resend": "^6.11.0"
"bcrypt": "^5.1.1"
},
"devDependencies": {
"@types/bcrypt": "^6.0.0",
"@types/node": "^22.0.0",
"@types/pg": "^8.11.0",
"@types/bcrypt": "^5.0.2",
"tsx": "^4.19.0",
"typescript": "^5.7.0"
}
}
}
-117
View File
@@ -1,117 +0,0 @@
import { describe, it } from 'node:test';
import { equal } from 'node:assert';
import http from 'node:http';
describe('Auth health endpoint', () => {
const startHealthServer = (poolMock) => {
return new Promise((resolve) => {
const server = http.createServer(async (req, res) => {
if (req.url === '/health' && req.method === 'GET') {
try {
const client = await poolMock.connect();
try {
await Promise.race([
client.query('SELECT 1'),
new Promise((_, reject) => setTimeout(() => reject(new Error('DB timeout')), 2000)),
]);
} finally {
client.release();
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', db: 'reachable' }));
} catch {
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'error', db: 'unreachable' }));
}
return;
}
res.writeHead(404);
res.end();
});
server.listen(0, '0.0.0.0', () => {
const addr = server.address();
const port = typeof addr === 'object' && addr ? addr.port : 0;
resolve({ port, close: () => server.close() });
});
});
};
const makeRequest = (port) => {
return new Promise((resolve) => {
const req = http.get(`http://localhost:${port}/health`, (res) => {
let body = '';
res.on('data', (chunk) => { body += chunk; });
res.on('end', () => {
resolve({ status: res.statusCode, body });
});
});
req.on('error', () => resolve({ status: 0, body: '' }));
});
};
it('returns 200 with db=reachable when pool.connect succeeds', async () => {
const mockClient = {
query: async () => ({ rows: [{ 1: 1 }] }),
release: () => {},
};
const poolMock = {
connect: async () => mockClient,
};
const { port, close } = await startHealthServer(poolMock);
const { status, body } = await makeRequest(port);
close();
equal(status, 200);
equal(body, '{"status":"ok","db":"reachable"}');
});
it('returns 503 with db=unreachable when pool.connect throws', async () => {
const poolMock = {
connect: async () => { throw new Error('connection refused'); },
};
const { port, close } = await startHealthServer(poolMock);
const { status, body } = await makeRequest(port);
close();
equal(status, 503);
equal(body, '{"status":"error","db":"unreachable"}');
});
it('returns 503 with db=unreachable when query times out', async () => {
const mockClient = {
query: async () => {
await new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), 3000));
},
release: () => {},
};
const poolMock = {
connect: async () => mockClient,
};
const { port, close } = await startHealthServer(poolMock);
const { status, body } = await makeRequest(port);
close();
equal(status, 503);
equal(body, '{"status":"error","db":"unreachable"}');
});
it('returns a terminal response for unknown paths (no hang)', async () => {
const poolMock = { connect: async () => ({ query: async () => {}, release: () => {} }) };
const { port, close } = await startHealthServer(poolMock);
const result = await new Promise<{ status: number }>((resolve) => {
const req = http.get(`http://localhost:${port}/`, (res) => {
res.resume();
res.on('end', () => resolve({ status: res.statusCode ?? 0 }));
});
req.on('error', () => resolve({ status: 0 }));
setTimeout(() => resolve({ status: -1 }), 1000);
});
close();
equal(result.status !== -1, true, 'Unknown path must return a terminal response within 1s');
});
});
+8 -41
View File
@@ -1,30 +1,20 @@
import { betterAuth } from "better-auth";
import bcrypt from "bcrypt";
import pg from "pg";
import { Resend } from "resend";
const { Pool } = pg;
const pool = new Pool({
connectionString:
process.env.DATABASE_URL ??
"postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
});
const secret = process.env.BETTER_AUTH_SECRET;
if (!secret) {
throw new Error("BETTER_AUTH_SECRET environment variable is required");
}
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
console.warn(
"WARNING: DATABASE_URL is not set — using default localhost connection. " +
"Set DATABASE_URL for production deployments."
);
}
export const pool = new Pool({
connectionString: databaseUrl ?? "postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
});
const resend = new Resend(process.env.RESEND_API_KEY);
const fromEmail = process.env.FROM_EMAIL || "CartSnitch <noreply@cartsnitch.com>";
export const auth = betterAuth({
database: pool,
basePath: "/auth",
@@ -37,7 +27,7 @@ export const auth = betterAuth({
maxPasswordLength: 128,
password: {
hash: async (password: string) => {
return bcrypt.hash(password, 12);
return bcrypt.hash(password, 10);
},
verify: async (data: { hash: string; password: string }) => {
return bcrypt.compare(data.password, data.hash);
@@ -45,29 +35,7 @@ export const auth = betterAuth({
},
},
emailVerification: {
sendOnSignUp: true,
autoSignInAfterVerification: true,
sendVerificationEmail: async ({ user, url }) => {
await resend.emails.send({
from: fromEmail,
to: user.email,
subject: "Verify your CartSnitch email",
html: `<p>Hi ${user.name || ""},</p><p>Click the link below to verify your email address:</p><p><a href="${url}">Verify Email</a></p><p>This link expires in 1 hour.</p><p>— CartSnitch</p>`,
});
},
},
session: {
modelName: "sessions",
fields: {
userId: "user_id",
expiresAt: "expires_at",
ipAddress: "ip_address",
userAgent: "user_agent",
createdAt: "created_at",
updatedAt: "updated_at",
},
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60 * 24, // refresh after 1 day
cookieCache: {
@@ -118,6 +86,5 @@ export const auth = betterAuth({
"https://cartsnitch.com",
"https://cartsnitch.farh.net",
"https://cartsnitch.dev.farh.net",
"https://cartsnitch.uat.farh.net",
],
});
});
+5 -19
View File
@@ -1,6 +1,6 @@
import { createServer } from "node:http";
import { toNodeHandler } from "better-auth/node";
import { auth, pool } from "./auth.js";
import { auth } from "./auth.js";
const port = parseInt(process.env.PORT ?? "3001", 10);
@@ -8,27 +8,13 @@ const handler = toNodeHandler(auth);
const server = createServer(async (req, res) => {
// Health check
if ((req.url === "/health" || req.url === "/auth/health") && req.method === "GET") {
try {
const client = await pool.connect();
try {
await Promise.race([
client.query("SELECT 1"),
new Promise((_, reject) => setTimeout(() => reject(new Error("DB timeout")), 2000)),
]);
} finally {
client.release();
}
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok", db: "reachable" }));
} catch {
res.writeHead(503, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "error", db: "unreachable" }));
}
if (req.url === "/health" && req.method === "GET") {
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "ok" }));
return;
}
// All other routes handled by Better-Auth (returns 404 for unknown paths)
// All /auth/* routes handled by Better-Auth
await handler(req, res);
});
Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Submodule cartsnitch deleted from a53daddb9a
+1 -1
View File
@@ -14,7 +14,7 @@ if config.config_file_name is not None:
db_url = os.environ.get("CARTSNITCH_DATABASE_URL_SYNC")
if db_url:
config.set_main_option("sqlalchemy.url", db_url.replace("%", "%%"))
config.set_main_option("sqlalchemy.url", db_url)
target_metadata = Base.metadata
@@ -1,37 +0,0 @@
"""Add email_inbound_token to users.
Revision ID: 001_add_email_inbound_token
Revises:
Create Date: 2026-04-02
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "001_add_email_inbound_token"
down_revision: str | None = None
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column("users", sa.Column("email_inbound_token", sa.String(22), nullable=True))
op.create_unique_constraint("uq_users_email_inbound_token", "users", ["email_inbound_token"])
# Backfill existing users with generated tokens (PostgreSQL)
op.execute(
"UPDATE users SET email_inbound_token = "
"substring(replace(gen_random_uuid()::text, '-', ''), 1, 22) "
"WHERE email_inbound_token IS NULL"
)
# Alter to non-nullable
op.alter_column("users", "email_inbound_token", nullable=False)
def downgrade() -> None:
op.drop_constraint("uq_users_email_inbound_token", "users", type_="unique")
op.drop_column("users", "email_inbound_token")
@@ -1,28 +0,0 @@
"""Add GIN index on normalized_products.upc_variants for fast JSON containment lookups.
Revision ID: 002_add_normalized_products_upc_variants_index
Revises: 001_add_email_inbound_token
Create Date: 2026-04-14
"""
from collections.abc import Sequence
from alembic import op
revision: str = "002_add_normalized_products_upc_variants_index"
down_revision: str | None = "001_add_email_inbound_token"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.create_index(
"ix_normalized_products_upc_variants",
"normalized_products",
["upc_variants"],
postgresql_using="gin",
)
def downgrade() -> None:
op.drop_index("ix_normalized_products_upc_variants", table_name="normalized_products")
@@ -3,7 +3,6 @@
from typing import TYPE_CHECKING
from sqlalchemy import JSON, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_common.constants import ProductCategory, SizeUnit
@@ -27,9 +26,7 @@ class NormalizedProduct(UUIDPrimaryKeyMixin, TimestampMixin, Base):
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().with_variant(JSONB(), "postgresql"), default=list
)
upc_variants: Mapped[list[str] | None] = mapped_column(JSON, default=list)
# Relationships
purchase_items: Mapped[list["PurchaseItem"]] = relationship(back_populates="normalized_product")
+2 -12
View File
@@ -1,11 +1,10 @@
"""User and UserStoreAccount models."""
import secrets
import uuid
from datetime import datetime
from typing import TYPE_CHECKING
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, text
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from cartsnitch_common.constants import AccountStatus
@@ -22,16 +21,7 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
__tablename__ = "users"
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
email_inbound_token: Mapped[str] = mapped_column(
String(22),
nullable=False,
unique=True,
default=lambda: secrets.token_urlsafe(16),
server_default=text(
"replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
),
)
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
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)
@@ -20,7 +20,6 @@ class UserRead(BaseModel):
id: uuid.UUID
email: str
display_name: str | None
email_inbound_token: str
created_at: datetime
updated_at: datetime
-34
View File
@@ -147,40 +147,6 @@ class TestStoreLocationModel:
assert loc.lat == pytest.approx(42.2808)
class TestUserModel:
def test_email_inbound_token_auto_populated(self, session):
user = User(
id=uuid.uuid4(),
email="token_test@example.com",
hashed_password="hashed",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add(user)
session.commit()
assert user.email_inbound_token is not None
assert len(user.email_inbound_token) == 22
def test_email_inbound_token_unique(self, session):
user1 = User(
id=uuid.uuid4(),
email="user1@example.com",
hashed_password="hashed",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
user2 = User(
id=uuid.uuid4(),
email="user2@example.com",
hashed_password="hashed",
created_at=datetime.now(UTC),
updated_at=datetime.now(UTC),
)
session.add_all([user1, user2])
session.commit()
assert user1.email_inbound_token != user2.email_inbound_token
class TestUserStoreAccountModel:
def test_account_status_enum(self, session):
user = User(
-244
View File
@@ -1,244 +0,0 @@
# UAT Receipt Submission Path
**Issue:** [CAR-812](/CAR/issues/CAR-812)
**Author:** Barcode Betty
**Date:** 2026-05-04
---
## Overview
The UAT environment supports receipt submission via **inbound email**. This is the only supported submission method in UAT — there is no public REST API surface for receipt ingestion.
---
## How It Works
### Architecture
```
User composes email
Email sent to <user_token>@cartsnitch.<env>.farh.net
Mailgun webhook receives the email
Email job enqueued to DragonflyDB stream: email:receipts
email-worker (ReceiptWitness) consumes the job
Worker resolves user via email_inbound_token lookup in DB
Retailer detected from email content (meijer / kroger / target)
Email parsed into Purchase + PurchaseItem records
receipt.ingested event published to Redis
MatchResult created with method=upc, confidence=1.0 for known UPCs
```
### Key Components
| Component | Location | Role |
|-----------|----------|------|
| `users.email_inbound_token` | DB (migration `001_add_email_inbound_token`) | 22-char unique token per user; used as email routing identifier |
| `email:receipts` stream | DragonflyDB | Queue holding pending email jobs |
| `email-worker` | `receiptwitness/src/receiptwitness/worker/email_worker.py` | Async worker consuming the stream |
| `BaseEmailParser` | `receiptwitness/src/receiptwitness/parsers/email/base.py` | Abstract parser; subclasses for meijer/kroger/target |
| Retailer detectors | `receiptwitness/src/receiptwitness/parsers/email/detector.py` | Sifts sender/subject to pick the right parser |
### Email Address Format
Each user is assigned a unique inbound token. The receipt submission email address is shown in **Settings → Receipt Email** on the UI:
**Address:** `receipts+<email_inbound_token>@receipts.cartsnitch.com`
To find a user's token in the UAT database (requires `kubectl` access to `cartsnitch-uat`):
```bash
kubectl exec -n cartsnitch-uat deployment/cartsnitch-api -- \\
python -c "from cartsnitch_common.database import get_sync_session; \\
from cartsnitch_common.models.user import User; \\
from sqlalchemy import select; \\
s = get_sync_session('postgresql://cartsnitch:cartsnitch@cartsnitch-pg-rw:5432/cartsnitch'); \\
u = s.execute(select(User).where(User.email=='dottie@example.com')).scalar_one(); \\
print(u.email_inbound_token)"
```
---
## Submitting a Test Receipt (Step-by-Step)
### Prerequisites
- A test user account in UAT with a known `email_inbound_token`
- A sample receipt email with a **known UPC** from the seeded `normalized_products` table
### Steps
1. **Obtain the test user's inbound token.**
Use the UAT Settings → Receipt Email page in the UI to see the full address `receipts+<token>@receipts.cartsnitch.com`, or query the DB directly (see above).
2. **Compose the email.**
Send to: the address shown in Settings → Receipt Email
Subject: anything
Body: plain-text or HTML receipt content
3. **Expected behavior after email is processed:**
- A `Receipt` row is created in `purchases`
- `PurchaseItem` rows are created with `upc` matching the seeded product UPC
- A `MatchResult` is created with `method='upc'` and `confidence=1.0`
---
## Known UPC for Dottie (from UAT seed)
> **NOTE:** `kubectl` is not available in this execution environment. The UAT seed and DB query could not be executed. The sample receipt below uses a plausible placeholder UPC. Before Dottie runs the regression:
> 1. Run `bash scripts/seed-env.sh uat` from a machine with UAT kubecontext
> 2. Query: `SELECT id, canonical_name, upc_variants->0->>'upc' AS sample_upc FROM normalized_products WHERE jsonb_array_length(upc_variants) > 0 LIMIT 1;`
> 3. Replace the placeholder values below with the real captured row
- `id`: **TBD — run seed and query UAT DB**
- `name`: **TBD — run seed and query UAT DB**
- `sample UPC`: **TBD — run seed and query UAT DB**
### Meijer Sample Receipt (plain text)
```
Meijer
===================================
Purchase Date: 03/15/2026
Store: Meijer #127 - Ann Arbor, MI
-----------------------------------
1 x Organic Whole Milk 1gal $4.99
1 x Whole Wheat Bread $3.29
1 x Bananas (2 lb) $0.67
1 x Chicken Breast (3 lb) $12.47
1 x Cheddar Cheese Block 8oz $5.99
-----------------------------------
Subtotal: $27.41
Tax: $1.93
Total: $29.34
===================================
THANK YOU FOR SHOPPING MEIJER
===================================
```
Meijer
===================================
Purchase Date: 03/15/2026
Store: Meijer #127 - Ann Arbor, MI
-----------------------------------
1 x Organic Whole Milk 1gal $4.99
1 x Whole Wheat Bread $3.29
1 x Bananas (2 lb) $0.67
1 x Chicken Breast (3 lb) $12.47
1 x Cheddar Cheese Block 8oz $5.99
-----------------------------------
Subtotal: $27.41
Tax: $1.93
Total: $29.34
===================================
THANK YOU FOR SHOPPING MEIJER
===================================
```
> **Note:** The `email-worker` parses the email body and extracts line items by retailer. The exact format and field mapping depends on the retailer parser. For Meijer, the parser looks for item lines matching `(\d+) x (.+?)\s+\$([\d.]+)`. UPCs in the `upc_variants` JSONB of seeded products will be matched during the normalization step.
### Kroger Sample Receipt (plain text)
```
KROGER
===================================
Purchase Date: 03/15/2026
Store: KROGER #412 - Ann Arbor MI
-----------------------------------
1 Organic Whole Milk 1gal $5.29
1 Whole Wheat Bread $3.49
1 Bananas (2 lb) $0.69
1 Chicken Breast (3 lb) $11.99
1 Sharp Cheddar Cheese 8oz $4.99
-----------------------------------
Subtotal: $26.45
Tax: $1.85
Total: $28.30
===================================
```
### Target Sample Receipt (plain text)
```
TARGET
===================================
03/15/2026 14:32
Store: 0874 Ann Arbor, MI
===================================
1 Organic Whole Milk 1G $5.49
1 Whole Wheat Bread $3.29
1 Bananas LB 2 $0.68
1 Chicken Breast 3# $12.99
1 Cheddar Cheese 8OZ $5.79
-----------------------------------
Subtotal: $28.24
Tax (6%): $1.69
Total: $29.93
===================================
```
---
## Troubleshooting
### Email not processed
1. Check the `email:receipts` stream has messages:
```bash
kubectl exec -n cartsnitch-uat deploy/email-worker -- python -c \\
"import asyncio; from receiptwitness.queue.email import get_redis; \\
async def chk(): c = await get_redis(); info = await c.xinfo_stream('email:receipts'); print(info); \\
asyncio.run(chk())"
```
2. Check `email-worker` logs for retailer detection failures:
```bash
kubectl logs -n cartsnitch-uat deploy/email-worker -f
```
3. Verify the token resolves to a user in the DB:
```bash
kubectl exec -n cartsnitch-uat deploy/cartsnitch-api -- \\
python -c "from cartsnitch_common.database import get_sync_session; \\
from cartsnitch_common.models.user import User; \\
from sqlalchemy import select; \\
s = get_sync_session('postgresql://...'); \\
r = s.execute(select(User.email_inbound_token).limit(5)).all(); \\
print(r)"
```
### No MatchResult created
The normalization pipeline requires a `normalized_product` row with the submitted UPC in `upc_variants`. If the seed was run, the product should be found. Check the `match_results` table after submission:
```sql
SELECT mr.*, np.canonical_name
FROM match_results mr
JOIN normalized_products np ON np.id = mr.normalized_product_id
WHERE mr.match_method = 'upc'
ORDER BY mr.created_at DESC
LIMIT 10;
```
---
## Related Files
| File | Role |
|------|------|
| `common/alembic/versions/001_add_email_inbound_token.py` | Adds `email_inbound_token` column |
| `receiptwitness/src/receiptwitness/worker/email_worker.py` | Consumes email jobs from stream |
| `receiptwitness/src/receiptwitness/queue/email.py` | DragonflyDB stream consumer group |
| `receiptwitness/src/receiptwitness/parsers/email/detector.py` | Retailer detection |
| `receiptwitness/src/receiptwitness/parsers/email/meijer.py` | Meijer email parser |
| `receiptwitness/src/receiptwitness/parsers/email/kroger.py` | Kroger email parser |
| `receiptwitness/src/receiptwitness/parsers/email/target.py` | Target email parser |
| `docs/uat-runbook.md` | UAT runbook (defect classification, entry/exit criteria) |
-100
View File
@@ -1,100 +0,0 @@
import { test as base, expect, type Page } from "@playwright/test";
import AxeBuilder from "@axe-core/playwright";
export const test = base.extend<{ axeCheck: void }>({
axeCheck: [async ({ page }, use) => {
await use();
const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]);
}, { auto: true }],
});
export { expect } from "@playwright/test";
const MOCK_USER_ID = "mock_user_123";
const MOCK_SESSION_ID = "mock_session_456";
async function mockAuthRoutes(page: Page, authenticated = false) {
await page.route(/.*\/auth\/sign-up\/email.*/, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
token: null,
user: {
id: MOCK_USER_ID,
email: "mock@cartsnitch.test",
name: "Mock User",
emailVerified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
}),
});
});
await page.route(/.*\/auth\/sign-in\/email.*/, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
redirect: false,
token: "mock_token_123",
user: {
id: MOCK_USER_ID,
email: "mock@cartsnitch.test",
name: "Mock User",
emailVerified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
}),
});
});
await page.route(/.*\/auth\/get-session.*/, async (route) => {
if (authenticated) {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session: {
id: MOCK_SESSION_ID,
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
ipAddress: null,
userAgent: null,
},
user: {
id: MOCK_USER_ID,
email: "mock@cartsnitch.test",
name: "Mock User",
emailVerified: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
}),
});
} else {
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "Unauthorized" }),
});
}
});
}
export async function mockSessionDelayed(page: Page, delayMs = 3000) {
await page.route(/.*\/auth\/get-session.*/, async (route) => {
await new Promise((r) => setTimeout(r, delayMs));
await route.fulfill({
status: 401,
contentType: "application/json",
body: JSON.stringify({ error: "Unauthorized" }),
});
});
}
export { mockAuthRoutes };
@@ -1,52 +0,0 @@
import { test, expect } from '@playwright/test';
import { mockAuthRoutes } from '../fixtures';
const uniqueEmail = () => `betty+e2e-${Date.now()}@cartsnitch.test`;
test.describe('J1: Registration and Login', () => {
test('shows success message after registration', async ({ page }) => {
await mockAuthRoutes(page, false);
await page.goto('/register');
await page.fill('[placeholder="Full Name"]', 'Betty Tester');
await page.fill('[placeholder="Email"]', uniqueEmail());
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
await page.click('button[type="submit"]');
// Registration now shows "Account created! Please sign in." message
await expect(page.locator('.bg-red-50')).toContainText('Account created! Please sign in.');
});
test('shows validation error when registration fields are empty', async ({ page }) => {
await page.goto('/register');
await page.click('button[type="submit"]');
await expect(page.locator('.bg-red-50')).toContainText('Please fill in all fields');
});
test('can navigate from register to login', async ({ page }) => {
await page.goto('/register');
await page.getByRole('link', { name: /sign in/i }).click();
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
});
test('can sign in with valid credentials', async ({ page }) => {
await mockAuthRoutes(page, true);
const email = uniqueEmail();
await page.goto('/register');
await page.fill('[placeholder="Full Name"]', 'Login Betty');
await page.fill('[placeholder="Email"]', email);
await page.fill('[placeholder="Password (min. 8 characters)"]', 'TestPass123!');
await page.click('button[type="submit"]');
await expect(page.locator('.bg-red-50')).toContainText('Account created! Please sign in.');
await page.goto('/login');
await page.fill('[placeholder="Email"]', 'test@cartsnitch.test');
await page.fill('[placeholder="Password"]', 'TestPass123!');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('http://localhost:5173/');
});
});
-43
View File
@@ -1,43 +0,0 @@
import { test, expect } from '@playwright/test';
import { mockAuthRoutes, mockSessionDelayed } from '../fixtures';
test.describe('J8: Unauthenticated Access', () => {
test('redirects /dashboard (/) to /login when not authenticated', async ({ page }) => {
await mockAuthRoutes(page, false);
await page.goto('/');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
});
test('redirects /purchases to /login when not authenticated', async ({ page }) => {
await mockAuthRoutes(page, false);
await page.goto('/purchases');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
});
test('redirects /products to /login when not authenticated', async ({ page }) => {
await mockAuthRoutes(page, false);
await page.goto('/products');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
});
test('redirects /coupons to /login when not authenticated', async ({ page }) => {
await mockAuthRoutes(page, false);
await page.goto('/coupons');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
});
test('shows loading spinner while auth session is pending', async ({ page }) => {
await mockSessionDelayed(page, 3000);
await page.goto('/purchases');
await expect(page.locator('.animate-spin')).toBeVisible({ timeout: 2000 });
await expect(page).toHaveURL(/\/login/, { timeout: 10_000 });
});
});
-8
View File
@@ -1,8 +0,0 @@
import { test, expect, mockAuthRoutes } from './fixtures';
test('app loads', async ({ page }) => {
await mockAuthRoutes(page, false);
await page.goto('/');
await expect(page).toHaveURL(/\/login/);
await expect(page.getByRole('heading', { name: /CartSnitch/i })).toBeVisible();
});
-24
View File
@@ -1,24 +0,0 @@
{
"ci": {
"collect": {
"staticDistDir": "./dist",
"url": ["http://localhost:4173/"],
"numberOfRuns": 1,
"settings": {
"chromeFlags": ["--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
"skipAudits": ["bf-cache"],
"disableFullPageScreenshot": true
}
},
"assert": {
"assertions": {
"categories:performance": ["warn", { "minScore": 0.7 }],
"categories:accessibility": ["error", { "minScore": 0.9 }],
"categories:best-practices": ["warn", { "minScore": 0.8 }]
}
},
"upload": {
"target": "temporary-public-storage"
}
}
}
-6
View File
@@ -9,12 +9,6 @@ server {
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript image/svg+xml;
gzip_min_length 256;
# Security headers
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self'; connect-src 'self' https://*.cartsnitch.com https://*.farh.net; frame-ancestors 'self'" always;
# Health endpoint for K8s probes
location /health {
access_log off;
+672 -1221
View File
File diff suppressed because it is too large Load Diff
+3 -17
View File
@@ -9,13 +9,11 @@
"lint": "eslint .",
"preview": "vite preview",
"test": "NODE_ENV=test vitest run",
"test:watch": "NODE_ENV=test vitest",
"test:e2e": "npx playwright test"
"test:watch": "NODE_ENV=test vitest"
},
"dependencies": {
"@tanstack/react-query": "^5.0.0",
"better-auth": "^1.2.0",
"picomatch": "4.0.4",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.0.0",
@@ -23,36 +21,24 @@
"zustand": "^5.0.0"
},
"devDependencies": {
"@axe-core/playwright": "^4.10.0",
"@eslint/js": "^9.39.4",
"@playwright/test": "^1.58.2",
"@tailwindcss/vite": "^4.0.0",
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.12.0",
"@types/react": "^18.3.28",
"@types/react-dom": "^18.3.7",
"@vitejs/plugin-react": "^4.7.0",
"@vitejs/plugin-react": "^4.5.2",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"jsdom": "^25.0.1",
"msw": "^2.12.14",
"playwright": "^1.58.2",
"tailwindcss": "^4.0.0",
"typescript": "^5.7.3",
"typescript-eslint": "^8.56.1",
"vite": "^6.4.2",
"vite": "^6.3.5",
"vite-plugin-pwa": "^0.21.2",
"vitest": "^3.2.4"
},
"overrides": {
"@rollup/pluginutils": "5.3.0",
"flatted": "^3.4.2",
"serialize-javascript": "7.0.5",
"brace-expansion": ">=1.1.13",
"lodash": ">=4.17.24",
"minimatch": "^10.2.4"
}
}
-19
View File
@@ -1,19 +0,0 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
webServer: {
command: 'npm run dev',
url: 'http://localhost:5173',
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: 'http://localhost:5173',
},
});
-4
View File
@@ -1,4 +0,0 @@
User-agent: *
Allow: /
Sitemap: https://cartsnitch.com/sitemap.xml
+168
View File
@@ -0,0 +1,168 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
permissions:
contents: write
packages: write
env:
REGISTRY: ghcr.io
IMAGE_NAME: cartsnitch/receiptwitness
jobs:
lint:
runs-on: runners-cartsnitch
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install cartsnitch-common from GitHub
run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b"
- run: pip install ruff
- name: Ruff lint
run: ruff check .
- name: Ruff format check
run: ruff format --check .
typecheck:
runs-on: runners-cartsnitch
continue-on-error: true
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install cartsnitch-common from GitHub
run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b"
- run: pip install -e ".[dev]" mypy
- name: Type check
run: mypy src/receiptwitness
test:
runs-on: runners-cartsnitch
services:
postgres:
image: postgres:15-alpine
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
env:
POSTGRES_USER: cartsnitch
POSTGRES_PASSWORD: cartsnitch_test
POSTGRES_DB: cartsnitch_test
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
redis:
image: redis:7-alpine
credentials:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
ports:
- 6379:6379
options: >-
--health-cmd "redis-cli ping"
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://cartsnitch:cartsnitch_test@localhost:5432/cartsnitch_test
REDIS_URL: redis://localhost:6379/0
ENCRYPTION_KEY: dGVzdC1lbmNyeXB0aW9uLWtleS0xMjM0NTY3ODk=
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
cache: pip
- name: Install cartsnitch-common from GitHub
run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b"
- run: pip install -e ".[dev]"
- name: Install Playwright browsers
run: playwright install chromium --with-deps
- name: Run tests
run: pytest --tb=short -q
build-and-push:
runs-on: runners-cartsnitch
needs: [lint, test]
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Generate CalVer tag
id: calver
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
DATE_TAG=$(date -u +%Y.%m.%d)
EXISTING=$(git tag -l "v${DATE_TAG}*" | sort -V | tail -1)
if [ -z "$EXISTING" ]; then
VERSION="$DATE_TAG"
elif [ "$EXISTING" = "v${DATE_TAG}" ]; then
VERSION="${DATE_TAG}.2"
else
BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//")
VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"
fi
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
echo "CalVer tag: $VERSION"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=sha,prefix=sha-
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: .
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
target: prod
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Create git tag
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
run: |
git tag "v${{ steps.calver.outputs.version }}"
git push origin "v${{ steps.calver.outputs.version }}"
+15 -14
View File
@@ -3,22 +3,24 @@ FROM python:3.12-slim AS build
WORKDIR /app
# build-essential and libpq-dev are needed to compile any C-extension wheels
# (e.g. psycopg2 fallback). No git needed common/ is copied from the repo root.
ARG APT_CACHE_BUST=1
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
# git is required to install cartsnitch-common from GitHub; build-essential and
# libpq-dev are needed to compile any C-extension wheels (e.g. psycopg2 fallback)
RUN apt-get update && apt-get install -y --no-install-recommends \
git \
libpq-dev \
build-essential \
&& rm -rf /var/lib/apt/lists/*
# Build context is the repo root. These paths are relative to the root.
COPY receiptwitness/pyproject.toml ./
COPY receiptwitness/src/ ./src/
COPY common/ ./common/
COPY pyproject.toml ./
COPY src/ ./src/
# Install from the local common/ (cartsnitch-common>=0.1.0 in pyproject.toml
# will be satisfied by the local package) then install receiptwitness itself.
RUN pip install --no-cache-dir --prefix=/install ./common/ .
# cartsnitch-common is not on PyPI — install it directly from GitHub, then
# install the rest of the package dependencies in a single resolver pass so
# pip can satisfy the cartsnitch-common>=0.1.0 constraint declared in
# pyproject.toml without hitting PyPI for it.
RUN pip install --no-cache-dir --prefix=/install \
"cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b" \
.
# Stage 2: Production image with Playwright + Chromium
FROM python:3.12-slim AS prod
@@ -26,8 +28,7 @@ FROM python:3.12-slim AS prod
WORKDIR /app
# 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 \
RUN apt-get update && apt-get install -y --no-install-recommends \
libnss3 \
libatk1.0-0 \
libatk-bridge2.0-0 \
@@ -50,7 +51,7 @@ RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-reco
RUN adduser --system --group --uid 1000 app
COPY --from=build /install /usr/local
COPY receiptwitness/src/ ./src/
COPY src/ ./src/
# Install Playwright Chromium browser (runs as root; /opt/playwright is world-readable)
RUN PLAYWRIGHT_BROWSERS_PATH=/opt/playwright playwright install chromium
+1 -6
View File
@@ -11,16 +11,14 @@ dependencies = [
"cartsnitch-common>=0.1.0",
"playwright>=1.49,<2.0",
"playwright-stealth>=1.0,<2.0",
"cryptography>=46.0,<47.0",
"cryptography>=42.0,<44.0",
"fastapi>=0.115,<1.0",
"uvicorn[standard]>=0.30,<1.0",
"beautifulsoup4>=4.12,<5.0",
"redis>=5.0,<6.0",
"pydantic>=2.0,<3.0",
"pydantic-settings>=2.0,<3.0",
"sqlalchemy[asyncio]>=2.0,<3.0",
"asyncpg>=0.29,<1.0",
"resend>=2.0",
]
[project.optional-dependencies]
@@ -29,9 +27,6 @@ dev = [
"pytest-asyncio>=0.23",
"ruff>=0.3",
"pytest-cov>=5.0",
"fakeredis[aioredis]>=2.20",
"httpx>=0.27",
"python-multipart>=0.0.9",
]
[tool.hatch.build.targets.wheel]
@@ -1,65 +1,9 @@
"""Internal API routes for triggering scrapes and checking status."""
import hashlib
import hmac
import re
import time
from fastapi import APIRouter, HTTPException, Request
from receiptwitness.config import settings
from receiptwitness.queue.email import EmailJob, enqueue_email, get_redis
from fastapi import APIRouter
router = APIRouter()
TOKEN_PATTERN = re.compile(r"receipts\+([A-Za-z0-9_-]+)@")
def verify_mailgun_signature(token: str, timestamp: str, signature: str) -> bool:
"""Verify Mailgun webhook signature."""
try:
ts = int(timestamp)
except (ValueError, TypeError):
return False
if abs(time.time() - ts) > 300: # 5 min freshness
return False
key = settings.mailgun_webhook_signing_key.encode()
hmac_digest = hmac.new(key, f"{timestamp}{token}".encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(signature, hmac_digest)
@router.post("/inbound/email")
async def receive_inbound_email(request: Request):
form = await request.form()
# 1. Verify Mailgun signature
token = str(form.get("token", ""))
timestamp = str(form.get("timestamp", ""))
signature = str(form.get("signature", ""))
if not verify_mailgun_signature(token, timestamp, signature):
raise HTTPException(status_code=406, detail="Invalid signature")
# 2. Extract account token from recipient
recipient = str(form.get("recipient", ""))
match = TOKEN_PATTERN.search(recipient)
if not match:
raise HTTPException(status_code=406, detail="Invalid recipient")
account_token = match.group(1)
# 3. Enqueue — worker resolves token -> user_id
body_html_val = form.get("body-html")
body_plain_val = form.get("body-plain")
job = EmailJob(
user_id=account_token,
sender=str(form.get("sender", "")),
recipient=recipient,
subject=str(form.get("subject", "")),
body_html=str(body_html_val) if body_html_val is not None else None,
body_plain=str(body_plain_val) if body_plain_val is not None else None,
received_at=str(form.get("timestamp", "")),
message_id=str(form.get("Message-Id", "")),
)
client = await get_redis()
await enqueue_email(client, job)
return {"status": "queued"}
@router.get("/health")
async def health():
+1 -42
View File
@@ -1,12 +1,8 @@
"""Service-specific configuration for ReceiptWitness."""
from pydantic import model_validator
from pydantic_settings import BaseSettings
_PLACEHOLDER_VALUES = {"change-me-in-production"}
class ReceiptWitnessSettings(BaseSettings):
model_config = {"env_prefix": "RW_"}
@@ -26,42 +22,5 @@ class ReceiptWitnessSettings(BaseSettings):
headless: bool = True
browser_timeout_ms: int = 60000
# Email notifications (Resend)
resend_api_key: str = ""
notification_email_from: str = "notifications@cartsnitch.com"
notifications_enabled: bool = False
# Mailgun inbound email webhook
mailgun_webhook_signing_key: str = ""
@model_validator(mode="after")
def validate_required_vars(self):
errors = []
if not self.session_encryption_key or self.session_encryption_key in _PLACEHOLDER_VALUES:
errors.append(
"RW_SESSION_ENCRYPTION_KEY must be set to a secure value. "
'Generate one with: python -c "from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())"'
)
if self.notifications_enabled and not self.resend_api_key:
errors.append(
"RW_RESEND_API_KEY must be set when RW_NOTIFICATIONS_ENABLED=true. "
"Get an API key from https://resend.com/api-keys"
)
if errors:
raise ValueError(
"ReceiptWitness startup failed — missing required config:\n"
+ "\n".join(f" - {e}" for e in errors)
)
return self
class _LazySettings:
_instance: ReceiptWitnessSettings | None = None
def __getattr__(self, name: str):
if _LazySettings._instance is None:
_LazySettings._instance = ReceiptWitnessSettings()
return getattr(_LazySettings._instance, name)
settings = _LazySettings()
settings = ReceiptWitnessSettings()
+8 -46
View File
@@ -2,17 +2,12 @@
import json
import logging
import uuid
from datetime import UTC, datetime
from decimal import Decimal
import redis.asyncio as aioredis
from cartsnitch_common.database import get_async_session_factory
from cartsnitch_common.models.user import User
from sqlalchemy import select
from receiptwitness.config import settings
from receiptwitness.notifications.email import send_receipt_notification
logger = logging.getLogger(__name__)
@@ -44,36 +39,6 @@ async def get_redis_client() -> aioredis.Redis:
return aioredis.Redis(connection_pool=_get_pool())
async def _send_notification_for_event(payload: dict) -> None:
"""Look up user email and send receipt notification. Silently skips on error."""
try:
user_uuid = uuid.UUID(payload["user_id"])
except (ValueError, KeyError):
logger.warning("Invalid user_id in event payload: %s", payload.get("user_id"))
return
try:
session_factory = get_async_session_factory(settings.database_url)
async with session_factory() as session:
result = await session.execute(select(User.email).where(User.id == user_uuid))
row = result.scalar_one_or_none()
if not row:
logger.warning("User %s not found for notification", user_uuid)
return
user_email = row
except Exception:
logger.exception("Failed to look up user email for notification")
return
await send_receipt_notification(
user_email=user_email,
store_name=payload["store_slug"],
item_count=payload["item_count"],
total=payload["total"],
purchase_date=payload["purchase_date"],
)
async def publish_receipt_ingested(
user_id: str,
store_slug: str,
@@ -83,19 +48,18 @@ async def publish_receipt_ingested(
total: Decimal | float,
) -> None:
"""Publish a cartsnitch.receipts.ingested event after successful ingestion."""
payload = {
"user_id": user_id,
"store_slug": store_slug,
"purchase_id": purchase_id,
"purchase_date": purchase_date,
"item_count": item_count,
"total": float(total) if isinstance(total, Decimal) else total,
}
event = {
"event_type": CHANNEL_RECEIPTS_INGESTED,
"timestamp": datetime.now(UTC).isoformat(),
"service": "receiptwitness",
"payload": payload,
"payload": {
"user_id": user_id,
"store_slug": store_slug,
"purchase_id": purchase_id,
"purchase_date": purchase_date,
"item_count": item_count,
"total": float(total) if isinstance(total, Decimal) else total,
},
}
try:
@@ -109,5 +73,3 @@ async def publish_receipt_ingested(
except aioredis.ConnectionError:
logger.error("Failed to publish event — Redis/DragonflyDB connection error")
raise
else:
await _send_notification_for_event(payload)
@@ -1,45 +0,0 @@
"""Email notifications via Resend."""
import asyncio
import html
import logging
import resend
from receiptwitness.config import settings
logger = logging.getLogger(__name__)
async def send_receipt_notification(
user_email: str,
store_name: str,
item_count: int,
total: float,
purchase_date: str,
) -> None:
"""Send receipt ingestion confirmation email via Resend."""
if not settings.notifications_enabled or not settings.resend_api_key:
logger.debug("Notifications disabled — skipping email send")
return
resend.api_key = settings.resend_api_key
store_name_safe = html.escape(store_name)
purchase_date_safe = html.escape(purchase_date)
try:
await asyncio.to_thread(
resend.Emails.send,
{
"from": settings.notification_email_from,
"to": [user_email],
"subject": f"Receipt processed: {store_name} - ${total:.2f}",
"html": (
f"<p>Your receipt from <strong>{store_name_safe}</strong> on "
f"{purchase_date_safe} has been processed.</p>"
f"<p>{item_count} items, total: ${total:.2f}</p>"
),
},
)
logger.info("Receipt notification sent to %s", user_email)
except Exception:
logger.exception("Failed to send receipt notification to %s", user_email)
@@ -1 +0,0 @@
"""Email receipt parsers for retailer email receipts."""
@@ -1,32 +0,0 @@
"""Base interface for email receipt parsers."""
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
@dataclass
class EmailReceipt:
"""Raw email data before parsing."""
sender: str
recipient: str
subject: str
body_html: str | None = None
body_plain: str | None = None
received_at: str | None = None
raw_headers: dict = field(default_factory=dict)
class BaseEmailParser(ABC):
"""All retailer email parsers implement this interface."""
@abstractmethod
def can_parse(self, email: EmailReceipt) -> bool:
"""Return True if this parser handles this email."""
...
@abstractmethod
def parse(self, email: EmailReceipt) -> dict:
"""Parse email into a dict matching PurchaseCreate schema fields.
Must include an items list matching PurchaseItemCreate fields."""
...
@@ -1,25 +0,0 @@
"""Detect which retailer sent a receipt email."""
import re
from receiptwitness.parsers.email.base import EmailReceipt
RETAILER_PATTERNS: dict[str, list[str]] = {
"meijer": [r"@meijer\.com$", r"@email\.meijer\.com$"],
"kroger": [r"@kroger\.com$", r"@email\.kroger\.com$"],
"target": [r"@target\.com$", r"@email\.target\.com$"],
}
def detect_retailer(email: EmailReceipt) -> str | None:
"""Return retailer slug or None if unrecognized."""
sender = email.sender.lower().strip()
# Extract email from "Name <email>" format
match = re.search(r"<([^>]+)>", sender)
if match:
sender = match.group(1)
for retailer, patterns in RETAILER_PATTERNS.items():
for pattern in patterns:
if re.search(pattern, sender):
return retailer
return None
@@ -1,157 +0,0 @@
"""Kroger email receipt parser."""
import logging
import re
from datetime import datetime
from decimal import Decimal, InvalidOperation
from bs4 import BeautifulSoup
from receiptwitness.parsers.email.base import BaseEmailParser, EmailReceipt
logger = logging.getLogger(__name__)
def _to_decimal(value: str | float | int | None, default: Decimal = Decimal("0")) -> Decimal:
"""Safely convert a value to Decimal."""
if value is None:
return default
try:
return Decimal(str(value).replace("$", "").replace(",", "").strip())
except (InvalidOperation, ValueError):
return default
def _extract_total(body: str) -> Decimal:
"""Extract the transaction total from email body."""
patterns = [
r"Total[:\s]*\$?([0-9,]+\.[0-9]{2})",
r"Amount[:\s]*\$?([0-9,]+\.[0-9]{2})",
r"Grand\s+Total[:\s]*\$?([0-9,]+\.[0-9]{2})",
]
for pattern in patterns:
match = re.search(pattern, body, re.IGNORECASE)
if match:
return _to_decimal(match.group(1))
return Decimal("0")
def _extract_receipt_id(body: str) -> str | None:
"""Extract receipt ID / transaction ID from HTML body.
Strips HTML tags first so that whitespace between delimiters and values
(e.g. from ``</strong> KR-2026-0315-4829`` -> `` KR-2026-0315-4829``)
is normalized and the pattern can match cleanly.
"""
stripped = re.sub(r"<[^>]+>", "", body)
patterns = [
r"Receipt\s*#[:\s]*([A-Z0-9-]+)",
r"Transaction\s*#[:\s]*([A-Z0-9-]+)",
r"Order\s*#[:\s]*([A-Z0-9-]+)",
r"Confirmation\s*#[:\s]*([A-Z0-9-]+)",
]
for pattern in patterns:
match = re.search(pattern, stripped, re.IGNORECASE)
if match:
return match.group(1)
return None
def _extract_date(body: str) -> str:
"""Extract purchase date from email body. Returns ISO date string or empty string."""
patterns = [
r"(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})",
r"([A-Z][a-z]{2}\s+\d{1,2},?\s+\d{4})",
]
for pattern in patterns:
match = re.search(pattern, body)
if match:
raw = match.group(1)
try:
dt = datetime.strptime(raw.replace(",", ""), "%b %d %Y")
return dt.strftime("%Y-%m-%d")
except ValueError:
pass
try:
for fmt in ("%m/%d/%Y", "%m/%d/%y", "%d/%m/%Y", "%d/%m/%y"):
try:
dt = datetime.strptime(raw, fmt)
return dt.strftime("%Y-%m-%d")
except ValueError:
continue
except Exception:
pass
return ""
def _extract_items_soup(body: str) -> list[dict]:
"""Extract line items from HTML email body using BeautifulSoup."""
items = []
try:
soup = BeautifulSoup(body, "html.parser")
text = soup.get_text(separator="\n", strip=True)
# Strip HTML tags from raw body to normalize whitespace
stripped = re.sub(r"<[^>]+>", " ", body)
stripped = re.sub(r"\s+", " ", stripped)
skip_prefixes = (
"Subtotal",
"Tax",
"Total",
"Kroger",
"Target",
"Date",
"Receipt",
"Order",
"Transaction",
"Confirmation",
"Thank",
"Questions",
"Keep",
"Receipt",
)
for line in text.split("\n"):
line = line.strip()
if not line or line.startswith(skip_prefixes):
continue
# Match lines like "Product Name $9.99"
match = re.match(r"(.+?)\s+\$([0-9]+\.[0-9]{2})\s*$", line)
if match:
name = match.group(1).strip()
price = _to_decimal(match.group(2))
if len(name) > 2 and price > 0:
items.append(
{
"product_name_raw": name,
"quantity": Decimal("1"),
"unit_price": price,
"extended_price": price,
}
)
except Exception:
pass
return items[:20]
class KrogerEmailParser(BaseEmailParser):
"""Parse Kroger email receipts (digital receipts via kroger.com)."""
KROGER_KEYWORDS = ("kroger", "kroger.com", "plus")
def can_parse(self, email: EmailReceipt) -> bool:
sender = (email.sender or "").lower()
body = (email.body_html or email.body_plain or "").lower()
return any(kw in sender or kw in body for kw in self.KROGER_KEYWORDS)
def parse(self, email: EmailReceipt) -> dict:
body = (email.body_html or email.body_plain or "").strip()
total = _extract_total(body)
receipt_id = _extract_receipt_id(body) or ""
purchase_date = _extract_date(body)
items = _extract_items_soup(body)
return {
"receipt_id": receipt_id,
"purchase_date": purchase_date,
"total": total,
"items": items,
}
@@ -1,259 +0,0 @@
"""Parse Meijer digital receipt emails into structured purchase data."""
import re
from decimal import Decimal, InvalidOperation
from bs4 import BeautifulSoup
from bs4.element import Tag
from receiptwitness.parsers.email.base import BaseEmailParser, EmailReceipt
def _to_decimal(value, default: str = "0") -> Decimal:
"""Safely convert a value to Decimal."""
if value is None:
return Decimal(default)
try:
return Decimal(str(value).replace("$", "").replace(",", "").strip())
except (InvalidOperation, ValueError, TypeError):
return Decimal(default)
def _extract_receipt_id(soup: BeautifulSoup, subject: str | None) -> str | None:
"""Extract receipt/transaction ID from subject or body."""
if subject:
match = re.search(r"TXN[-\s]\d{4}[-\s]\d{4}[-\s]\d+", subject)
if match:
return match.group(0).replace(" ", "-")
# Fallback: look in body
text = soup.get_text()
match = re.search(r"TXN[-\s]\d{4}[-\s]\d{4}[-\s]\d+", text)
if match:
return match.group(0).replace(" ", "-")
return None
def _extract_purchase_date(soup: BeautifulSoup, subject: str | None) -> str | None:
"""Extract purchase date from subject or body."""
text = soup.get_text()
# Try ISO format first: YYYY-MM-DD
match = re.search(r"(\d{4})-(\d{2})-(\d{2})", text)
if match:
return f"{match.group(1)}-{match.group(2)}-{match.group(3)}"
# Try written format: March 15, 2026
match = re.search(r"([A-Za-z]+)\s+(\d{1,2}),?\s+(\d{4})", text)
if match:
month_str = match.group(1).lower()
day = match.group(2)
year = match.group(3)
month_map = {
"january": "01",
"february": "02",
"march": "03",
"april": "04",
"may": "05",
"june": "06",
"july": "07",
"august": "08",
"september": "09",
"october": "10",
"november": "11",
"december": "12",
}
month = month_map.get(month_str)
if month:
return f"{year}-{month}-{day.zfill(2)}"
# MM/DD/YYYY
match = re.search(r"(\d{1,2})/(\d{1,2})/(\d{4})", text)
if match:
return f"{match.group(3)}-{match.group(1).zfill(2)}-{match.group(2).zfill(2)}"
return None
def _extract_store_info(soup: BeautifulSoup) -> dict:
"""Extract store name and number from the email body."""
store_info: dict = {}
# Look for store number in header
store_num_match = re.search(r"Meijer\s+Store\s+#?(\d+)", soup.get_text(), re.IGNORECASE)
if store_num_match:
store_info["store_number"] = store_num_match.group(1)
return store_info
def _extract_items(table: Tag | None) -> list[dict]:
"""Extract line items from the items table."""
items: list[dict] = []
if not table:
return items
rows = table.find_all("tr")
for row in rows:
cells = row.find_all("td")
if len(cells) < 3:
continue
name_cell = cells[0].get_text(strip=True)
qty_cell = cells[1].get_text(strip=True)
price_cell = cells[2].get_text(strip=True)
if not name_cell or name_cell.lower() in ("item", "description"):
continue
# Skip subtotal/tax/total/savings rows
if any(
label in name_cell.lower()
for label in ("subtotal", "tax", "total", "savings", "grand total")
):
continue
try:
quantity = Decimal(qty_cell)
except (InvalidOperation, ValueError, TypeError):
quantity = Decimal("1")
price_str = price_cell.replace("$", "").replace(",", "").strip()
try:
unit_price = Decimal(price_str)
except (InvalidOperation, ValueError, TypeError):
unit_price = Decimal("0")
extended_price = unit_price # Default to unit price; no qty column in fixture
items.append(
{
"product_name_raw": name_cell,
"quantity": quantity,
"unit_price": unit_price,
"extended_price": extended_price,
}
)
return items
def _extract_totals_plain(text: str) -> dict:
"""Extract totals from plain text (no HTML)."""
totals: dict = {
"subtotal": None,
"tax": None,
"total": None,
"savings_total": None,
}
match = re.search(r"\bSubtotal\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
if match:
totals["subtotal"] = _to_decimal(match.group(1))
match = re.search(r"\bTax\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
if match:
totals["tax"] = _to_decimal(match.group(1))
grand_total_match = re.search(r"Grand\s+Total\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
if grand_total_match:
totals["total"] = _to_decimal(grand_total_match.group(1))
savings_match = re.search(r"\bSavings\b[:\s$\-]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
if savings_match:
totals["savings_total"] = _to_decimal(savings_match.group(1))
if totals["total"] is None:
total_match = re.search(r"\bTotal\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
if total_match:
totals["total"] = _to_decimal(total_match.group(1))
return totals
def _extract_totals(soup: BeautifulSoup) -> dict:
"""Extract subtotal, tax, total, and savings from the totals section."""
text = soup.get_text()
totals: dict = {
"subtotal": None,
"tax": None,
"total": None,
"savings_total": None,
}
# Subtotal — use word boundary to avoid matching "Subtotal" with "Total"
match = re.search(r"\bSubtotal\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
if match:
totals["subtotal"] = _to_decimal(match.group(1))
# Tax
match = re.search(r"\bTax\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
if match:
totals["tax"] = _to_decimal(match.group(1))
# Grand Total (before plain "Total" to avoid matching "Subtotal")
grand_total_match = re.search(r"Grand\s+Total\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
if grand_total_match:
totals["total"] = _to_decimal(grand_total_match.group(1))
# Savings — allow any combination of whitespace/$- around the number
savings_match = re.search(r"\bSavings\b[:\s$\-]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
if savings_match:
totals["savings_total"] = _to_decimal(savings_match.group(1))
# Plain "Total" only if Grand Total wasn't found
if totals["total"] is None:
total_match = re.search(r"\bTotal\b[:\s$]*([0-9,]+\.?\d*)", text, re.IGNORECASE)
if total_match:
totals["total"] = _to_decimal(total_match.group(1))
return totals
class MeijerEmailParser(BaseEmailParser):
"""Parse Meijer digital receipt emails forwarded by users."""
def can_parse(self, email: EmailReceipt) -> bool:
sender = email.sender.lower().strip()
# Extract email from "Name <email>" format
match = re.search(r"<([^>]+)>", sender)
if match:
sender = match.group(1)
return "meijer" in sender
def parse(self, email: EmailReceipt) -> dict:
body_html = email.body_html
body_plain = email.body_plain or ""
body = body_html or body_plain
soup = BeautifulSoup(body, "html.parser")
receipt_id = _extract_receipt_id(soup, email.subject)
purchase_date = _extract_purchase_date(soup, email.subject)
_ = _extract_store_info(soup)
# Find the items table — look for one with Item/Qty/Price headers
table = None
for tbl in soup.find_all("table"):
headers = tbl.find_all("th")
header_texts = [h.get_text(strip=True).lower() for h in headers]
if any("item" in h or "qty" in h or "price" in h for h in header_texts):
table = tbl
break
items = _extract_items(table)
# Extract totals from HTML; fall back to plain text if no HTML
if body_html:
totals = _extract_totals(soup)
else:
totals = _extract_totals_plain(body_plain)
return {
"receipt_id": receipt_id or "",
"purchase_date": purchase_date or "",
"total": totals["total"] or Decimal("0"),
"subtotal": totals["subtotal"],
"tax": totals["tax"],
"savings_total": totals["savings_total"],
"items": items,
}
@@ -1,156 +0,0 @@
"""Target email receipt parser."""
import logging
import re
from datetime import datetime
from decimal import Decimal, InvalidOperation
from bs4 import BeautifulSoup
from receiptwitness.parsers.email.base import BaseEmailParser, EmailReceipt
logger = logging.getLogger(__name__)
def _to_decimal(value: str | float | int | None, default: Decimal = Decimal("0")) -> Decimal:
"""Safely convert a value to Decimal."""
if value is None:
return default
try:
return Decimal(str(value).replace("$", "").replace(",", "").strip())
except (InvalidOperation, ValueError):
return default
def _extract_total(body: str) -> Decimal:
"""Extract the transaction total from email body."""
patterns = [
r"Total[:\s]*\$?([0-9,]+\.[0-9]{2})",
r"Amount[:\s]*\$?([0-9,]+\.[0-9]{2})",
r"Grand\s+Total[:\s]*\$?([0-9,]+\.[0-9]{2})",
]
for pattern in patterns:
match = re.search(pattern, body, re.IGNORECASE)
if match:
return _to_decimal(match.group(1))
return Decimal("0")
def _extract_receipt_id(body: str) -> str | None:
"""Extract receipt ID / transaction ID from HTML body.
Strips HTML tags first so that whitespace between delimiters and values
(e.g. from ``</strong> TGT-2026-0318-9124`` -> `` TGT-2026-0318-9124``)
is normalized and the pattern can match cleanly.
"""
stripped = re.sub(r"<[^>]+>", "", body)
patterns = [
r"Receipt\s*#[:\s]*([A-Z0-9-]+)",
r"Order\s*#[:\s]*([A-Z0-9-]+)",
r"Confirmation\s*#[:\s]*([A-Z0-9-]+)",
r"Target\s+Order\s*#[:\s]*([A-Z0-9-]+)",
]
for pattern in patterns:
match = re.search(pattern, stripped, re.IGNORECASE)
if match:
return match.group(1)
return None
def _extract_date(body: str) -> str:
"""Extract purchase date from email body. Returns ISO date string or empty string."""
patterns = [
r"(\d{1,2}[/-]\d{1,2}[/-]\d{2,4})",
r"([A-Z][a-z]{2}\s+\d{1,2},?\s+\d{4})",
]
for pattern in patterns:
match = re.search(pattern, body)
if match:
raw = match.group(1)
try:
dt = datetime.strptime(raw.replace(",", ""), "%b %d %Y")
return dt.strftime("%Y-%m-%d")
except ValueError:
pass
try:
for fmt in ("%m/%d/%Y", "%m/%d/%y", "%d/%m/%Y", "%d/%m/%y"):
try:
dt = datetime.strptime(raw, fmt)
return dt.strftime("%Y-%m-%d")
except ValueError:
continue
except Exception:
pass
return ""
def _extract_items_soup(body: str) -> list[dict]:
"""Extract line items from HTML email body using BeautifulSoup."""
items = []
try:
soup = BeautifulSoup(body, "html.parser")
text = soup.get_text(separator="\n", strip=True)
for line in text.split("\n"):
line = line.strip()
if not line or line.startswith(
(
"Subtotal",
"Tax",
"Total",
"Target",
"Kroger",
"Date",
"Receipt",
"Order",
"Transaction",
"Confirmation",
"Thank",
"Questions",
"Keep",
"Receipt",
"Store",
)
):
continue
# Match lines like "Product Name $9.99"
match = re.match(r"(.+?)\s+\$([0-9]+\.[0-9]{2})\s*$", line)
if match:
name = match.group(1).strip()
price = _to_decimal(match.group(2))
if len(name) > 2 and price > 0:
items.append(
{
"product_name_raw": name,
"quantity": Decimal("1"),
"unit_price": price,
"extended_price": price,
}
)
except Exception:
pass
return items[:20]
class TargetEmailParser(BaseEmailParser):
"""Parse Target email receipts (Circle order confirmations)."""
TARGET_KEYWORDS = ("target.com", "targetnow", "circle", "target")
def can_parse(self, email: EmailReceipt) -> bool:
sender = (email.sender or "").lower()
body = (email.body_html or email.body_plain or "").lower()
return any(kw in sender or kw in body for kw in self.TARGET_KEYWORDS)
def parse(self, email: EmailReceipt) -> dict:
body = (email.body_html or email.body_plain or "").strip()
total = _extract_total(body)
receipt_id = _extract_receipt_id(body) or ""
purchase_date = _extract_date(body)
items = _extract_items_soup(body)
return {
"receipt_id": receipt_id,
"purchase_date": purchase_date,
"total": total,
"items": items,
}
@@ -5,14 +5,12 @@ Matches products across retailers by:
2. Fuzzy name matching via token-based Jaccard similarity (lower confidence)
"""
import json
import re
from dataclasses import dataclass
from enum import StrEnum
from cartsnitch_common.models.product import NormalizedProduct
from sqlalchemy import cast, func, select, String
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy import select
from sqlalchemy.orm import Session
@@ -98,24 +96,17 @@ def jaccard_similarity(a: str, b: str) -> float:
def match_by_upc(session: Session, upc: str) -> MatchResult | None:
"""Find a normalized product by exact UPC match.
Uses PostgreSQL JSONB containment (@>) for production efficiency.
Falls back to LIKE on SQLite for test compatibility.
Loads products with upc_variants and checks membership in Python
for cross-database compatibility (works on both PostgreSQL and SQLite).
"""
dialect_name = session.bind.dialect.name if session.bind else "default"
if dialect_name == "postgresql":
stmt = select(NormalizedProduct).where(
cast(NormalizedProduct.upc_variants, JSONB).op("@>")(
func.cast(json.dumps([upc]), JSONB)
)
)
else:
stmt = select(NormalizedProduct).where(
NormalizedProduct.upc_variants.is_not(None),
cast(NormalizedProduct.upc_variants, String).contains(upc),
)
product = session.execute(stmt).scalars().first()
if product:
return MatchResult(product=product, confidence=1.0, method=MatchMethod.UPC)
# TODO: Use PostgreSQL JSON containment query (@>) for production.
# Current approach loads all products into memory — acceptable for tests
# and small datasets, but will not scale.
stmt = select(NormalizedProduct).where(NormalizedProduct.upc_variants.is_not(None))
products = session.execute(stmt).scalars().all()
for product in products:
if product.upc_variants and upc in product.upc_variants:
return MatchResult(product=product, confidence=1.0, method=MatchMethod.UPC)
return None
@@ -1 +0,0 @@
"""DragonflyDB Streams queue for email receipt processing."""
@@ -1,77 +0,0 @@
"""DragonflyDB Streams queue for email receipt processing."""
from __future__ import annotations
import json
import logging
from dataclasses import asdict, dataclass
from typing import cast
import redis.asyncio as aioredis
from receiptwitness.config import settings
logger = logging.getLogger(__name__)
STREAM_KEY = "email:receipts"
CONSUMER_GROUP = "email-workers"
@dataclass
class EmailJob:
"""Payload for an email receipt processing job."""
user_id: str
sender: str
recipient: str
subject: str
body_html: str | None
body_plain: str | None
received_at: str
message_id: str # from email provider, for dedup
async def get_redis() -> aioredis.Redis:
"""Get async Redis/DragonflyDB client."""
return cast(aioredis.Redis, aioredis.from_url(settings.redis_url, decode_responses=True))
async def ensure_consumer_group(client: aioredis.Redis) -> None:
"""Create consumer group if it does not exist."""
try:
await client.xgroup_create(STREAM_KEY, CONSUMER_GROUP, id="0", mkstream=True)
except aioredis.ResponseError as e:
if "BUSYGROUP" not in str(e):
raise
async def enqueue_email(client: aioredis.Redis, job: EmailJob) -> str:
"""Add email job to the stream. Returns the stream message ID."""
payload: dict[str, str | bytes | int | float] = {"data": json.dumps(asdict(job))}
msg_id: str = cast(str, await client.xadd(STREAM_KEY, payload)) # type: ignore[arg-type] # redis-py StreamCommands.xadd expects broader FieldT union; runtime behavior is correct
logger.info("Enqueued email job %s for user %s", msg_id, job.user_id)
return msg_id
async def consume_emails(
client: aioredis.Redis,
consumer_name: str,
count: int = 1,
block_ms: int = 5000,
) -> list[tuple[str, EmailJob]]:
"""Read pending messages from the stream. Returns list of (msg_id, EmailJob)."""
await ensure_consumer_group(client)
messages = await client.xreadgroup(
CONSUMER_GROUP, consumer_name, {STREAM_KEY: ">"}, count=count, block=block_ms
)
results = []
for _stream, entries in messages:
for msg_id, fields in entries:
job = EmailJob(**json.loads(fields["data"]))
results.append((msg_id, job))
return results
async def ack_email(client: aioredis.Redis, msg_id: str) -> None:
"""Acknowledge a processed message."""
await client.xack(STREAM_KEY, CONSUMER_GROUP, msg_id)
@@ -1 +0,0 @@
"""Async email receipt worker consuming from DragonflyDB Streams."""
@@ -1,104 +0,0 @@
"""Async worker that consumes email receipt jobs from DragonflyDB Streams."""
import asyncio
import logging
from cartsnitch_common.database import get_async_session_factory
from cartsnitch_common.models.user import User
from sqlalchemy import select
from receiptwitness.config import settings
from receiptwitness.events import publish_receipt_ingested
from receiptwitness.parsers.email.base import BaseEmailParser, EmailReceipt
from receiptwitness.parsers.email.detector import detect_retailer
from receiptwitness.parsers.email.kroger import KrogerEmailParser
from receiptwitness.parsers.email.meijer import MeijerEmailParser
from receiptwitness.parsers.email.target import TargetEmailParser
from receiptwitness.queue.email import ack_email, consume_emails, get_redis
logger = logging.getLogger(__name__)
CONSUMER_NAME = "worker-1"
# Registry of available email parsers
PARSERS: dict[str, BaseEmailParser] = {
"meijer": MeijerEmailParser(),
"kroger": KrogerEmailParser(),
"target": TargetEmailParser(),
}
async def resolve_user(token: str) -> str | None:
"""Look up user_id from email_inbound_token."""
session_factory = get_async_session_factory(settings.database_url)
async with session_factory() as session:
result = await session.execute(select(User.id).where(User.email_inbound_token == token))
row = result.scalar_one_or_none()
return str(row) if row else None
async def process_job(msg_id: str, job) -> bool:
"""Process a single email job. Returns True on success."""
# 1. Resolve user from token
user_id = await resolve_user(job.user_id) # user_id field holds token
if not user_id:
logger.warning("Unknown token %s, dropping message %s", job.user_id, msg_id)
return True # ack to avoid infinite retry
# 2. Build EmailReceipt
email = EmailReceipt(
sender=job.sender,
recipient=job.recipient,
subject=job.subject,
body_html=job.body_html,
body_plain=job.body_plain,
received_at=job.received_at,
)
# 3. Detect retailer
retailer = detect_retailer(email)
if not retailer or retailer not in PARSERS:
logger.warning(
"Unrecognized retailer from %s, archiving msg %s",
job.sender,
msg_id,
)
return True # ack — no parser available
# 4. Parse
parser = PARSERS[retailer]
parsed = parser.parse(email)
# 5. Publish event
await publish_receipt_ingested(
user_id=user_id,
store_slug=retailer,
purchase_id=parsed.get("receipt_id", msg_id),
purchase_date=parsed.get("purchase_date", ""),
item_count=len(parsed.get("items", [])),
total=parsed.get("total", 0),
)
return True
async def run_worker() -> None:
"""Main worker loop — consume and process email jobs."""
client = await get_redis()
logger.info("Email worker started, consuming from email:receipts")
while True:
try:
jobs = await consume_emails(client, CONSUMER_NAME, count=5, block_ms=5000)
for msg_id, job in jobs:
try:
success = await process_job(msg_id, job)
if success:
await ack_email(client, msg_id)
except Exception:
logger.exception("Failed to process email job %s", msg_id)
except Exception:
logger.exception("Worker loop error, retrying in 5s")
await asyncio.sleep(5)
if __name__ == "__main__":
asyncio.run(run_worker())
-4
View File
@@ -1,16 +1,12 @@
"""Shared test fixtures."""
import json
import os
from pathlib import Path
import pytest
FIXTURES_DIR = Path(__file__).parent / "fixtures"
os.environ.setdefault("RW_SESSION_ENCRYPTION_KEY", "test-secret-key-for-unit-tests-only-32bytes!")
os.environ.setdefault("RW_MAILGUN_WEBHOOK_SIGNING_KEY", "test-mailgun-signing-key")
@pytest.fixture
def meijer_receipt_data() -> dict:
-45
View File
@@ -1,45 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Kroger Digital Receipt</title>
</head>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
<div style="background-color: #0057a8; color: white; padding: 20px; text-align: center;">
<img src="https://www.kroger.com/email-logo.png" alt="Kroger" style="height: 40px;">
<h1 style="margin: 10px 0; font-size: 24px;">Your Digital Receipt</h1>
<p style="margin: 0;">Kroger Plus Member</p>
</div>
<div style="padding: 20px; background-color: #f5f5f5;">
<h2 style="color: #0057a8; margin-top: 0;">Kroger #882 - Downtown</h2>
<p style="margin: 5px 0;">123 Main Street<br>Anytown, OH 45202</p>
<p style="margin: 5px 0;"><strong>Date:</strong> 03/15/2026</p>
<p style="margin: 5px 0;"><strong>Receipt #:</strong> KR-2026-0315-4829</p>
<p style="margin: 5px 0;"><strong>Transaction #:</strong> TXN-789123456</p>
</div>
<div style="padding: 20px;">
<h3>Items Purchased</h3>
<p>Whole Milk 1 Gallon $3.99</p>
<p>Sourdough Bread $4.49</p>
<p>Free Range Eggs 12ct $5.99</p>
<p>Baby Spinach 5oz $4.29</p>
</div>
<div style="padding: 20px; background-color: #e8f4e8; border-left: 4px solid #0057a8;">
<p style="margin: 5px 0;"><strong>Subtotal:</strong> $18.76</p>
<p style="margin: 5px 0;"><strong>Tax:</strong> $1.24</p>
<p style="margin: 5px 0; color: #0057a8; font-weight: bold; font-size: 18px;">Total: $20.00</p>
</div>
<div style="padding: 15px; margin-top: 15px; background-color: #fff8e1; border-left: 4px solid #ffc107;">
<p style="margin: 0; font-size: 14px; color: #666;">Kroger Plus Savings: <strong>$3.25</strong> saved on this order.</p>
</div>
<div style="padding: 20px; text-align: center; color: #999; font-size: 12px; margin-top: 20px;">
<p>Thank you for shopping at Kroger!</p>
<p>Keep your receipt for returns within 90 days.</p>
</div>
</body>
</html>
-127
View File
@@ -1,127 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Meijer Digital Receipt</title>
<style>
body { font-family: Arial, sans-serif; background: #f4f4f4; margin: 0; padding: 20px; }
.receipt-container { background: #ffffff; max-width: 600px; margin: 0 auto; padding: 30px; border: 1px solid #dddddd; }
.header { background: #003399; color: #ffffff; padding: 20px; text-align: center; margin: -30px -30px 20px -30px; }
.header h1 { margin: 0; font-size: 24px; }
.store-info { text-align: center; margin-bottom: 20px; border-bottom: 2px dashed #cccccc; padding-bottom: 15px; }
.store-info h2 { margin: 0; font-size: 18px; color: #003399; }
.receipt-meta { display: flex; justify-content: space-between; font-size: 14px; color: #555555; margin-bottom: 20px; }
table { width: 100%; border-collapse: collapse; margin-bottom: 20px; }
th { background: #f0f0f0; text-align: left; padding: 8px 10px; font-size: 13px; color: #333333; }
td { padding: 8px 10px; border-bottom: 1px solid #eeeeee; font-size: 14px; }
.item-name { font-weight: bold; }
.totals { margin-left: auto; width: 250px; }
.totals-row { display: flex; justify-content: space-between; padding: 6px 0; font-size: 14px; }
.totals-row.grand-total { font-weight: bold; font-size: 16px; border-top: 2px solid #333333; padding-top: 10px; margin-top: 4px; }
.savings { color: #cc0000; }
.footer { text-align: center; font-size: 12px; color: #888888; margin-top: 20px; padding-top: 15px; border-top: 1px solid #dddddd; }
</style>
</head>
<body>
<div class="receipt-container">
<div class="header">
<h1>MEIJER</h1>
<p style="margin: 5px 0 0; font-size: 14px;">Digital Receipt</p>
</div>
<div class="store-info">
<h2>Meijer Store #42</h2>
<p style="margin: 5px 0 0; font-size: 13px; color: #666;">1555 Lake Drive SE, Grand Rapids, MI 49506</p>
</div>
<div class="receipt-meta">
<div>
<strong>Date:</strong> March 15, 2026<br />
<strong>Time:</strong> 2:34 PM
</div>
<div style="text-align: right;">
<strong>Transaction #</strong><br />
TXN-2026-0315-0042
</div>
</div>
<table>
<thead>
<tr>
<th>Item</th>
<th style="text-align: center;">Qty</th>
<th style="text-align: right;">Price</th>
</tr>
</thead>
<tbody>
<tr>
<td class="item-name">ORGANIC BANANAS</td>
<td style="text-align: center;">1</td>
<td style="text-align: right;">$0.69</td>
</tr>
<tr>
<td class="item-name">WHOLE MILK 1 GAL</td>
<td style="text-align: center;">1</td>
<td style="text-align: right;">$4.29</td>
</tr>
<tr>
<td class="item-name">MEIJER WHOLE GRAIN OAT CEREAL 18OZ</td>
<td style="text-align: center;">1</td>
<td style="text-align: right;">$4.99</td>
</tr>
<tr>
<td class="item-name">FRESH BROCCOLI CROWN</td>
<td style="text-align: center;">1</td>
<td style="text-align: right;">$2.49</td>
</tr>
<tr>
<td class="item-name">GROUND BEEF 85/15 1LB</td>
<td style="text-align: center;">1</td>
<td style="text-align: right;">$6.99</td>
</tr>
<tr>
<td class="item-name">SOURDOUGH BREAD</td>
<td style="text-align: center;">1</td>
<td style="text-align: right;">$3.99</td>
</tr>
<tr>
<td class="item-name">MEIJER BABY SPINACH 5OZ</td>
<td style="text-align: center;">1</td>
<td style="text-align: right;">$4.49</td>
</tr>
<tr>
<td class="item-name">LARGE EGGS DOZEN</td>
<td style="text-align: center;">1</td>
<td style="text-align: right;">$3.29</td>
</tr>
</tbody>
</table>
<div class="totals">
<div class="totals-row">
<span>Subtotal</span>
<span>$31.22</span>
</div>
<div class="totals-row">
<span>Tax</span>
<span>$2.19</span>
</div>
<div class="totals-row savings">
<span>Total Savings</span>
<span>-$3.40</span>
</div>
<div class="totals-row grand-total">
<span>Total</span>
<span>$33.41</span>
</div>
</div>
<div class="footer">
<p>Thank you for shopping at Meijer!</p>
<p>Keep your receipt for your records.<br />
Questions? Call 1-800-927-8699 or visit meijer.com</p>
</div>
</div>
</body>
</html>
-44
View File
@@ -1,44 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>Target Order Confirmation</title>
</head>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto; padding: 20px; color: #333;">
<div style="background-color: #cc0000; color: white; padding: 20px; text-align: center;">
<img src="https://assets.target.com/email-logo.png" alt="Target" style="height: 40px;">
<h1 style="margin: 10px 0; font-size: 24px;">Order Confirmation</h1>
<p style="margin: 0;">Thanks for shopping Target Circle!</p>
</div>
<div style="padding: 20px; background-color: #f5f5f5;">
<h2 style="color: #cc0000; margin-top: 0;">Target Store #1247 - Riverside</h2>
<p style="margin: 5px 0;">4500 River Road<br>Columbus, OH 43220</p>
<p style="margin: 5px 0;"><strong>Date:</strong> 03/18/2026</p>
<p style="margin: 5px 0;"><strong>Order #:</strong> TGT-2026-0318-9124</p>
<p style="margin: 5px 0;"><strong>Confirmation #:</strong> CNF-44772819</p>
</div>
<div style="padding: 20px;">
<h3>Items Purchased</h3>
<p>Good & Gather Whole Milk 1 Gal $3.89</p>
<p>Arborio Rice 2lb bag $6.49</p>
<p>Parmesan Wedge 8oz $7.99</p>
</div>
<div style="padding: 20px; background-color: #fff8e1; border-left: 4px solid #cc0000;">
<p style="margin: 5px 0;"><strong>Subtotal:</strong> $18.37</p>
<p style="margin: 5px 0;"><strong>Tax:</strong> $1.45</p>
<p style="margin: 5px 0; color: #cc0000; font-weight: bold; font-size: 18px;">Total: $19.82</p>
</div>
<div style="padding: 15px; margin-top: 15px; background-color: #e8f4e8; border-left: 4px solid #4caf50;">
<p style="margin: 0; font-size: 14px; color: #333;">Target Circle offer saved you <strong>$0.30</strong> on this order.</p>
</div>
<div style="padding: 20px; text-align: center; color: #999; font-size: 12px; margin-top: 20px;">
<p>Questions? Call Target Guest Services at 1-800-591-3869.</p>
<p>Receipt valid for returns within 30 days.</p>
</div>
</body>
</html>
@@ -1 +0,0 @@
"""Tests for the ReceiptWitness API routes."""
@@ -1,125 +0,0 @@
"""Tests for the /inbound/email webhook endpoint."""
import hashlib
import hmac
import time
from unittest.mock import AsyncMock, patch
import pytest
from fastapi.testclient import TestClient
from receiptwitness.main import app
@pytest.fixture
def client():
return TestClient(app)
@pytest.fixture
def mock_redis():
redis_mock = AsyncMock()
with patch("receiptwitness.api.routes.get_redis", return_value=redis_mock):
enqueue_patcher = patch("receiptwitness.api.routes.enqueue_email", new_callable=AsyncMock)
with enqueue_patcher as mock_enqueue:
yield {"redis": redis_mock, "enqueue": mock_enqueue}
def make_signature(signing_key: str, token: str, timestamp: str) -> str:
return hmac.new(
signing_key.encode(),
f"{timestamp}{token}".encode(),
hashlib.sha256,
).hexdigest()
def valid_form(signing_key: str = "test-secret"):
ts = str(int(time.time()))
token = "test-token"
sig = make_signature(signing_key, token, ts)
return {
"token": token,
"timestamp": ts,
"signature": sig,
"sender": "sender@example.com",
"recipient": "receipts+user123@example.com",
"subject": "Your Meijer Receipt",
"body-html": "<p>Thank you for shopping at Meijer</p>",
"body-plain": "Thank you for shopping at Meijer",
"Message-Id": "<msg-001@example.com>",
}
def test_valid_webhook(client, mock_redis):
with patch("receiptwitness.api.routes.settings") as mock_settings:
mock_settings.mailgun_webhook_signing_key = "test-secret"
response = client.post("/inbound/email", data=valid_form())
assert response.status_code == 200
assert response.json() == {"status": "queued"}
mock_redis["enqueue"].assert_awaited_once()
def test_invalid_signature(client, mock_redis):
with patch("receiptwitness.api.routes.settings") as mock_settings:
mock_settings.mailgun_webhook_signing_key = "test-secret"
form = valid_form()
form["signature"] = "wrong-signature"
response = client.post("/inbound/email", data=form)
assert response.status_code == 406
assert response.json()["detail"] == "Invalid signature"
mock_redis["enqueue"].assert_not_awaited()
def test_invalid_recipient_no_plus(client, mock_redis):
with patch("receiptwitness.api.routes.settings") as mock_settings:
mock_settings.mailgun_webhook_signing_key = "test-secret"
form = valid_form()
form["recipient"] = "receipts@example.com" # no plus-address
response = client.post("/inbound/email", data=form)
assert response.status_code == 406
assert response.json()["detail"] == "Invalid recipient"
mock_redis["enqueue"].assert_not_awaited()
def test_stale_timestamp(client, mock_redis):
with patch("receiptwitness.api.routes.settings") as mock_settings:
mock_settings.mailgun_webhook_signing_key = "test-secret"
ts = str(int(time.time()) - 600) # 10 min old
token = "test-token"
sig = make_signature("test-secret", token, ts)
form = {
"token": token,
"timestamp": ts,
"signature": sig,
"sender": "sender@example.com",
"recipient": "receipts+user123@example.com",
"subject": "Receipt",
}
response = client.post("/inbound/email", data=form)
assert response.status_code == 406
assert response.json()["detail"] == "Invalid signature"
mock_redis["enqueue"].assert_not_awaited()
def test_invalid_timestamp_returns_406(client, mock_redis):
"""Empty timestamp should return 406, not 500."""
with patch("receiptwitness.api.routes.settings") as mock_settings:
mock_settings.mailgun_webhook_signing_key = "test-secret"
form = {
"token": "test-token",
"timestamp": "",
"signature": "any-sig",
"sender": "sender@example.com",
"recipient": "receipts+user123@example.com",
"subject": "Receipt",
}
response = client.post("/inbound/email", data=form)
assert response.status_code == 406
assert response.json()["detail"] == "Invalid signature"
mock_redis["enqueue"].assert_not_awaited()
def test_get_inbound_email_returns_405(client):
"""GET /inbound/email is not allowed."""
response = client.get("/inbound/email")
assert response.status_code == 405
-46
View File
@@ -1,46 +0,0 @@
import pytest
from receiptwitness.config import ReceiptWitnessSettings
def test_valid_config():
s = ReceiptWitnessSettings(
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
)
assert s.session_encryption_key
def test_missing_session_encryption_key_raises():
with pytest.raises(ValueError, match="RW_SESSION_ENCRYPTION_KEY"):
ReceiptWitnessSettings(session_encryption_key="")
def test_placeholder_session_encryption_key_raises():
with pytest.raises(ValueError, match="RW_SESSION_ENCRYPTION_KEY"):
ReceiptWitnessSettings(session_encryption_key="change-me-in-production")
def test_notifications_enabled_without_resend_key_raises():
with pytest.raises(ValueError, match="RW_RESEND_API_KEY"):
ReceiptWitnessSettings(
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=",
notifications_enabled=True,
resend_api_key="",
)
def test_notifications_disabled_without_resend_key_ok():
s = ReceiptWitnessSettings(
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=",
notifications_enabled=False,
resend_api_key="",
)
assert s.notifications_enabled is False
def test_notifications_enabled_with_resend_key_ok():
s = ReceiptWitnessSettings(
session_encryption_key="7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8=",
notifications_enabled=True,
resend_api_key="re_test_1234567890",
)
assert s.resend_api_key == "re_test_1234567890"
@@ -1,84 +0,0 @@
"""Tests for email notifications."""
from unittest.mock import patch
import pytest
class TestSendReceiptNotification:
@pytest.fixture
def mock_resend(self):
with patch("receiptwitness.notifications.email.resend") as mock:
yield mock
@pytest.mark.asyncio
async def test_sends_email_with_correct_params(self, mock_resend):
from receiptwitness.notifications.email import send_receipt_notification
with (
patch("receiptwitness.notifications.email.settings") as mock_settings,
patch(
"receiptwitness.notifications.email.asyncio.to_thread",
new=lambda fn, *args, **kwargs: fn(*args, **kwargs),
),
):
mock_settings.notifications_enabled = True
mock_settings.resend_api_key = "re_testkey_123"
mock_settings.notification_email_from = "noreply@test.com"
await send_receipt_notification(
user_email="user@example.com",
store_name="Meijer",
item_count=5,
total=42.99,
purchase_date="2026-03-28",
)
mock_resend.Emails.send.assert_called_once_with(
{
"from": "noreply@test.com",
"to": ["user@example.com"],
"subject": "Receipt processed: Meijer - $42.99",
"html": (
"<p>Your receipt from <strong>Meijer</strong> on "
"2026-03-28 has been processed.</p>"
"<p>5 items, total: $42.99</p>"
),
}
)
@pytest.mark.asyncio
async def test_skips_when_disabled(self, mock_resend):
from receiptwitness.notifications.email import send_receipt_notification
with patch("receiptwitness.notifications.email.settings") as mock_settings:
mock_settings.notifications_enabled = False
mock_settings.resend_api_key = "re_testkey_123"
await send_receipt_notification(
user_email="user@example.com",
store_name="Meijer",
item_count=5,
total=42.99,
purchase_date="2026-03-28",
)
mock_resend.Emails.send.assert_not_called()
@pytest.mark.asyncio
async def test_skips_when_api_key_empty(self, mock_resend):
from receiptwitness.notifications.email import send_receipt_notification
with patch("receiptwitness.notifications.email.settings") as mock_settings:
mock_settings.notifications_enabled = True
mock_settings.resend_api_key = ""
await send_receipt_notification(
user_email="user@example.com",
store_name="Meijer",
item_count=5,
total=42.99,
purchase_date="2026-03-28",
)
mock_resend.Emails.send.assert_not_called()
@@ -1,49 +0,0 @@
"""Tests for retailer detector."""
from receiptwitness.parsers.email.base import EmailReceipt
from receiptwitness.parsers.email.detector import detect_retailer
def test_detect_meijer():
email = EmailReceipt(
sender="receipts@meijer.com",
recipient="user@example.com",
subject="Your Receipt",
)
assert detect_retailer(email) == "meijer"
def test_detect_kroger():
email = EmailReceipt(
sender="noreply@email.kroger.com",
recipient="user@example.com",
subject="Your Receipt",
)
assert detect_retailer(email) == "kroger"
def test_detect_target():
email = EmailReceipt(
sender="Target <receipts@target.com>",
recipient="user@example.com",
subject="Your Receipt",
)
assert detect_retailer(email) == "target"
def test_detect_unknown():
email = EmailReceipt(
sender="noreply@walmart.com",
recipient="user@example.com",
subject="Your Receipt",
)
assert detect_retailer(email) is None
def test_detect_case_insensitive():
email = EmailReceipt(
sender="Receipts@MEIJER.COM",
recipient="user@example.com",
subject="Your Receipt",
)
assert detect_retailer(email) == "meijer"
@@ -1,93 +0,0 @@
"""Tests for KrogerEmailParser."""
from pathlib import Path
from receiptwitness.parsers.email.base import EmailReceipt
from receiptwitness.parsers.email.kroger import KrogerEmailParser
FIXTURE_PATH = Path(__file__).parent.parent.parent / "fixtures" / "kroger_email_receipt.html"
class TestKrogerEmailParser:
"""Tests for KrogerEmailParser."""
def setup_method(self) -> None:
self.parser = KrogerEmailParser()
self.fixture_html = FIXTURE_PATH.read_text()
def test_can_parse_kroger_sender(self) -> None:
email = EmailReceipt(
sender="noreply@email.kroger.com",
recipient="user@example.com",
subject="Your Kroger Receipt",
body_html=self.fixture_html,
)
assert self.parser.can_parse(email) is True
def test_can_parse_kroger_in_body(self) -> None:
email = EmailReceipt(
sender="someone@unknown.com",
recipient="user@example.com",
subject="Your Receipt",
body_html="<html><body>Kroger digital receipt</body></html>",
)
assert self.parser.can_parse(email) is True
def test_cannot_parse_unrelated(self) -> None:
email = EmailReceipt(
sender="noreply@walmart.com",
recipient="user@example.com",
subject="Your Receipt",
body_html="<html><body>Walmart receipt</body></html>",
)
assert self.parser.can_parse(email) is False
def test_parse_items(self) -> None:
email = EmailReceipt(
sender="noreply@kroger.com",
recipient="user@example.com",
subject="Your Kroger Receipt",
body_html=self.fixture_html,
)
result = self.parser.parse(email)
items = result.get("items", [])
assert len(items) >= 3
product_names = [item["product_name_raw"] for item in items]
assert any("Whole Milk" in name for name in product_names)
assert any("Sourdough" in name for name in product_names)
for item in items:
assert "unit_price" in item
assert "extended_price" in item
def test_parse_totals(self) -> None:
email = EmailReceipt(
sender="noreply@kroger.com",
recipient="user@example.com",
subject="Your Kroger Receipt",
body_html=self.fixture_html,
)
result = self.parser.parse(email)
total = result.get("total", 0)
assert total > 0
def test_parse_receipt_id(self) -> None:
email = EmailReceipt(
sender="noreply@kroger.com",
recipient="user@example.com",
subject="Your Kroger Receipt",
body_html=self.fixture_html,
)
result = self.parser.parse(email)
receipt_id = result.get("receipt_id", "")
assert "KR-2026" in receipt_id or "TXN" in receipt_id
def test_parse_date(self) -> None:
email = EmailReceipt(
sender="noreply@kroger.com",
recipient="user@example.com",
subject="Your Kroger Receipt",
body_html=self.fixture_html,
)
result = self.parser.parse(email)
purchase_date = result.get("purchase_date", "")
assert purchase_date == "2026-03-15"

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