Compare commits
278 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 60beb2d89e | |||
| 9120c834e4 | |||
| f96daceb0f | |||
| 0c5cce2adc | |||
| e3a0d94236 | |||
| 3f03d46ff5 | |||
| c0c4acb73f | |||
| a35c264823 | |||
| 63752fe5cb | |||
| 9ab585f336 | |||
| 78b3a71450 | |||
| 3216e6a1c2 | |||
| a66583b883 | |||
| 4a7d5131fc | |||
| 56b1ff9a36 | |||
| b660336897 | |||
| af713f422b | |||
| 55ab0b7ceb | |||
| 93a94e9777 | |||
| 1bb669f3ca | |||
| 82978f072b | |||
| 9ba745b5a9 | |||
| c13e640864 | |||
| c7b7494151 | |||
| f023480100 | |||
| 9acaf5e83a | |||
| 4e10c75fd0 | |||
| ffdc26cce5 | |||
| 2e96e8f0a7 | |||
| 88ac74e94c | |||
| 66279716ba | |||
| 15ab4ed38c | |||
| fbd77a9434 | |||
| fef5e86645 | |||
| cf39ed1dcd | |||
| 71e2978f52 | |||
| 4945ac71ae | |||
| 53ffef0ed1 | |||
| 5308923136 | |||
| bdaca519f6 | |||
| cfad4eab37 | |||
| 90e23ac592 | |||
| d8e7a416d2 | |||
| c03e599ae3 | |||
| f051e4b4af | |||
| 908ebde4c6 | |||
| c715c0e47a | |||
| a0eef27944 | |||
| c968088a3f | |||
| bb50ddc85d | |||
| bd2e8feff6 | |||
| 2b32bfdfe1 | |||
| 1e8223caeb | |||
| e1d77d7789 | |||
| 16200c5500 | |||
| 1803d09095 | |||
| 8592701382 | |||
| 17447fb5e1 | |||
| e29bad9a39 | |||
| 349b519a00 | |||
| b274fdff8e | |||
| a64dc7ab5e | |||
| 7fc524b593 | |||
| 0fb99e6c16 | |||
| a53daddb9a | |||
| 4e139dc4b6 | |||
| 3351d74058 | |||
| 1ce5d738d1 | |||
| e69b3c47be | |||
| 4c217757c3 | |||
| 121dc5724e | |||
| ee45400c7c | |||
| 1aff898545 | |||
| 6481cf03e4 | |||
| adfa34f2c2 | |||
| 37c75c3887 | |||
| ade03fdd1c | |||
| 8a0b2c03a1 | |||
| 5825174f0d | |||
| 6b75d4906f | |||
| aa893d9cc1 | |||
| 91c062130c | |||
| 68e6be1985 | |||
| 0aef2455fd | |||
| c2a0263ddd | |||
| 24f0dd0e67 | |||
| 6602b8c105 | |||
| da96ec7dc4 | |||
| 37798251be | |||
| cfea2586cb | |||
| bc5e03e7a0 | |||
| dbbc8d2e7b | |||
| ee97f64db6 | |||
| 538a5f4f4d | |||
| 1267caf43c | |||
| 015401861a | |||
| 4485bf1d5e | |||
| 9891e1aefb | |||
| f7bf767da5 | |||
| 2f1833e90d | |||
| b2725fd512 | |||
| 69ad161e36 | |||
| 5532b43e38 | |||
| 0be7ccd4b4 | |||
| 485f890df3 | |||
| 6d37cecdba | |||
| 3745f5be69 | |||
| bf3ed0ede3 | |||
| abec954320 | |||
| ec9deb515b | |||
| 3f41eb7346 | |||
| cfed9b0482 | |||
| 25edd8d5e3 | |||
| 6cbd1ef298 | |||
| bd3cb3b9ab | |||
| 94214f762e | |||
| 3bedc651c6 | |||
| 138033be9b | |||
| 562c6ef6f6 | |||
| 8ddefe82e4 | |||
| def921f115 | |||
| 43ee1c3531 | |||
| ccc8189d88 | |||
| f03d7a33c8 | |||
| 7bf0165fe4 | |||
| 86594e4a8e | |||
| ef63c47b7c | |||
| be75c7f254 | |||
| e90637c227 | |||
| c2f1a83c1d | |||
| 67e60c9ae1 | |||
| a25b673dd6 | |||
| 6f8e5a9577 | |||
| 4e003ba3d0 | |||
| 4996ff7432 | |||
| bbfa816e57 | |||
| ffc6c7960d | |||
| cf16415720 | |||
| 5904eb03a2 | |||
| 33f9e17339 | |||
| 7639be9a41 | |||
| 87b6433ff7 | |||
| ebe439ce84 | |||
| a663729121 | |||
| d7c9938f7e | |||
| 4fc7933e30 | |||
| 6e0cb93ee2 | |||
| 02434060ee | |||
| 0e4848f8b4 | |||
| bb7010f881 | |||
| 4756e1c1c5 | |||
| 73c038e406 | |||
| 02e34d65bb | |||
| a869bb42d7 | |||
| d77d1b58b8 | |||
| d86c0001eb | |||
| 5cc2bb78e9 | |||
| c9075be6e0 | |||
| 6c297b5e81 | |||
| 80004e4285 | |||
| 94f99595fc | |||
| c8de30ec6e | |||
| c1dc3e77e0 | |||
| 1af98c40ab | |||
| 1aaa8e78fd | |||
| c3bfd3560b | |||
| de2407d985 | |||
| d52fb83296 | |||
| c855575e77 | |||
| 7c45b04dce | |||
| f721918f95 | |||
| 692f42fbbb | |||
| b95f1725c7 | |||
| 70b9d1d6d6 | |||
| f36429936a | |||
| 1b418e7c6f | |||
| 0b31badbcd | |||
| eb579dcaa5 | |||
| 086868d450 | |||
| 63621df0b8 | |||
| 41e6bfdcf5 | |||
| a60859f22f | |||
| 8e8d4a4774 | |||
| e85d757cc6 | |||
| 43cb62a4d6 | |||
| f7e1574176 | |||
| ee6352a2f5 | |||
| 2f37f0501f | |||
| 4c36fd4156 | |||
| c9172f088f | |||
| ac4cba2b0d | |||
| 0c47be8ef3 | |||
| 440f92e96e | |||
| 97bbdf68a5 | |||
| 02e5bee390 | |||
| d475b3876a | |||
| 76bcc53992 | |||
| 470b615528 | |||
| f26f8f7e56 | |||
| 78b7831d43 | |||
| e45b510519 | |||
| f25044ea7e | |||
| b637fd9c11 | |||
| 983ee2c398 | |||
| 8af7b37b38 | |||
| b21a30b2e7 | |||
| 361ad3acc2 | |||
| 5e165d277e | |||
| 6828e4d0a9 | |||
| 0b9dd74f7d | |||
| 7a06f0618b | |||
| 9385463171 | |||
| b658f77f9c | |||
| 8706112be3 | |||
| 00b2b2469b | |||
| 1a464fd77d | |||
| 962e64b72a | |||
| ff91003e90 | |||
| cd733fbc7d | |||
| 1f9086f2f2 | |||
| 59407ae54a | |||
| 8659b99059 | |||
| e82ed5ac12 | |||
| 0d8ee5f386 | |||
| 09864c1a96 | |||
| 3621504c22 | |||
| 24adc7e35b | |||
| 99294ea46d | |||
| a28e9d9dd4 | |||
| d405caceca | |||
| f0d1694a1c | |||
| 6b32197ad2 | |||
| 528887a4a2 | |||
| bca46bf68e | |||
| 5d3b8fc8c2 | |||
| 6e76222b81 | |||
| 65e670a887 | |||
| 63aae4f2eb | |||
| e9bc46121f | |||
| 56d9d5ad2e | |||
| 1966b94a97 | |||
| a33b6a0c30 | |||
| c2b5ccb830 | |||
| 69e1be1560 | |||
| 43673583c1 | |||
| b7b9e987df | |||
| e6ed9d9193 | |||
| f0c60778cc | |||
| 7d31491114 | |||
| aba26b9d2f | |||
| d0cecf9686 | |||
| dfe7b42db3 | |||
| b6df3dc0cb | |||
| 6c09db5478 | |||
| 3f13cb1bf6 | |||
| d4f7194d3f | |||
| ee731c4aa3 | |||
| 98d95a661a | |||
| de120cb429 | |||
| b18cb24ec4 | |||
| 1491974aba | |||
| fe8e2567a2 | |||
| ea8dcad398 | |||
| e9eb9cf489 | |||
| 14ba9d0b82 | |||
| 6b73647689 | |||
| 4f42247bf2 | |||
| d5ee743d84 | |||
| 41380e9526 | |||
| 4c29d8a241 | |||
| 31b7c14719 | |||
| 6b6b9e7d01 | |||
| c62a151210 | |||
| 835aff3522 | |||
| 5588c1b5d8 | |||
| c5ed863ab1 | |||
| 8d0552f73f | |||
| 3a75ee7aee |
+376
-31
@@ -2,9 +2,9 @@ name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [main, dev, uat]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
branches: [main, dev, uat]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
@@ -13,11 +13,13 @@ concurrency:
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
security-events: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: cartsnitch/cartsnitch
|
||||
AUTH_IMAGE_NAME: cartsnitch/auth
|
||||
RECEIPTWITNESS_IMAGE_NAME: cartsnitch/receiptwitness
|
||||
API_IMAGE_NAME: cartsnitch/api
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
@@ -46,11 +48,62 @@ 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
|
||||
needs: [lint, test]
|
||||
if: github.event_name == 'push'
|
||||
needs: [lint, test, e2e]
|
||||
outputs:
|
||||
calver_tag: ${{ steps.calver.outputs.version }}
|
||||
sha_tag: sha-${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -73,8 +126,15 @@ jobs:
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "CalVer tag: $VERSION"
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
@@ -87,32 +147,63 @@ jobs:
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,prefix=sha-
|
||||
type=sha,prefix=sha-,format=long
|
||||
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
target: prod
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Scan frontend image for vulnerabilities
|
||||
uses: anchore/scan-action@v5
|
||||
id: scan
|
||||
env:
|
||||
GRYPE_CONFIG: .grype.yaml
|
||||
with:
|
||||
image: "${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:sha-${{ github.sha }}"
|
||||
fail-build: true
|
||||
severity-cutoff: high
|
||||
only-fixed: "true"
|
||||
output-format: sarif
|
||||
|
||||
- name: Upload frontend scan results to GitHub Security
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: ${{ steps.scan.outputs.sarif }}
|
||||
|
||||
- name: Push Docker image
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
target: prod
|
||||
cache-from: type=gha
|
||||
|
||||
- name: Create git tag
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
git tag "v${{ steps.calver.outputs.version }}"
|
||||
git push origin "v${{ steps.calver.outputs.version }}"
|
||||
|
||||
build-and-push-auth:
|
||||
build-and-push-receiptwitness:
|
||||
runs-on: runners-cartsnitch
|
||||
if: github.event_name == 'push'
|
||||
needs: [lint, test]
|
||||
outputs:
|
||||
calver_tag: ${{ steps.calver.outputs.version }}
|
||||
sha_tag: sha-${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
@@ -124,47 +215,176 @@ 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 GHCR
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (auth)
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.AUTH_IMAGE_NAME }}
|
||||
images: ${{ env.REGISTRY }}/${{ env.RECEIPTWITNESS_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,prefix=sha-
|
||||
type=sha,prefix=sha-,format=long
|
||||
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push auth Docker image
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./auth
|
||||
file: ./auth/Dockerfile
|
||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
context: .
|
||||
file: ./receiptwitness/Dockerfile
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
APT_CACHE_BUST=${{ github.run_id }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Scan receiptwitness image for vulnerabilities
|
||||
uses: anchore/scan-action@v5
|
||||
id: scan
|
||||
env:
|
||||
GRYPE_CONFIG: .grype.yaml
|
||||
with:
|
||||
image: "${{ env.REGISTRY }}/${{ env.RECEIPTWITNESS_IMAGE_NAME }}:sha-${{ github.sha }}"
|
||||
fail-build: true
|
||||
severity-cutoff: high
|
||||
only-fixed: "true"
|
||||
output-format: sarif
|
||||
|
||||
- name: Upload receiptwitness scan results to GitHub Security
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: ${{ steps.scan.outputs.sarif }}
|
||||
|
||||
- name: Push Docker image
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
file: ./receiptwitness/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
APT_CACHE_BUST=${{ github.run_id }}
|
||||
cache-from: type=gha
|
||||
|
||||
build-and-push-api:
|
||||
runs-on: runners-cartsnitch
|
||||
if: github.event_name == 'push'
|
||||
needs: [lint, test]
|
||||
outputs:
|
||||
calver_tag: ${{ steps.calver.outputs.version }}
|
||||
sha_tag: sha-${{ github.sha }}
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate CalVer tag
|
||||
id: calver
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
DATE_TAG=$(date -u +%Y.%m.%d)
|
||||
EXISTING=$(git tag -l "v${DATE_TAG}*" | sort -V | tail -1)
|
||||
if [ -z "$EXISTING" ]; then VERSION="$DATE_TAG"
|
||||
elif [ "$EXISTING" = "v${DATE_TAG}" ]; then VERSION="${DATE_TAG}.2"
|
||||
else BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//"); VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"; fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (API)
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,prefix=sha-,format=long
|
||||
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./api
|
||||
file: ./api/Dockerfile
|
||||
load: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
APT_CACHE_BUST=${{ github.run_id }}
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Scan api image for vulnerabilities
|
||||
uses: anchore/scan-action@v5
|
||||
id: scan
|
||||
env:
|
||||
GRYPE_CONFIG: .grype.yaml
|
||||
with:
|
||||
image: "${{ env.REGISTRY }}/${{ env.API_IMAGE_NAME }}:sha-${{ github.sha }}"
|
||||
fail-build: true
|
||||
severity-cutoff: high
|
||||
only-fixed: "true"
|
||||
output-format: sarif
|
||||
|
||||
- name: Upload api scan results to GitHub Security
|
||||
uses: github/codeql-action/upload-sarif@v3
|
||||
if: always()
|
||||
with:
|
||||
sarif_file: ${{ steps.scan.outputs.sarif }}
|
||||
|
||||
- name: Push Docker image
|
||||
if: github.event_name == 'push'
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: ./api
|
||||
file: ./api/Dockerfile
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
APT_CACHE_BUST=${{ github.run_id }}
|
||||
cache-from: type=gha
|
||||
|
||||
deploy-dev:
|
||||
runs-on: runners-cartsnitch
|
||||
needs: [build-and-push, build-and-push-auth]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
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')
|
||||
steps:
|
||||
- name: Generate GitHub App token
|
||||
id: app-token
|
||||
@@ -189,11 +409,50 @@ jobs:
|
||||
- name: Install kustomize
|
||||
uses: imranismail/setup-kustomize@v2
|
||||
|
||||
- name: Update dev overlay image tag
|
||||
- name: Determine image tag for frontend
|
||||
id: frontend_tag
|
||||
run: |
|
||||
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
|
||||
echo "tag=${{ needs.build-and-push.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "tag=${{ needs.build-and-push.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Update frontend image tag
|
||||
if: needs.build-and-push.result == 'success'
|
||||
run: |
|
||||
cd infra/apps/overlays/dev
|
||||
kustomize edit set image ghcr.io/cartsnitch/cartsnitch:${{ needs.build-and-push.outputs.calver_tag }}
|
||||
kustomize edit set image ghcr.io/cartsnitch/auth:${{ needs.build-and-push-auth.outputs.calver_tag }}
|
||||
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 }}
|
||||
|
||||
- name: Commit and push to infra
|
||||
run: |
|
||||
@@ -201,5 +460,91 @@ 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 commit -m "ci(dev): update cartsnitch and auth images to ${{ needs.build-and-push.outputs.calver_tag }}"
|
||||
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 push origin main
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
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
|
||||
+4
-1
@@ -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,6 +11,9 @@ 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
|
||||
|
||||
+8
-2
@@ -1,6 +1,7 @@
|
||||
FROM python:3.12-slim AS build
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ARG APT_CACHE_BUST=0
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
|
||||
libpq-dev \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
@@ -12,10 +13,15 @@ RUN pip install --no-cache-dir --prefix=/install .
|
||||
|
||||
FROM python:3.12-slim AS prod
|
||||
|
||||
ARG APT_CACHE_BUST=0
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends libpq5 && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
RUN adduser --system --group --uid 1000 app
|
||||
COPY --from=build /install /usr/local
|
||||
COPY src/ ./src/
|
||||
COPY alembic.ini ./
|
||||
COPY alembic/ ./alembic/
|
||||
|
||||
USER 1000
|
||||
EXPOSE 8000
|
||||
@@ -23,4 +29,4 @@ EXPOSE 8000
|
||||
HEALTHCHECK --interval=30s --timeout=3s \
|
||||
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"
|
||||
|
||||
CMD ["uvicorn", "cartsnitch_api.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
CMD ["sh", "-c", "python -m alembic upgrade head && uvicorn cartsnitch_api.main:app --host 0.0.0.0 --port 8000"]
|
||||
|
||||
+14
-2
@@ -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)
|
||||
config.set_main_option("sqlalchemy.url", db_url.replace("%", "%%"))
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
@@ -31,6 +31,7 @@ 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()
|
||||
@@ -44,9 +45,20 @@ def run_migrations_online() -> None:
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata)
|
||||
context.configure(connection=connection, target_metadata=target_metadata, version_table_column_width=128)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
# Create any tables defined in models but not yet created by migrations.
|
||||
# This bootstraps fresh databases that have no legacy schema.
|
||||
# checkfirst=True ensures this is a no-op on existing databases.
|
||||
try:
|
||||
Base.metadata.create_all(bind=connection, checkfirst=True)
|
||||
connection.commit()
|
||||
except Exception as exc:
|
||||
import logging
|
||||
logging.getLogger("alembic.env").warning(
|
||||
"create_all failed (non-fatal, migrations should handle table creation): %s", exc
|
||||
)
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
|
||||
@@ -33,6 +33,21 @@ 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",
|
||||
@@ -43,7 +58,6 @@ 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()
|
||||
|
||||
@@ -21,81 +21,94 @@ depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
inspector = sa.inspect(conn)
|
||||
|
||||
# --- Extend users table for Better-Auth compatibility ---
|
||||
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))
|
||||
# Guard: on a fresh DB Base.metadata.create_all (called in env.py after migrations)
|
||||
# creates the users table with all columns, so migration 002 must not re-run add_column.
|
||||
if inspector.has_table("users"):
|
||||
existing_user_cols = [c["name"] for c in inspector.get_columns("users")]
|
||||
if "email_verified" not in existing_user_cols:
|
||||
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"))
|
||||
if "image" not in existing_user_cols:
|
||||
op.add_column("users", sa.Column("image", sa.Text(), nullable=True))
|
||||
|
||||
# --- Create sessions table ---
|
||||
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"])
|
||||
if not inspector.has_table("sessions"):
|
||||
op.create_table(
|
||||
"sessions",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("token", sa.Text(), nullable=False),
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("ip_address", sa.Text(), nullable=True),
|
||||
sa.Column("user_agent", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_sessions_token", "sessions", ["token"], unique=True)
|
||||
op.create_index("ix_sessions_user_id", "sessions", ["user_id"])
|
||||
|
||||
# --- Create accounts table ---
|
||||
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"])
|
||||
if not inspector.has_table("accounts"):
|
||||
op.create_table(
|
||||
"accounts",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("user_id", sa.Text(), nullable=False),
|
||||
sa.Column("account_id", sa.Text(), nullable=False),
|
||||
sa.Column("provider_id", sa.Text(), nullable=False),
|
||||
sa.Column("access_token", sa.Text(), nullable=True),
|
||||
sa.Column("refresh_token", sa.Text(), nullable=True),
|
||||
sa.Column("access_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("refresh_token_expires_at", sa.DateTime(timezone=True), nullable=True),
|
||||
sa.Column("scope", sa.Text(), nullable=True),
|
||||
sa.Column("id_token", sa.Text(), nullable=True),
|
||||
sa.Column("password", sa.Text(), nullable=True),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
op.create_index("ix_accounts_user_id", "accounts", ["user_id"])
|
||||
|
||||
# --- Create verifications table ---
|
||||
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"),
|
||||
)
|
||||
if not inspector.has_table("verifications"):
|
||||
op.create_table(
|
||||
"verifications",
|
||||
sa.Column("id", sa.Text(), nullable=False),
|
||||
sa.Column("identifier", sa.Text(), nullable=False),
|
||||
sa.Column("value", sa.Text(), nullable=False),
|
||||
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
||||
sa.PrimaryKeyConstraint("id"),
|
||||
)
|
||||
|
||||
# --- Migrate existing password hashes to accounts table ---
|
||||
# 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()
|
||||
# Only run on existing (non-fresh) DBs that already have users table with data
|
||||
if inspector.has_table("users"):
|
||||
users = conn.execute(
|
||||
text("SELECT id, hashed_password FROM users WHERE hashed_password IS NOT NULL")
|
||||
).fetchall()
|
||||
|
||||
for user_id, hashed_password in users:
|
||||
user_id_str = str(user_id)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
|
||||
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
|
||||
),
|
||||
{"user_id": user_id_str, "account_id": user_id_str, "password": hashed_password},
|
||||
)
|
||||
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.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")
|
||||
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"))
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
"""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)
|
||||
@@ -0,0 +1,136 @@
|
||||
"""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"
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
"""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"))
|
||||
@@ -0,0 +1,210 @@
|
||||
"""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")
|
||||
@@ -0,0 +1,38 @@
|
||||
"""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(),
|
||||
)
|
||||
@@ -5,8 +5,6 @@ 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
|
||||
@@ -21,12 +19,15 @@ 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) -> UUID:
|
||||
async def _validate_session_token(token: str, db: AsyncSession) -> str:
|
||||
"""Validate a Better-Auth session token against the sessions table.
|
||||
|
||||
Returns the user_id (as UUID) if the session is valid and not expired.
|
||||
Better-Auth stores the raw token in the DB. The cookie/Bearer header
|
||||
carries the same raw token, so we compare directly.
|
||||
"""
|
||||
result = await db.execute(
|
||||
text("SELECT user_id, expires_at FROM sessions WHERE token = :token"),
|
||||
@@ -51,14 +52,14 @@ async def _validate_session_token(token: str, db: AsyncSession) -> UUID:
|
||||
detail="Session expired",
|
||||
)
|
||||
|
||||
return UUID(str(user_id))
|
||||
return str(user_id)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> UUID:
|
||||
) -> str:
|
||||
"""Extract and validate the session token from cookie or Authorization header.
|
||||
|
||||
Checks in order:
|
||||
@@ -67,14 +68,19 @@ async def get_current_user(
|
||||
"""
|
||||
token: str | None = None
|
||||
|
||||
# 1. Check session cookie
|
||||
cookie_token = request.cookies.get(SESSION_COOKIE_NAME)
|
||||
# 1. Check session cookie — prefer __Secure- variant (HTTPS) over plain (HTTP dev)
|
||||
cookie_token = request.cookies.get(SECURE_SESSION_COOKIE_NAME) or request.cookies.get(
|
||||
SESSION_COOKIE_NAME
|
||||
)
|
||||
if cookie_token:
|
||||
token = cookie_token
|
||||
# Better-Auth cookie format is "token.sessionId" — extract just the token part
|
||||
token = cookie_token.split(".")[0] if "." in cookie_token else cookie_token
|
||||
|
||||
# 2. Fall back to Bearer header
|
||||
if not token and credentials:
|
||||
token = credentials.credentials
|
||||
# Callers might pass the compound value here too
|
||||
raw = credentials.credentials
|
||||
token = raw.split(".")[0] if "." in raw else raw
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
@@ -82,7 +88,9 @@ async def get_current_user(
|
||||
detail="Authentication required",
|
||||
)
|
||||
|
||||
return await _validate_session_token(token, db)
|
||||
user_id = await _validate_session_token(token, db)
|
||||
request.state.user_id = user_id
|
||||
return user_id
|
||||
|
||||
|
||||
async def verify_service_key(x_service_key: str = Header()) -> None:
|
||||
|
||||
@@ -5,13 +5,14 @@ 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,
|
||||
@@ -23,7 +24,7 @@ router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
@router.get("/me", response_model=UserResponse)
|
||||
async def get_me(
|
||||
user_id: UUID = Depends(get_current_user),
|
||||
user_id: str = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = AuthService(db)
|
||||
@@ -38,7 +39,7 @@ async def get_me(
|
||||
@router.patch("/me", response_model=UserResponse)
|
||||
async def update_me(
|
||||
body: UpdateUserRequest,
|
||||
user_id: UUID = Depends(get_current_user),
|
||||
user_id: str = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = AuthService(db)
|
||||
@@ -54,7 +55,7 @@ async def update_me(
|
||||
|
||||
@router.delete("/me", status_code=status.HTTP_204_NO_CONTENT)
|
||||
async def delete_me(
|
||||
user_id: UUID = Depends(get_current_user),
|
||||
user_id: str = Depends(get_current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = AuthService(db)
|
||||
|
||||
@@ -1,26 +1,76 @@
|
||||
"""Redis/DragonflyDB caching helpers."""
|
||||
|
||||
import redis.asyncio as redis
|
||||
|
||||
from cartsnitch_api.config import settings
|
||||
|
||||
|
||||
class CacheClient:
|
||||
"""Stub for Redis/DragonflyDB caching.
|
||||
"""Redis/DragonflyDB caching with connection pooling.
|
||||
|
||||
Will be used for expensive queries: price trends, product comparisons.
|
||||
Cache invalidation via Redis pub/sub events from other services.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.url = settings.redis_url
|
||||
self._pool: redis.ConnectionPool | None = None
|
||||
self._client: redis.Redis | None = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the Redis connection pool."""
|
||||
self._pool = redis.ConnectionPool.from_url(
|
||||
settings.redis_url,
|
||||
max_connections=20,
|
||||
decode_responses=True,
|
||||
)
|
||||
self._client = redis.Redis(connection_pool=self._pool)
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the Redis connection pool."""
|
||||
if self._client:
|
||||
await self._client.aclose()
|
||||
if self._pool:
|
||||
await self._pool.aclose()
|
||||
|
||||
async def get(self, key: str) -> str | None:
|
||||
# TODO: implement with redis-py async
|
||||
return None
|
||||
if not self._client:
|
||||
return None
|
||||
return await self._client.get(key)
|
||||
|
||||
async def set(self, key: str, value: str, ttl_seconds: int = 300) -> None:
|
||||
# TODO: implement with redis-py async
|
||||
pass
|
||||
if not self._client:
|
||||
return
|
||||
await self._client.set(key, value, ex=ttl_seconds)
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
# TODO: implement with redis-py async
|
||||
pass
|
||||
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()
|
||||
|
||||
@@ -1,23 +1,25 @@
|
||||
import base64
|
||||
|
||||
from pydantic import model_validator
|
||||
from pydantic import AliasChoices, Field, model_validator
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = {"env_prefix": "CARTSNITCH_"}
|
||||
|
||||
database_url: str = "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
|
||||
database_url: str = Field(
|
||||
default="postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
|
||||
validation_alias=AliasChoices("CARTSNITCH_DATABASE_URL", "DATABASE_URL"),
|
||||
)
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
|
||||
jwt_secret_key: str = "change-me-in-production"
|
||||
jwt_secret_key: str
|
||||
jwt_algorithm: str = "HS256"
|
||||
jwt_access_token_expire_minutes: int = 15
|
||||
jwt_refresh_token_expire_days: int = 7
|
||||
|
||||
service_key: str = "change-me-in-production"
|
||||
# Valid Fernet key for local dev — MUST be overridden in production
|
||||
fernet_key: str = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
|
||||
service_key: str
|
||||
fernet_key: str
|
||||
|
||||
auth_service_url: str = "http://auth:3001"
|
||||
|
||||
@@ -30,11 +32,31 @@ 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_fernet_key(self):
|
||||
"""Validate fernet_key is a valid 32-byte url-safe base64 key at startup."""
|
||||
def validate_secrets(self):
|
||||
if not self.jwt_secret_key or self.jwt_secret_key in self._PLACEHOLDER_VALUES:
|
||||
raise ValueError(
|
||||
"CARTSNITCH_JWT_SECRET_KEY must be set to a secure value. "
|
||||
'Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
||||
)
|
||||
if not self.service_key or self.service_key in self._PLACEHOLDER_VALUES:
|
||||
raise ValueError(
|
||||
"CARTSNITCH_SERVICE_KEY must be set to a secure value. "
|
||||
'Generate one with: python -c "import secrets; print(secrets.token_urlsafe(32))"'
|
||||
)
|
||||
if not self.fernet_key or self.fernet_key in self._PLACEHOLDER_VALUES:
|
||||
raise ValueError(
|
||||
"CARTSNITCH_FERNET_KEY must be set to a valid Fernet key. "
|
||||
"Generate one with: python -c "
|
||||
"'from cryptography.fernet import Fernet; print(Fernet.generate_key().decode())'"
|
||||
)
|
||||
try:
|
||||
decoded = base64.urlsafe_b64decode(self.fernet_key.encode())
|
||||
if len(decoded) != 32:
|
||||
@@ -49,5 +71,14 @@ 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()
|
||||
|
||||
@@ -6,7 +6,14 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
|
||||
|
||||
from cartsnitch_api.config import settings
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=False)
|
||||
engine = create_async_engine(
|
||||
settings.database_url,
|
||||
echo=False,
|
||||
pool_size=10,
|
||||
max_overflow=20,
|
||||
pool_pre_ping=True,
|
||||
pool_recycle=3600,
|
||||
)
|
||||
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
|
||||
|
||||
@@ -14,3 +21,8 @@ async def get_db() -> AsyncGenerator[AsyncSession, None]:
|
||||
"""FastAPI dependency that yields an async DB session."""
|
||||
async with async_session_factory() as session:
|
||||
yield session
|
||||
|
||||
|
||||
async def dispose_engine() -> None:
|
||||
"""Dispose the database engine, closing all pooled connections."""
|
||||
await engine.dispose()
|
||||
|
||||
@@ -2,12 +2,15 @@
|
||||
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import APIRouter, FastAPI
|
||||
|
||||
from cartsnitch_api.auth.routes import router as auth_router
|
||||
from cartsnitch_api.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
|
||||
@@ -18,13 +21,15 @@ 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):
|
||||
# TODO: initialize DB session pool, Redis connection, service clients
|
||||
await cache_client.initialize()
|
||||
yield
|
||||
# TODO: cleanup connections
|
||||
await cache_client.close()
|
||||
await dispose_engine()
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
@@ -39,6 +44,7 @@ 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)
|
||||
@@ -46,15 +52,20 @@ def create_app() -> FastAPI:
|
||||
# Routers
|
||||
app.include_router(health_router)
|
||||
app.include_router(auth_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)
|
||||
|
||||
# Data endpoints mounted under /api/v1
|
||||
v1_router = APIRouter(prefix="/api/v1")
|
||||
v1_router.include_router(user_router)
|
||||
v1_router.include_router(stores_router)
|
||||
v1_router.include_router(purchases_router)
|
||||
v1_router.include_router(products_router)
|
||||
v1_router.include_router(prices_router)
|
||||
v1_router.include_router(coupons_router)
|
||||
v1_router.include_router(shopping_router)
|
||||
v1_router.include_router(alerts_router)
|
||||
v1_router.include_router(scraping_router)
|
||||
v1_router.include_router(public_router)
|
||||
app.include_router(v1_router)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""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)
|
||||
@@ -11,6 +11,6 @@ def add_cors_middleware(app: FastAPI) -> None:
|
||||
CORSMiddleware,
|
||||
allow_origins=settings.cors_origins,
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
allow_methods=["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization", "Accept", "Origin", "X-Requested-With"],
|
||||
)
|
||||
|
||||
@@ -4,18 +4,32 @@ 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 _SlidingWindowCounter:
|
||||
|
||||
class RateLimitBackend(Protocol):
|
||||
"""Protocol for rate limit backends."""
|
||||
|
||||
async def is_allowed(self, key: str) -> tuple[bool, int, int]:
|
||||
"""Check if request is allowed. Returns (allowed, remaining, retry_after)."""
|
||||
|
||||
|
||||
class InMemorySlidingWindow:
|
||||
"""Thread-safe in-memory sliding window rate limiter."""
|
||||
|
||||
def __init__(self, max_requests: int, window_seconds: int) -> None:
|
||||
@@ -24,13 +38,12 @@ class _SlidingWindowCounter:
|
||||
self._hits: dict[str, list[float]] = defaultdict(list)
|
||||
self._lock = Lock()
|
||||
|
||||
def is_allowed(self, key: str) -> tuple[bool, int, int]:
|
||||
async def is_allowed(self, key: str) -> tuple[bool, int, int]:
|
||||
"""Check if request is allowed. Returns (allowed, remaining, retry_after)."""
|
||||
now = time.monotonic()
|
||||
cutoff = now - self.window_seconds
|
||||
|
||||
with self._lock:
|
||||
# Prune expired entries
|
||||
self._hits[key] = [t for t in self._hits[key] if t > cutoff]
|
||||
|
||||
current_count = len(self._hits[key])
|
||||
@@ -43,15 +56,84 @@ class _SlidingWindowCounter:
|
||||
return True, remaining, 0
|
||||
|
||||
|
||||
# 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,
|
||||
)
|
||||
class RedisSlidingWindow:
|
||||
"""Redis-backed sliding window rate limiter using sorted sets."""
|
||||
|
||||
def __init__(self, redis: Redis, max_requests: int, window_seconds: int) -> None:
|
||||
self.redis = redis
|
||||
self.max_requests = max_requests
|
||||
self.window_seconds = window_seconds
|
||||
|
||||
async def is_allowed(self, key: str) -> tuple[bool, int, int]:
|
||||
"""Check if request is allowed. Returns (allowed, remaining, retry_after)."""
|
||||
try:
|
||||
now = time.monotonic()
|
||||
cutoff = now - self.window_seconds
|
||||
now_ms = int(now * 1000)
|
||||
cutoff_ms = int(cutoff * 1000)
|
||||
|
||||
pipe = self.redis.pipeline()
|
||||
pipe.zremrangebyscore(key, 0, cutoff_ms)
|
||||
pipe.zcard(key)
|
||||
results = await pipe.execute()
|
||||
|
||||
current_count = results[1]
|
||||
|
||||
if current_count >= self.max_requests:
|
||||
oldest = await self.redis.zrange(key, 0, 0, withscores=True)
|
||||
if oldest:
|
||||
retry_after = int((oldest[0][1] - cutoff) / 1000) + 1
|
||||
else:
|
||||
retry_after = self.window_seconds
|
||||
return False, 0, retry_after
|
||||
|
||||
member = f"{now_ms}:{uuid.uuid4().hex[:8]}"
|
||||
pipe = self.redis.pipeline()
|
||||
pipe.zadd(key, {member: now_ms})
|
||||
pipe.expire(key, self.window_seconds)
|
||||
await pipe.execute()
|
||||
|
||||
remaining = self.max_requests - current_count - 1
|
||||
return True, remaining, 0
|
||||
|
||||
except RedisError as e:
|
||||
logger.warning("Redis rate limit error, falling back to in-memory: %s", e)
|
||||
in_memory = InMemorySlidingWindow(self.max_requests, self.window_seconds)
|
||||
return await in_memory.is_allowed(key)
|
||||
|
||||
|
||||
_redis_client: Redis | None = None
|
||||
_use_redis = False
|
||||
|
||||
if settings.rate_limit_redis_enabled:
|
||||
try:
|
||||
_redis_client = Redis.from_url(settings.redis_url)
|
||||
_use_redis = True
|
||||
logger.info("Rate limiting will use Redis at %s", settings.redis_url)
|
||||
except Exception as e:
|
||||
logger.warning("Failed to connect to Redis for rate limiting, using in-memory: %s", e)
|
||||
_use_redis = False
|
||||
|
||||
if _use_redis and _redis_client:
|
||||
_public_limiter = RedisSlidingWindow(
|
||||
_redis_client, settings.rate_limit_requests, settings.rate_limit_window_seconds
|
||||
)
|
||||
_auth_limiter = RedisSlidingWindow(
|
||||
_redis_client, settings.rate_limit_requests * 5, settings.rate_limit_window_seconds
|
||||
)
|
||||
_auth_strict_limiter = RedisSlidingWindow(
|
||||
_redis_client, settings.rate_limit_auth_requests, settings.rate_limit_auth_window_seconds
|
||||
)
|
||||
else:
|
||||
_public_limiter = InMemorySlidingWindow(
|
||||
settings.rate_limit_requests, settings.rate_limit_window_seconds
|
||||
)
|
||||
_auth_limiter = InMemorySlidingWindow(
|
||||
settings.rate_limit_requests * 5, settings.rate_limit_window_seconds
|
||||
)
|
||||
_auth_strict_limiter = InMemorySlidingWindow(
|
||||
settings.rate_limit_auth_requests, settings.rate_limit_auth_window_seconds
|
||||
)
|
||||
|
||||
|
||||
def _get_client_ip(request: Request) -> str:
|
||||
@@ -62,30 +144,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, _SlidingWindowCounter]:
|
||||
def _get_rate_limit_key(request: Request) -> tuple[str, RateLimitBackend]:
|
||||
"""Determine rate limit key and which limiter to use."""
|
||||
if request.url.path.startswith("/public"):
|
||||
return f"ip:{_get_client_ip(request)}", _public_limiter
|
||||
|
||||
# For authenticated endpoints, use Bearer token as key if present
|
||||
if request.url.path.startswith("/auth/") and request.method == "POST":
|
||||
return f"ip:{_get_client_ip(request)}", _auth_strict_limiter
|
||||
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header.startswith("Bearer "):
|
||||
token = auth_header[7:]
|
||||
# Use last 16 chars of token as key to avoid storing full tokens
|
||||
return f"token:{token[-16:]}", _auth_limiter
|
||||
token_hash = hashlib.sha256(token.encode()).hexdigest()
|
||||
return f"token:{token_hash}", _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 = limiter.is_allowed(key)
|
||||
allowed, remaining, retry_after = await limiter.is_allowed(key)
|
||||
|
||||
if not allowed:
|
||||
return JSONResponse(
|
||||
|
||||
@@ -32,8 +32,8 @@ class Purchase(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
|
||||
__tablename__ = "purchases"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
store_id: Mapped[str] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||
store_location_id: Mapped[uuid.UUID | None] = mapped_column(ForeignKey("store_locations.id"))
|
||||
receipt_id: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
purchase_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""User and UserStoreAccount models."""
|
||||
|
||||
import uuid
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy import Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from cartsnitch_api.constants import AccountStatus
|
||||
@@ -16,14 +17,28 @@ if TYPE_CHECKING:
|
||||
from cartsnitch_api.models.store import Store
|
||||
|
||||
|
||||
class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
class User(TimestampMixin, Base):
|
||||
"""Application user."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id: Mapped[str] = mapped_column(Text, primary_key=True)
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
display_name: Mapped[str | None] = mapped_column(String(100))
|
||||
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")
|
||||
@@ -36,8 +51,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[uuid.UUID] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
store_id: Mapped[uuid.UUID] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||
user_id: Mapped[str] = mapped_column(ForeignKey("users.id"), nullable=False)
|
||||
store_id: Mapped[str] = mapped_column(ForeignKey("stores.id"), nullable=False)
|
||||
session_data: Mapped[dict | None] = mapped_column(EncryptedJSON)
|
||||
session_expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
last_sync_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
||||
|
||||
@@ -18,10 +18,14 @@ router = APIRouter(prefix="/public", tags=["public"])
|
||||
|
||||
|
||||
@router.get("/trends/{product_id}", response_model=PublicTrendResponse)
|
||||
async def public_price_trend(product_id: UUID, db: AsyncSession = Depends(get_db)):
|
||||
async def public_price_trend(
|
||||
product_id: UUID,
|
||||
days: int = Query(90, ge=1, le=365),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = PublicService(db)
|
||||
try:
|
||||
return await svc.get_trend(product_id)
|
||||
return await svc.get_trend(product_id, days=days)
|
||||
except LookupError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND, detail="Product not found"
|
||||
@@ -31,6 +35,7 @@ async def public_price_trend(product_id: UUID, db: AsyncSession = Depends(get_db
|
||||
@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:
|
||||
@@ -39,10 +44,14 @@ async def public_store_comparison(
|
||||
detail="At least one product_id is required",
|
||||
)
|
||||
svc = PublicService(db)
|
||||
return await svc.get_store_comparison(product_ids)
|
||||
return await svc.get_store_comparison(product_ids, category=category)
|
||||
|
||||
|
||||
@router.get("/inflation", response_model=PublicInflationResponse)
|
||||
async def public_inflation(db: AsyncSession = Depends(get_db)):
|
||||
async def public_inflation(
|
||||
category: str | None = Query(None, max_length=100, pattern=r"^[a-zA-Z0-9 _-]+$"),
|
||||
period: str = Query("all-time", pattern=r"^(all-time|1y|6m|3m|1m)$"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
svc = PublicService(db)
|
||||
return await svc.get_inflation()
|
||||
return await svc.get_inflation(category=category, period=period)
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
"""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
|
||||
@@ -16,12 +16,17 @@ class UpdateUserRequest(BaseModel):
|
||||
|
||||
|
||||
class UserResponse(BaseModel):
|
||||
id: UUID
|
||||
id: str
|
||||
email: str
|
||||
display_name: str
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class EmailInAddressResponse(BaseModel):
|
||||
email_address: str
|
||||
instructions: str
|
||||
|
||||
|
||||
# ---------- Stores ----------
|
||||
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ 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
|
||||
|
||||
@@ -15,7 +13,7 @@ class AuthService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get_user(self, user_id: UUID) -> dict:
|
||||
async def get_user(self, user_id: str) -> dict:
|
||||
from cartsnitch_api.models import User
|
||||
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
@@ -30,7 +28,7 @@ class AuthService:
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
|
||||
async def update_user(self, user_id: UUID, **fields) -> dict:
|
||||
async def update_user(self, user_id: str, **fields) -> dict:
|
||||
from cartsnitch_api.models import User
|
||||
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
@@ -58,7 +56,7 @@ class AuthService:
|
||||
"created_at": user.created_at,
|
||||
}
|
||||
|
||||
async def delete_user(self, user_id: UUID) -> None:
|
||||
async def delete_user(self, user_id: str) -> None:
|
||||
from cartsnitch_api.models import User
|
||||
|
||||
result = await self.db.execute(select(User).where(User.id == user_id))
|
||||
@@ -68,3 +66,14 @@ 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"
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""Public service — unauthenticated price transparency endpoints."""
|
||||
|
||||
from datetime import date, timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from sqlalchemy import and_, func, select
|
||||
@@ -13,7 +14,7 @@ class PublicService:
|
||||
def __init__(self, db: AsyncSession) -> None:
|
||||
self.db = db
|
||||
|
||||
async def get_trend(self, product_id: UUID) -> dict:
|
||||
async def get_trend(self, product_id: UUID, days: int = 90) -> dict:
|
||||
from cartsnitch_api.models import NormalizedProduct, PriceHistory
|
||||
|
||||
result = await self.db.execute(
|
||||
@@ -23,9 +24,13 @@ 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)
|
||||
.where(
|
||||
PriceHistory.normalized_product_id == product_id,
|
||||
PriceHistory.observed_date >= date_threshold,
|
||||
)
|
||||
.options(selectinload(PriceHistory.store))
|
||||
.order_by(PriceHistory.observed_date)
|
||||
)
|
||||
@@ -45,20 +50,25 @@ class PublicService:
|
||||
],
|
||||
}
|
||||
|
||||
async def get_store_comparison(self, product_ids: list[UUID]) -> dict:
|
||||
async def get_store_comparison(
|
||||
self, product_ids: list[UUID], category: str | None = None
|
||||
) -> dict:
|
||||
from cartsnitch_api.models import NormalizedProduct, PriceHistory
|
||||
|
||||
if not product_ids:
|
||||
return {"products": []}
|
||||
|
||||
# Fetch all products in one query
|
||||
prod_result = await self.db.execute(
|
||||
select(NormalizedProduct).where(NormalizedProduct.id.in_(product_ids))
|
||||
)
|
||||
product_query = select(NormalizedProduct).where(NormalizedProduct.id.in_(product_ids))
|
||||
if category:
|
||||
product_query = product_query.where(NormalizedProduct.category == category)
|
||||
prod_result = await self.db.execute(product_query)
|
||||
products_by_id = {p.id: p for p in prod_result.scalars().all()}
|
||||
|
||||
# Latest prices for all requested products in one query
|
||||
subq = latest_price_per_store(product_ids)
|
||||
if not products_by_id:
|
||||
return {"products": []}
|
||||
|
||||
filtered_product_ids = list(products_by_id.keys())
|
||||
subq = latest_price_per_store(filtered_product_ids)
|
||||
prices_result = await self.db.execute(
|
||||
select(PriceHistory)
|
||||
.join(
|
||||
@@ -69,18 +79,17 @@ class PublicService:
|
||||
PriceHistory.normalized_product_id == subq.c.normalized_product_id,
|
||||
),
|
||||
)
|
||||
.where(PriceHistory.normalized_product_id.in_(product_ids))
|
||||
.where(PriceHistory.normalized_product_id.in_(filtered_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 product_ids:
|
||||
for pid in filtered_product_ids:
|
||||
product = products_by_id.get(pid)
|
||||
if not product:
|
||||
continue
|
||||
@@ -102,19 +111,29 @@ class PublicService:
|
||||
|
||||
return {"products": products}
|
||||
|
||||
async def get_inflation(self) -> dict:
|
||||
async def get_inflation(self, category: str | None = None, period: str = "all-time") -> dict:
|
||||
"""Aggregate price change stats. Compares average prices across periods."""
|
||||
from cartsnitch_api.models import NormalizedProduct, PriceHistory
|
||||
|
||||
# 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)
|
||||
)
|
||||
date_threshold = None
|
||||
if period != "all-time":
|
||||
days_map = {"1y": 365, "6m": 180, "3m": 90, "1m": 30}
|
||||
days = days_map.get(period, 365)
|
||||
date_threshold = date.today() - timedelta(days=days)
|
||||
|
||||
query = select(
|
||||
NormalizedProduct.category,
|
||||
func.avg(PriceHistory.regular_price),
|
||||
).join(NormalizedProduct)
|
||||
|
||||
if category:
|
||||
query = query.where(NormalizedProduct.category == category)
|
||||
if date_threshold:
|
||||
query = query.where(PriceHistory.observed_date >= date_threshold)
|
||||
|
||||
query = query.group_by(NormalizedProduct.category)
|
||||
|
||||
result = await self.db.execute(query)
|
||||
categories = {}
|
||||
for row in result.all():
|
||||
cat, avg_price = row
|
||||
@@ -122,7 +141,7 @@ class PublicService:
|
||||
categories[cat] = float(avg_price) if avg_price else 0.0
|
||||
|
||||
return {
|
||||
"period": "all-time",
|
||||
"period": period,
|
||||
"cartsnitch_index": sum(categories.values()) / max(len(categories), 1),
|
||||
"cpi_baseline": 100.0,
|
||||
"categories": categories,
|
||||
|
||||
+36
-8
@@ -19,6 +19,25 @@ 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:"
|
||||
|
||||
|
||||
@@ -60,7 +79,8 @@ 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,
|
||||
@@ -71,8 +91,10 @@ 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,
|
||||
@@ -88,8 +110,10 @@ 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,
|
||||
@@ -98,7 +122,8 @@ async def db_engine():
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
|
||||
)
|
||||
"""))
|
||||
""")
|
||||
)
|
||||
|
||||
yield engine
|
||||
|
||||
@@ -133,10 +158,13 @@ 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).
|
||||
Returns (user_dict, session_token). Better-Auth stores the raw token
|
||||
in the DB, so we insert it as-is.
|
||||
"""
|
||||
user_id = str(uuid.uuid4())
|
||||
email = user_overrides.get("email", "test@example.com")
|
||||
|
||||
@@ -71,6 +71,56 @@ 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."""
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,61 @@
|
||||
"""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
|
||||
@@ -1,47 +1,184 @@
|
||||
"""Tests for rate limiting middleware."""
|
||||
|
||||
import time
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from cartsnitch_api.middleware.rate_limit import _SlidingWindowCounter
|
||||
from cartsnitch_api.config import settings
|
||||
from cartsnitch_api.middleware.rate_limit import (
|
||||
InMemorySlidingWindow,
|
||||
RedisSlidingWindow,
|
||||
_get_client_ip,
|
||||
_get_rate_limit_key,
|
||||
)
|
||||
|
||||
|
||||
class TestSlidingWindowCounter:
|
||||
class TestInMemorySlidingWindow:
|
||||
def test_allows_within_limit(self):
|
||||
counter = _SlidingWindowCounter(max_requests=5, window_seconds=60)
|
||||
limiter = InMemorySlidingWindow(max_requests=5, window_seconds=60)
|
||||
for i in range(5):
|
||||
allowed, remaining, retry = counter.is_allowed("test-key")
|
||||
allowed, remaining, retry = limiter.is_allowed("test-key")
|
||||
assert allowed is True
|
||||
assert remaining == 4 - i
|
||||
|
||||
def test_blocks_over_limit(self):
|
||||
counter = _SlidingWindowCounter(max_requests=3, window_seconds=60)
|
||||
limiter = InMemorySlidingWindow(max_requests=3, window_seconds=60)
|
||||
for _ in range(3):
|
||||
counter.is_allowed("test-key")
|
||||
limiter.is_allowed("test-key")
|
||||
|
||||
allowed, remaining, retry = counter.is_allowed("test-key")
|
||||
allowed, remaining, retry = limiter.is_allowed("test-key")
|
||||
assert allowed is False
|
||||
assert remaining == 0
|
||||
assert retry > 0
|
||||
|
||||
def test_separate_keys(self):
|
||||
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")
|
||||
limiter = InMemorySlidingWindow(max_requests=2, window_seconds=60)
|
||||
limiter.is_allowed("key-a")
|
||||
limiter.is_allowed("key-a")
|
||||
allowed_a, _, _ = limiter.is_allowed("key-a")
|
||||
assert allowed_a is False
|
||||
|
||||
# key-b should still be allowed
|
||||
allowed_b, remaining, _ = counter.is_allowed("key-b")
|
||||
allowed_b, remaining, _ = limiter.is_allowed("key-b")
|
||||
assert allowed_b is True
|
||||
assert remaining == 1
|
||||
|
||||
def test_resets_after_window_expires(self):
|
||||
limiter = InMemorySlidingWindow(max_requests=2, window_seconds=1)
|
||||
for _ in range(2):
|
||||
limiter.is_allowed("test-key")
|
||||
allowed, remaining, _ = limiter.is_allowed("test-key")
|
||||
assert allowed is False
|
||||
|
||||
time.sleep(1.1)
|
||||
allowed, remaining, _ = limiter.is_allowed("test-key")
|
||||
assert allowed is True
|
||||
assert remaining == 1
|
||||
|
||||
|
||||
class TestGetClientIp:
|
||||
def test_x_forwarded_for_single(self):
|
||||
req = MagicMock()
|
||||
req.headers = {"x-forwarded-for": "192.168.1.1"}
|
||||
req.client = None
|
||||
assert _get_client_ip(req) == "192.168.1.1"
|
||||
|
||||
def test_x_forwarded_for_multiple(self):
|
||||
req = MagicMock()
|
||||
req.headers = {"x-forwarded-for": "192.168.1.1, 10.0.0.1, 172.16.0.1"}
|
||||
req.client = None
|
||||
assert _get_client_ip(req) == "192.168.1.1"
|
||||
|
||||
def test_x_forwarded_for_with_port(self):
|
||||
req = MagicMock()
|
||||
req.headers = {"x-forwarded-for": "192.168.1.1:8080"}
|
||||
req.client = None
|
||||
assert _get_client_ip(req) == "192.168.1.1"
|
||||
|
||||
def test_no_forwarded_header(self):
|
||||
req = MagicMock()
|
||||
req.headers = {}
|
||||
req.client.host = "127.0.0.1"
|
||||
assert _get_client_ip(req) == "127.0.0.1"
|
||||
|
||||
def test_no_client(self):
|
||||
req = MagicMock()
|
||||
req.headers = {}
|
||||
req.client = None
|
||||
assert _get_client_ip(req) == "unknown"
|
||||
|
||||
|
||||
class TestGetRateLimitKey:
|
||||
def _make_request(
|
||||
self,
|
||||
path: str = "/purchases",
|
||||
method: str = "GET",
|
||||
auth_header: str = "",
|
||||
headers: dict | None = None,
|
||||
) -> MagicMock:
|
||||
req = MagicMock()
|
||||
req.url.path = path
|
||||
req.method = method
|
||||
req.headers = dict(headers) if headers else {}
|
||||
if auth_header:
|
||||
req.headers["authorization"] = auth_header
|
||||
return req
|
||||
|
||||
def test_public_path_uses_public_limiter(self):
|
||||
req = self._make_request("/public/inflation")
|
||||
key, limiter = _get_rate_limit_key(req)
|
||||
assert key.startswith("ip:")
|
||||
assert limiter.max_requests == settings.rate_limit_requests
|
||||
|
||||
def test_auth_post_path_uses_strict_limiter(self):
|
||||
req = self._make_request("/auth/login", method="POST")
|
||||
key, limiter = _get_rate_limit_key(req)
|
||||
assert key.startswith("ip:")
|
||||
assert limiter.max_requests == settings.rate_limit_auth_requests
|
||||
assert limiter.window_seconds == settings.rate_limit_auth_window_seconds
|
||||
|
||||
def test_auth_get_path_uses_auth_limiter(self):
|
||||
req = self._make_request("/auth/me", method="GET")
|
||||
key, limiter = _get_rate_limit_key(req)
|
||||
assert key.startswith("ip:")
|
||||
assert limiter.max_requests == settings.rate_limit_requests * 5
|
||||
|
||||
def test_authenticated_token_uses_auth_limiter(self):
|
||||
req = self._make_request("/purchases", auth_header="Bearer token123")
|
||||
key, limiter = _get_rate_limit_key(req)
|
||||
assert key.startswith("token:")
|
||||
assert limiter.max_requests == settings.rate_limit_requests * 5
|
||||
|
||||
def test_distinct_tokens_produce_distinct_keys(self):
|
||||
req1 = self._make_request("/purchases", auth_header="Bearer token_alpha_12345")
|
||||
req2 = self._make_request("/purchases", auth_header="Bearer token_beta_67890")
|
||||
key1, _ = _get_rate_limit_key(req1)
|
||||
key2, _ = _get_rate_limit_key(req2)
|
||||
assert key1 != key2
|
||||
|
||||
def test_same_token_produces_same_key(self):
|
||||
req1 = self._make_request("/purchases", auth_header="Bearer same_token_value_abc")
|
||||
req2 = self._make_request("/purchases", auth_header="Bearer same_token_value_abc")
|
||||
key1, _ = _get_rate_limit_key(req1)
|
||||
key2, _ = _get_rate_limit_key(req2)
|
||||
assert key1 == key2
|
||||
|
||||
def test_key_does_not_contain_raw_token_suffix(self):
|
||||
raw_token = "my_secret_jwt_token_xyz"
|
||||
req = self._make_request("/purchases", auth_header=f"Bearer {raw_token}")
|
||||
key, _ = _get_rate_limit_key(req)
|
||||
assert raw_token[-16:] not in key
|
||||
assert raw_token not in key
|
||||
|
||||
|
||||
class TestRedisSlidingWindowFallback:
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_on_redis_connection_error(self):
|
||||
mock_redis = AsyncMock()
|
||||
mock_redis.pipeline.return_value = AsyncMock()
|
||||
pipe_mock = AsyncMock()
|
||||
pipe_mock.execute.side_effect = Exception("Connection refused")
|
||||
mock_redis.pipeline.return_value = pipe_mock
|
||||
|
||||
limiter = RedisSlidingWindow(mock_redis, max_requests=5, window_seconds=60)
|
||||
allowed, remaining, retry = await limiter.is_allowed("test-key")
|
||||
assert allowed is True
|
||||
assert remaining == 4
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fallback_on_redis_error_during_pipeline(self):
|
||||
mock_redis = AsyncMock()
|
||||
pipe_mock = AsyncMock()
|
||||
pipe_mock.execute.side_effect = Exception("Redis error")
|
||||
mock_redis.pipeline.return_value = pipe_mock
|
||||
|
||||
limiter = RedisSlidingWindow(mock_redis, max_requests=3, window_seconds=60)
|
||||
allowed, remaining, retry = await limiter.is_allowed("test-key")
|
||||
assert allowed is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rate_limit_returns_429(client):
|
||||
"""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
|
||||
@@ -49,7 +186,6 @@ 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
|
||||
|
||||
@@ -6,13 +6,14 @@ from httpx import ASGITransport, AsyncClient
|
||||
from cartsnitch_api.main import app
|
||||
|
||||
EXPECTED_ROUTES = [
|
||||
# Auth (6)
|
||||
# Auth (7)
|
||||
("post", "/auth/register"),
|
||||
("post", "/auth/login"),
|
||||
("post", "/auth/refresh"),
|
||||
("get", "/auth/me"),
|
||||
("patch", "/auth/me"),
|
||||
("delete", "/auth/me"),
|
||||
("get", "/auth/me/email-in-address"),
|
||||
# Stores (4)
|
||||
("get", "/stores"),
|
||||
("get", "/me/stores"),
|
||||
@@ -89,4 +90,4 @@ async def test_route_count():
|
||||
if method in ("get", "post", "put", "delete", "patch"):
|
||||
count += 1
|
||||
|
||||
assert count == 33, f"Expected 33 routes, found {count}"
|
||||
assert count == 34, f"Expected 34 routes, found {count}"
|
||||
|
||||
@@ -71,3 +71,97 @@ 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()
|
||||
|
||||
@@ -9,3 +9,7 @@ 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>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
FROM node:22-alpine AS builder
|
||||
RUN apk update && apk upgrade --no-cache
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN npm ci
|
||||
@@ -7,6 +8,7 @@ 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* ./
|
||||
|
||||
Generated
+79
-599
@@ -8,12 +8,13 @@
|
||||
"name": "@cartsnitch/auth",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"bcrypt": "^5.1.1",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "^1.2.0",
|
||||
"pg": "^8.13.0"
|
||||
"pg": "^8.13.0",
|
||||
"resend": "^6.11.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pg": "^8.11.0",
|
||||
"tsx": "^4.19.0",
|
||||
@@ -590,26 +591,6 @@
|
||||
"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",
|
||||
@@ -653,6 +634,12 @@
|
||||
"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",
|
||||
@@ -660,9 +647,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/bcrypt": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz",
|
||||
"integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==",
|
||||
"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==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -691,71 +678,18 @@
|
||||
"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": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz",
|
||||
"integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==",
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-6.0.0.tgz",
|
||||
"integrity": "sha512-cU8v/EGSrnH+HnxV2z0J7/blxH8gq7Xh2JFT6Aroax7UohdmiJJlxApMxtKfuI7z68NvvVcmR78k2LbT6efhRg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@mapbox/node-pre-gyp": "^1.0.11",
|
||||
"node-addon-api": "^5.0.0"
|
||||
"node-addon-api": "^8.3.0",
|
||||
"node-gyp-build": "^4.8.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
"node": ">= 18"
|
||||
}
|
||||
},
|
||||
"node_modules/better-auth": {
|
||||
@@ -883,90 +817,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -1009,35 +865,11 @@
|
||||
"@esbuild/win32-x64": "0.27.4"
|
||||
}
|
||||
},
|
||||
"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/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/fsevents": {
|
||||
"version": "2.3.3",
|
||||
@@ -1054,27 +886,6 @@
|
||||
"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",
|
||||
@@ -1088,72 +899,6 @@
|
||||
"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",
|
||||
@@ -1172,94 +917,6 @@
|
||||
"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",
|
||||
@@ -1276,84 +933,23 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-addon-api": {
|
||||
"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==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"whatwg-url": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "4.x || >=6.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"encoding": "^0.1.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"encoding": {
|
||||
"optional": true
|
||||
}
|
||||
"node": "^18 || ^20 || >= 21"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"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",
|
||||
"bin": {
|
||||
"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-gyp-build": "bin.js",
|
||||
"node-gyp-build-optional": "optional.js",
|
||||
"node-gyp-build-test": "build-test.js"
|
||||
}
|
||||
},
|
||||
"node_modules/pg": {
|
||||
@@ -1445,6 +1041,12 @@
|
||||
"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",
|
||||
@@ -1484,18 +1086,25 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"inherits": "^2.0.3",
|
||||
"string_decoder": "^1.1.1",
|
||||
"util-deprecate": "^1.0.1"
|
||||
"postal-mime": "2.7.4",
|
||||
"svix": "1.90.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 6"
|
||||
"node": ">=20"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-email/render": "*"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@react-email/render": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
@@ -1508,78 +1117,18 @@
|
||||
"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",
|
||||
@@ -1589,65 +1138,26 @@
|
||||
"node": ">= 10.x"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"node_modules/standardwebhooks": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.0.0.tgz",
|
||||
"integrity": "sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safe-buffer": "~5.2.0"
|
||||
"@stablelib/base64": "^1.0.0",
|
||||
"fast-sha256": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"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==",
|
||||
"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==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
"standardwebhooks": "1.0.0",
|
||||
"uuid": "^10.0.0"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -1689,43 +1199,19 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"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==",
|
||||
"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"
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tr46": "~0.0.3",
|
||||
"webidl-conversions": "^3.0.0"
|
||||
"bin": {
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"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",
|
||||
@@ -1735,12 +1221,6 @@
|
||||
"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
-3
@@ -10,15 +10,16 @@
|
||||
"generate": "npx @better-auth/cli generate"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "^1.2.0",
|
||||
"pg": "^8.13.0",
|
||||
"bcrypt": "^5.1.1"
|
||||
"resend": "^6.11.0"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+41
-8
@@ -1,20 +1,30 @@
|
||||
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",
|
||||
@@ -27,7 +37,7 @@ export const auth = betterAuth({
|
||||
maxPasswordLength: 128,
|
||||
password: {
|
||||
hash: async (password: string) => {
|
||||
return bcrypt.hash(password, 10);
|
||||
return bcrypt.hash(password, 12);
|
||||
},
|
||||
verify: async (data: { hash: string; password: string }) => {
|
||||
return bcrypt.compare(data.password, data.hash);
|
||||
@@ -35,7 +45,29 @@ 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: {
|
||||
@@ -86,5 +118,6 @@ export const auth = betterAuth({
|
||||
"https://cartsnitch.com",
|
||||
"https://cartsnitch.farh.net",
|
||||
"https://cartsnitch.dev.farh.net",
|
||||
"https://cartsnitch.uat.farh.net",
|
||||
],
|
||||
});
|
||||
});
|
||||
+17
-3
@@ -1,6 +1,6 @@
|
||||
import { createServer } from "node:http";
|
||||
import { toNodeHandler } from "better-auth/node";
|
||||
import { auth } from "./auth.js";
|
||||
import { auth, pool } from "./auth.js";
|
||||
|
||||
const port = parseInt(process.env.PORT ?? "3001", 10);
|
||||
|
||||
@@ -9,8 +9,22 @@ const handler = toNodeHandler(auth);
|
||||
const server = createServer(async (req, res) => {
|
||||
// Health check
|
||||
if (req.url === "/health" && req.method === "GET") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "ok" }));
|
||||
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: "connected" }));
|
||||
} catch {
|
||||
res.writeHead(503, { "Content-Type": "application/json" });
|
||||
res.end(JSON.stringify({ status: "error", db: "unreachable" }));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Submodule
+1
Submodule cartsnitch added at a53daddb9a
@@ -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)
|
||||
config.set_main_option("sqlalchemy.url", db_url.replace("%", "%%"))
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
"""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")
|
||||
@@ -3,6 +3,7 @@
|
||||
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
|
||||
@@ -26,7 +27,9 @@ 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, default=list)
|
||||
upc_variants: Mapped[list[str] | None] = mapped_column(
|
||||
JSON().with_variant(JSONB(), "postgresql"), default=list
|
||||
)
|
||||
|
||||
# Relationships
|
||||
purchase_items: Mapped[list["PurchaseItem"]] = relationship(back_populates="normalized_product")
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
"""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
|
||||
from sqlalchemy import JSON, Boolean, DateTime, ForeignKey, String, Text, UniqueConstraint, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from cartsnitch_common.constants import AccountStatus
|
||||
@@ -21,7 +22,16 @@ class User(UUIDPrimaryKeyMixin, TimestampMixin, Base):
|
||||
__tablename__ = "users"
|
||||
|
||||
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
|
||||
hashed_password: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
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)
|
||||
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,6 +20,7 @@ class UserRead(BaseModel):
|
||||
id: uuid.UUID
|
||||
email: str
|
||||
display_name: str | None
|
||||
email_inbound_token: str
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
@@ -147,6 +147,40 @@ 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(
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# CartSnitch UAT Runbook v1
|
||||
|
||||
**Version:** 1.0
|
||||
**Author:** Savannah Savings, CTO
|
||||
**Date:** 2026-03-30
|
||||
**Effective:** Immediately upon Phase 1 completion
|
||||
|
||||
---
|
||||
|
||||
## 1. Defect Severity Classification
|
||||
|
||||
Every defect discovered during UAT **must** be classified by severity and priority before triage.
|
||||
|
||||
### Severity Levels
|
||||
|
||||
| Severity | Definition | Examples |
|
||||
|----------|-----------|----------|
|
||||
| **S1 — Critical** | Blocks all users from completing a core journey. System is down, data is lost, or security is breached. | Login page crashes for all users; purchase data deleted; auth tokens exposed in response |
|
||||
| **S2 — High** | Blocks a major user flow for a significant portion of users. Core feature is broken but workarounds may exist. | Registration fails for email addresses with `+` character; price alerts never trigger; store comparison shows wrong prices |
|
||||
| **S3 — Medium** | Feature is degraded but usable. User can complete the journey with friction. | Date formatting shows raw ISO string instead of friendly date; slow page load (>5s) on product detail; search results not sorted correctly |
|
||||
| **S4 — Low** | Cosmetic issue, minor UI inconsistency, or edge case with minimal user impact. | Button text truncated on narrow screens; extra whitespace in footer; tooltip shows on hover but not on focus |
|
||||
|
||||
### Priority Levels
|
||||
|
||||
Priority determines **when** the defect must be fixed. Priority is set by the CTO based on severity, business impact, and sprint capacity.
|
||||
|
||||
| Priority | SLA | When to Use |
|
||||
|----------|-----|------------|
|
||||
| **P0 — Fix Now** | Triage within 1 hour, fix deployed within 4 hours | S1 defects, any security vulnerability, data integrity issues |
|
||||
| **P1 — Fix This Sprint** | Triage within 4 hours, fix in current sprint | S2 defects blocking upcoming release, S1 defects with viable workaround |
|
||||
| **P2 — Fix Next Sprint** | Triage within 24 hours, scheduled for next sprint | S3 defects, S2 defects with easy workarounds |
|
||||
| **P3 — Backlog** | Triage within 48 hours, prioritized against backlog | S4 defects, minor improvements, nice-to-haves |
|
||||
|
||||
### Defect Report Template
|
||||
|
||||
Every defect filed during UAT must include:
|
||||
|
||||
```
|
||||
**Title:** [Short description]
|
||||
**Severity:** S1/S2/S3/S4
|
||||
**Priority:** P0/P1/P2/P3 (set by CTO at triage)
|
||||
**Journey:** [Which user journey — J1 through J10]
|
||||
**Environment:** [Dev / Prod, deployed image tag]
|
||||
**Steps to Reproduce:**
|
||||
1. Navigate to ...
|
||||
2. Click ...
|
||||
3. Enter ...
|
||||
**Expected Result:** ...
|
||||
**Actual Result:** ...
|
||||
**Screenshots/Logs:** [Attach or link]
|
||||
**Browser/Device:** [e.g., Chromium 124, mobile viewport 390x844]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. UAT Entry Criteria
|
||||
|
||||
UAT **must not begin** until ALL of the following are satisfied. Checkout Charlie verifies these before opening the UAT gate.
|
||||
|
||||
| # | Criterion | Verified By |
|
||||
|---|-----------|------------|
|
||||
| E1 | CI pipeline passes on the merged commit (lint, type-check, unit tests, build) | GitHub Actions (automated) |
|
||||
| E2 | Docker image is built and pushed to GHCR with a CalVer tag | GitHub Actions (automated) |
|
||||
| E3 | Dev environment is deployed and accessible at `cartsnitch.dev.farh.net` | Flux reconciliation + health check |
|
||||
| E4 | All Playwright E2E tests pass in CI | GitHub Actions (automated) |
|
||||
| E5 | No open S1/S2 defects from previous UAT cycle | Checkout Charlie (manual check) |
|
||||
| E6 | PR has been reviewed and approved by QA (Checkout Charlie) and CTO (Savannah Savings) | GitHub PR approvals |
|
||||
| E7 | PR has been merged to main by CEO (Coupon Carl) | GitHub merge event |
|
||||
| E8 | Acceptance criteria for the feature/change are documented in the Paperclip issue | Checkout Charlie (manual check) |
|
||||
|
||||
**If any entry criterion is not met**, UAT is blocked. Checkout Charlie must comment on the Paperclip issue specifying which criteria failed and assign back to the responsible party.
|
||||
|
||||
---
|
||||
|
||||
## 3. UAT Exit Criteria
|
||||
|
||||
UAT is **complete** only when ALL of the following are satisfied. Rollback Rhonda verifies these before signing off.
|
||||
|
||||
| # | Criterion | Verified By |
|
||||
|---|-----------|------------|
|
||||
| X1 | All 10 critical user journeys (J1-J10) have been executed | Rollback Rhonda (full regression) |
|
||||
| X2 | Zero open S1 (Critical) defects | Defect tracker |
|
||||
| X3 | Zero open S2 (High) defects, OR CTO has granted a documented exception | Defect tracker + CTO sign-off |
|
||||
| X4 | All S3/S4 defects are logged and triaged (not necessarily fixed) | Defect tracker |
|
||||
| X5 | 100% test execution rate -- every test case was run, none skipped | Rollback Rhonda's UAT report |
|
||||
| X6 | Accessibility scan (axe-core) reports zero critical violations | Automated in E2E suite |
|
||||
| X7 | Lighthouse performance score >= 50, accessibility score >= 90 | Lighthouse CI |
|
||||
| X8 | Written sign-off from Rollback Rhonda confirming all criteria met | Paperclip comment on issue |
|
||||
|
||||
**If any exit criterion is not met**, the release is blocked. Rollback Rhonda must:
|
||||
1. File defects for all failures using the Defect Report Template above.
|
||||
2. Comment on the Paperclip issue specifying which exit criteria failed.
|
||||
3. Assign back to CTO for triage and redistribution.
|
||||
|
||||
---
|
||||
|
||||
## 4. UAT Execution Procedure
|
||||
|
||||
### 4.1 Pre-UAT (Checkout Charlie)
|
||||
|
||||
1. Verify all entry criteria (E1-E8) are met.
|
||||
2. Comment on the Paperclip issue: "UAT gate open -- all entry criteria verified."
|
||||
3. Assign to Rollback Rhonda with status todo.
|
||||
|
||||
### 4.2 UAT Execution (Rollback Rhonda)
|
||||
|
||||
1. **Full regression run** -- execute ALL 10 user journeys against cartsnitch.dev.farh.net. No partial runs. No exceptions.
|
||||
2. For each journey, verify:
|
||||
- All interactive elements respond correctly (buttons, forms, links, toggles)
|
||||
- State transitions are correct (auth state, data mutations, navigation)
|
||||
- Error states are handled gracefully (invalid input, network failures)
|
||||
- Accessibility scan passes (axe-core integrated in Playwright)
|
||||
3. Log results for each journey: PASS / FAIL with details.
|
||||
4. File defects immediately for any failures.
|
||||
5. Complete the UAT report with execution results.
|
||||
|
||||
### 4.3 Post-UAT Sign-Off
|
||||
|
||||
1. If all exit criteria (X1-X8) are met:
|
||||
- Rollback Rhonda posts sign-off comment: "UAT PASSED -- all exit criteria met."
|
||||
- Production promotion is automated via Flux on UAT pass.
|
||||
2. If any exit criterion fails:
|
||||
- Rollback Rhonda posts failure comment with specific failures.
|
||||
- CTO triages defects and redistributes to engineers.
|
||||
- After fixes are merged, UAT restarts from 4.1 (full cycle).
|
||||
|
||||
---
|
||||
|
||||
## 5. Critical User Journeys Reference
|
||||
|
||||
| ID | Journey | Key Interactions |
|
||||
|----|---------|-----------------|
|
||||
| J1 | Registration -> Login -> Dashboard | Form submission, auth state, redirect |
|
||||
| J2 | Login -> Browse Products -> View Detail -> Price Chart | Search, navigation, data visualization |
|
||||
| J3 | Login -> Purchases -> Purchase Detail -> Product Link | List navigation, detail view, cross-linking |
|
||||
| J4 | Login -> Connect Store Account -> Verify Connection | OAuth flow, external integration |
|
||||
| J5 | Login -> Create Price Alert -> View -> Delete Alert | CRUD operations, confirmation dialogs |
|
||||
| J6 | Login -> Browse Coupons -> Copy Code | Clipboard interaction, toast feedback |
|
||||
| J7 | Login -> Settings -> Toggle Preferences -> Sign Out | Checkbox toggles, theme switch, session termination |
|
||||
| J8 | Login -> Store Comparison -> Compare Prices | Data comparison, sorting, price display |
|
||||
| J9 | Forgot Password Flow | Email input, validation, redirect |
|
||||
| J10 | Unauth Access -> Redirect to Login | Route protection, redirect behavior |
|
||||
|
||||
---
|
||||
|
||||
## 6. Revision History
|
||||
|
||||
| Version | Date | Author | Changes |
|
||||
|---------|------|--------|---------|
|
||||
| 1.0 | 2026-03-30 | Savannah Savings | Initial runbook -- defect taxonomy, entry/exit criteria, execution procedure |
|
||||
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
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 };
|
||||
@@ -0,0 +1,43 @@
|
||||
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('can register a new account and see check your email screen', 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"]');
|
||||
|
||||
await expect(page.getByRole('heading', { name: /check your email/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('shows validation error when registration fields are empty', async ({ page }) => {
|
||||
await page.goto('/register');
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
await expect(page.locator('.bg-red-50')).toContainText('Please fill in all fields');
|
||||
});
|
||||
|
||||
test('can navigate from register to login', async ({ page }) => {
|
||||
await page.goto('/register');
|
||||
await page.getByRole('link', { name: /sign in/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/login/);
|
||||
await expect(page.getByRole('heading', { name: /cartsnitch/i })).toBeVisible();
|
||||
});
|
||||
|
||||
test('can sign in with credentials and land on dashboard', async ({ page }) => {
|
||||
await mockAuthRoutes(page, true);
|
||||
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/');
|
||||
});
|
||||
|
||||
});
|
||||
@@ -0,0 +1,43 @@
|
||||
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 });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,8 @@
|
||||
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();
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"ci": {
|
||||
"collect": {
|
||||
"staticDistDir": "./dist",
|
||||
"url": ["http://localhost:4173/"],
|
||||
"numberOfRuns": 1,
|
||||
"settings": {
|
||||
"chromeFlags": ["--headless=new", "--no-sandbox", "--disable-gpu", "--disable-dev-shm-usage"],
|
||||
"skipAudits": ["bf-cache"],
|
||||
"disableFullPageScreenshot": true
|
||||
}
|
||||
},
|
||||
"assert": {
|
||||
"assertions": {
|
||||
"categories:performance": ["warn", { "minScore": 0.7 }],
|
||||
"categories:accessibility": ["error", { "minScore": 0.9 }],
|
||||
"categories:best-practices": ["warn", { "minScore": 0.8 }]
|
||||
}
|
||||
},
|
||||
"upload": {
|
||||
"target": "temporary-public-storage"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,12 @@ 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;
|
||||
|
||||
Generated
+1218
-669
File diff suppressed because it is too large
Load Diff
+17
-3
@@ -9,11 +9,13 @@
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview",
|
||||
"test": "NODE_ENV=test vitest run",
|
||||
"test:watch": "NODE_ENV=test vitest"
|
||||
"test:watch": "NODE_ENV=test vitest",
|
||||
"test:e2e": "npx playwright test"
|
||||
},
|
||||
"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",
|
||||
@@ -21,24 +23,36 @@
|
||||
"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.5.2",
|
||||
"@vitejs/plugin-react": "^4.7.0",
|
||||
"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.3.5",
|
||||
"vite": "^6.4.2",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: 'npm run dev',
|
||||
url: 'http://localhost:5173',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
use: {
|
||||
baseURL: 'http://localhost:5173',
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,4 @@
|
||||
User-agent: *
|
||||
Allow: /
|
||||
|
||||
Sitemap: https://cartsnitch.com/sitemap.xml
|
||||
-168
@@ -1,168 +0,0 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
packages: write
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: cartsnitch/receiptwitness
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: runners-cartsnitch
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
- name: Install cartsnitch-common from GitHub
|
||||
run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b"
|
||||
- run: pip install ruff
|
||||
- name: Ruff lint
|
||||
run: ruff check .
|
||||
- name: Ruff format check
|
||||
run: ruff format --check .
|
||||
|
||||
typecheck:
|
||||
runs-on: runners-cartsnitch
|
||||
continue-on-error: true
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
- name: Install cartsnitch-common from GitHub
|
||||
run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b"
|
||||
- run: pip install -e ".[dev]" mypy
|
||||
- name: Type check
|
||||
run: mypy src/receiptwitness
|
||||
|
||||
test:
|
||||
runs-on: runners-cartsnitch
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:15-alpine
|
||||
credentials:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
env:
|
||||
POSTGRES_USER: cartsnitch
|
||||
POSTGRES_PASSWORD: cartsnitch_test
|
||||
POSTGRES_DB: cartsnitch_test
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
credentials:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: >-
|
||||
--health-cmd "redis-cli ping"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
env:
|
||||
DATABASE_URL: postgresql://cartsnitch:cartsnitch_test@localhost:5432/cartsnitch_test
|
||||
REDIS_URL: redis://localhost:6379/0
|
||||
ENCRYPTION_KEY: dGVzdC1lbmNyeXB0aW9uLWtleS0xMjM0NTY3ODk=
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
cache: pip
|
||||
- name: Install cartsnitch-common from GitHub
|
||||
run: pip install "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@76685ed0384103228cd670b477b967e7752ebe6b"
|
||||
- run: pip install -e ".[dev]"
|
||||
- name: Install Playwright browsers
|
||||
run: playwright install chromium --with-deps
|
||||
- name: Run tests
|
||||
run: pytest --tb=short -q
|
||||
|
||||
build-and-push:
|
||||
runs-on: runners-cartsnitch
|
||||
needs: [lint, test]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Generate CalVer tag
|
||||
id: calver
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
DATE_TAG=$(date -u +%Y.%m.%d)
|
||||
EXISTING=$(git tag -l "v${DATE_TAG}*" | sort -V | tail -1)
|
||||
if [ -z "$EXISTING" ]; then
|
||||
VERSION="$DATE_TAG"
|
||||
elif [ "$EXISTING" = "v${DATE_TAG}" ]; then
|
||||
VERSION="${DATE_TAG}.2"
|
||||
else
|
||||
BUILD_NUM=$(echo "$EXISTING" | sed "s/v${DATE_TAG}\.//")
|
||||
VERSION="${DATE_TAG}.$((BUILD_NUM + 1))"
|
||||
fi
|
||||
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
|
||||
echo "CalVer tag: $VERSION"
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v3
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Log in to GHCR
|
||||
uses: docker/login-action@v3
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata
|
||||
id: meta
|
||||
uses: docker/metadata-action@v5
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=sha,prefix=sha-
|
||||
type=raw,value=${{ steps.calver.outputs.version }},enable=${{ github.ref == 'refs/heads/main' }}
|
||||
type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v6
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
target: prod
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
- name: Create git tag
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: |
|
||||
git tag "v${{ steps.calver.outputs.version }}"
|
||||
git push origin "v${{ steps.calver.outputs.version }}"
|
||||
+14
-15
@@ -3,24 +3,22 @@ FROM python:3.12-slim AS build
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 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 \
|
||||
# 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 \
|
||||
libpq-dev \
|
||||
build-essential \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml ./
|
||||
COPY src/ ./src/
|
||||
# Build context is the repo root. These paths are relative to the root.
|
||||
COPY receiptwitness/pyproject.toml ./
|
||||
COPY receiptwitness/src/ ./src/
|
||||
COPY common/ ./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" \
|
||||
.
|
||||
# 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/ .
|
||||
|
||||
# Stage 2: Production image with Playwright + Chromium
|
||||
FROM python:3.12-slim AS prod
|
||||
@@ -28,7 +26,8 @@ FROM python:3.12-slim AS prod
|
||||
WORKDIR /app
|
||||
|
||||
# Install Playwright system dependencies for Chromium
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
ARG APT_CACHE_BUST=1
|
||||
RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends \
|
||||
libnss3 \
|
||||
libatk1.0-0 \
|
||||
libatk-bridge2.0-0 \
|
||||
@@ -51,7 +50,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
RUN adduser --system --group --uid 1000 app
|
||||
|
||||
COPY --from=build /install /usr/local
|
||||
COPY src/ ./src/
|
||||
COPY receiptwitness/src/ ./src/
|
||||
|
||||
# Install Playwright Chromium browser (runs as root; /opt/playwright is world-readable)
|
||||
RUN PLAYWRIGHT_BROWSERS_PATH=/opt/playwright playwright install chromium
|
||||
|
||||
@@ -11,14 +11,16 @@ dependencies = [
|
||||
"cartsnitch-common>=0.1.0",
|
||||
"playwright>=1.49,<2.0",
|
||||
"playwright-stealth>=1.0,<2.0",
|
||||
"cryptography>=42.0,<44.0",
|
||||
"cryptography>=46.0,<47.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]
|
||||
@@ -27,6 +29,9 @@ 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,9 +1,65 @@
|
||||
"""Internal API routes for triggering scrapes and checking status."""
|
||||
|
||||
from fastapi import APIRouter
|
||||
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
|
||||
|
||||
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,8 +1,12 @@
|
||||
"""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_"}
|
||||
|
||||
@@ -22,5 +26,42 @@ 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
|
||||
|
||||
settings = ReceiptWitnessSettings()
|
||||
# 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()
|
||||
|
||||
@@ -2,12 +2,17 @@
|
||||
|
||||
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__)
|
||||
|
||||
@@ -39,6 +44,36 @@ 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,
|
||||
@@ -48,18 +83,19 @@ 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": {
|
||||
"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,
|
||||
},
|
||||
"payload": payload,
|
||||
}
|
||||
|
||||
try:
|
||||
@@ -73,3 +109,5 @@ 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)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
"""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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Email receipt parsers for retailer email receipts."""
|
||||
@@ -0,0 +1,32 @@
|
||||
"""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."""
|
||||
...
|
||||
@@ -0,0 +1,25 @@
|
||||
"""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
|
||||
@@ -0,0 +1,157 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
"""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,
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
"""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,12 +5,14 @@ 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 select
|
||||
from sqlalchemy import cast, func, select, String
|
||||
from sqlalchemy.dialects.postgresql import JSONB
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
|
||||
@@ -96,17 +98,24 @@ 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.
|
||||
|
||||
Loads products with upc_variants and checks membership in Python
|
||||
for cross-database compatibility (works on both PostgreSQL and SQLite).
|
||||
Uses PostgreSQL JSONB containment (@>) for production efficiency.
|
||||
Falls back to LIKE on SQLite for test compatibility.
|
||||
"""
|
||||
# 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)
|
||||
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)
|
||||
return None
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""DragonflyDB Streams queue for email receipt processing."""
|
||||
@@ -0,0 +1,77 @@
|
||||
"""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)
|
||||
@@ -0,0 +1 @@
|
||||
"""Async email receipt worker consuming from DragonflyDB Streams."""
|
||||
@@ -0,0 +1,104 @@
|
||||
"""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())
|
||||
@@ -1,12 +1,16 @@
|
||||
"""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:
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,127 @@
|
||||
<!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>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!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>
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the ReceiptWitness API routes."""
|
||||
@@ -0,0 +1,125 @@
|
||||
"""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
|
||||
@@ -0,0 +1,46 @@
|
||||
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"
|
||||
@@ -0,0 +1,84 @@
|
||||
"""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()
|
||||
@@ -0,0 +1,49 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,93 @@
|
||||
"""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"
|
||||
@@ -0,0 +1,182 @@
|
||||
"""Tests for the Meijer email receipt parser."""
|
||||
|
||||
import os
|
||||
from decimal import Decimal
|
||||
|
||||
import pytest
|
||||
|
||||
from receiptwitness.parsers.email.base import EmailReceipt
|
||||
from receiptwitness.parsers.email.meijer import MeijerEmailParser
|
||||
|
||||
FIXTURE_PATH = os.path.join(
|
||||
os.path.dirname(__file__), "..", "..", "fixtures", "meijer_email_receipt.html"
|
||||
)
|
||||
|
||||
|
||||
def load_fixture() -> str:
|
||||
with open(FIXTURE_PATH) as f:
|
||||
return f.read()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def meijer_email() -> EmailReceipt:
|
||||
html = load_fixture()
|
||||
return EmailReceipt(
|
||||
sender="Meijer Receipts <receipts@email.meijer.com>",
|
||||
recipient="shopper@example.com",
|
||||
subject="Your Meijer Receipt — Transaction #TXN-2026-0315-0042",
|
||||
body_html=html,
|
||||
body_plain=None,
|
||||
received_at="2026-03-15T14:34:00Z",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def kroger_email() -> EmailReceipt:
|
||||
return EmailReceipt(
|
||||
sender="Kroger <noreply@email.kroger.com>",
|
||||
recipient="shopper@example.com",
|
||||
subject="Your Kroger Receipt",
|
||||
body_html="<html><body>Kroger receipt</body></html>",
|
||||
)
|
||||
|
||||
|
||||
class TestCanParse:
|
||||
def test_can_parse_meijer(self, meijer_email: EmailReceipt):
|
||||
parser = MeijerEmailParser()
|
||||
assert parser.can_parse(meijer_email) is True
|
||||
|
||||
def test_cannot_parse_kroger(self, kroger_email: EmailReceipt):
|
||||
parser = MeijerEmailParser()
|
||||
assert parser.can_parse(kroger_email) is False
|
||||
|
||||
def test_can_parse_meijer_plain_sender(self):
|
||||
email = EmailReceipt(
|
||||
sender="receipts@meijer.com",
|
||||
recipient="shopper@example.com",
|
||||
subject="Receipt",
|
||||
body_html="<html></html>",
|
||||
)
|
||||
parser = MeijerEmailParser()
|
||||
assert parser.can_parse(email) is True
|
||||
|
||||
def test_cannot_parse_non_meijer(self):
|
||||
email = EmailReceipt(
|
||||
sender=" Target <no-reply@target.com>",
|
||||
recipient="shopper@example.com",
|
||||
subject="Target Receipt",
|
||||
body_html="<html></html>",
|
||||
)
|
||||
parser = MeijerEmailParser()
|
||||
assert parser.can_parse(email) is False
|
||||
|
||||
|
||||
class TestParseMeijerReceipt:
|
||||
def test_receipt_id_extracted(self, meijer_email: EmailReceipt):
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(meijer_email)
|
||||
assert result["receipt_id"] == "TXN-2026-0315-0042"
|
||||
|
||||
def test_purchase_date_extracted(self, meijer_email: EmailReceipt):
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(meijer_email)
|
||||
assert result["purchase_date"] == "2026-03-15"
|
||||
|
||||
def test_items_extracted(self, meijer_email: EmailReceipt):
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(meijer_email)
|
||||
items = result["items"]
|
||||
assert len(items) == 8
|
||||
|
||||
names = [item["product_name_raw"] for item in items]
|
||||
assert "ORGANIC BANANAS" in names
|
||||
assert "WHOLE MILK 1 GAL" in names
|
||||
assert "GROUND BEEF 85/15 1LB" in names
|
||||
|
||||
def test_item_quantities(self, meijer_email: EmailReceipt):
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(meijer_email)
|
||||
# Find ORGANIC BANANAS
|
||||
bananas = next(i for i in result["items"] if "BANANAS" in i["product_name_raw"])
|
||||
assert bananas["quantity"] == Decimal("1")
|
||||
|
||||
def test_item_prices(self, meijer_email: EmailReceipt):
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(meijer_email)
|
||||
# Find ORGANIC BANANAS
|
||||
bananas = next(i for i in result["items"] if "BANANAS" in i["product_name_raw"])
|
||||
assert bananas["unit_price"] == Decimal("0.69")
|
||||
assert bananas["extended_price"] == Decimal("0.69")
|
||||
|
||||
def test_totals(self, meijer_email: EmailReceipt):
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(meijer_email)
|
||||
assert result["total"] == Decimal("33.41")
|
||||
assert result["subtotal"] == Decimal("31.22")
|
||||
assert result["tax"] == Decimal("2.19")
|
||||
assert result["savings_total"] == Decimal("3.40")
|
||||
|
||||
|
||||
class TestParseHandlesMissingFields:
|
||||
def test_missing_body_html_falls_back_to_plain(self):
|
||||
email = EmailReceipt(
|
||||
sender="receipts@email.meijer.com",
|
||||
recipient="shopper@example.com",
|
||||
subject="Your Meijer Receipt",
|
||||
body_html=None,
|
||||
body_plain="TXN-1234 | March 15, 2026 | Total: $10.00",
|
||||
)
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(email)
|
||||
# Should not raise, returns minimal result
|
||||
assert result["receipt_id"] == ""
|
||||
assert result["purchase_date"] == "2026-03-15"
|
||||
assert result["total"] == Decimal("10.00")
|
||||
|
||||
def test_empty_email(self):
|
||||
email = EmailReceipt(
|
||||
sender="receipts@email.meijer.com",
|
||||
recipient="shopper@example.com",
|
||||
subject="Receipt",
|
||||
body_html="",
|
||||
body_plain="",
|
||||
)
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(email)
|
||||
assert result["receipt_id"] == ""
|
||||
assert result["purchase_date"] == ""
|
||||
assert result["total"] == Decimal("0")
|
||||
assert result["items"] == []
|
||||
|
||||
def test_missing_subject_date_from_body(self):
|
||||
html = """
|
||||
<html>
|
||||
<body>
|
||||
<p>Thank you for shopping on April 1, 2026</p>
|
||||
<p>Total: $15.00</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
email = EmailReceipt(
|
||||
sender="receipts@email.meijer.com",
|
||||
recipient="shopper@example.com",
|
||||
subject=None,
|
||||
body_html=html,
|
||||
)
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(email)
|
||||
assert result["purchase_date"] == "2026-04-01"
|
||||
|
||||
def test_missing_totals_defaults_to_zero(self):
|
||||
html = "<html><body><p>Just an email with no totals</p></body></html>"
|
||||
email = EmailReceipt(
|
||||
sender="receipts@email.meijer.com",
|
||||
recipient="shopper@example.com",
|
||||
subject="Receipt",
|
||||
body_html=html,
|
||||
)
|
||||
parser = MeijerEmailParser()
|
||||
result = parser.parse(email)
|
||||
assert result["total"] == Decimal("0")
|
||||
assert result["subtotal"] is None
|
||||
assert result["tax"] is None
|
||||
@@ -0,0 +1,93 @@
|
||||
"""Tests for TargetEmailParser."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from receiptwitness.parsers.email.base import EmailReceipt
|
||||
from receiptwitness.parsers.email.target import TargetEmailParser
|
||||
|
||||
FIXTURE_PATH = Path(__file__).parent.parent.parent / "fixtures" / "target_email_receipt.html"
|
||||
|
||||
|
||||
class TestTargetEmailParser:
|
||||
"""Tests for TargetEmailParser."""
|
||||
|
||||
def setup_method(self) -> None:
|
||||
self.parser = TargetEmailParser()
|
||||
self.fixture_html = FIXTURE_PATH.read_text()
|
||||
|
||||
def test_can_parse_target_sender(self) -> None:
|
||||
email = EmailReceipt(
|
||||
sender="receipts@target.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Target Order Confirmation",
|
||||
body_html=self.fixture_html,
|
||||
)
|
||||
assert self.parser.can_parse(email) is True
|
||||
|
||||
def test_can_parse_circle_in_body(self) -> None:
|
||||
email = EmailReceipt(
|
||||
sender="someone@unknown.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Receipt",
|
||||
body_html="<html><body>Target Circle savings offer</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="orders@target.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Target Order",
|
||||
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("Arborio" 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="orders@target.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Target Order",
|
||||
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="orders@target.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Target Order",
|
||||
body_html=self.fixture_html,
|
||||
)
|
||||
result = self.parser.parse(email)
|
||||
receipt_id = result.get("receipt_id", "")
|
||||
assert "TGT-2026" in receipt_id or "CNF" in receipt_id
|
||||
|
||||
def test_parse_date(self) -> None:
|
||||
email = EmailReceipt(
|
||||
sender="orders@target.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Target Order",
|
||||
body_html=self.fixture_html,
|
||||
)
|
||||
result = self.parser.parse(email)
|
||||
purchase_date = result.get("purchase_date", "")
|
||||
assert purchase_date == "2026-03-18"
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Tests for email queue using DragonflyDB Streams."""
|
||||
|
||||
import pytest
|
||||
from fakeredis import aioredis as fake_aioredis
|
||||
|
||||
from receiptwitness.queue.email import (
|
||||
CONSUMER_GROUP,
|
||||
STREAM_KEY,
|
||||
EmailJob,
|
||||
ack_email,
|
||||
consume_emails,
|
||||
enqueue_email,
|
||||
ensure_consumer_group,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def fake_client():
|
||||
"""Yield a fake async Redis client."""
|
||||
client = fake_aioredis.FakeRedis(decode_responses=True)
|
||||
yield client
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_job():
|
||||
"""Sample EmailJob for testing."""
|
||||
return EmailJob(
|
||||
user_id="user-123",
|
||||
sender="no-reply@kroger.com",
|
||||
recipient="user@example.com",
|
||||
subject="Kroger Receipt",
|
||||
body_html="<html><body>Receipt</body></html>",
|
||||
body_plain="Receipt",
|
||||
received_at="2026-04-01T12:00:00Z",
|
||||
message_id="msg-abc-123",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enqueue_and_consume(fake_client, sample_job):
|
||||
"""Enqueue a job, consume it, verify fields match."""
|
||||
msg_id = await enqueue_email(fake_client, sample_job)
|
||||
assert msg_id is not None
|
||||
|
||||
consumed = await consume_emails(fake_client, "test-worker", count=1, block_ms=100)
|
||||
assert len(consumed) == 1
|
||||
consumed_id, consumed_job = consumed[0]
|
||||
assert consumed_id == msg_id
|
||||
assert consumed_job.user_id == sample_job.user_id
|
||||
assert consumed_job.sender == sample_job.sender
|
||||
assert consumed_job.recipient == sample_job.recipient
|
||||
assert consumed_job.subject == sample_job.subject
|
||||
assert consumed_job.message_id == sample_job.message_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ack_removes_from_pending(fake_client, sample_job):
|
||||
"""After ack, message is no longer pending."""
|
||||
msg_id = await enqueue_email(fake_client, sample_job)
|
||||
|
||||
# Consume the message (moves it to pending)
|
||||
consumed = await consume_emails(fake_client, "test-worker", count=1, block_ms=100)
|
||||
assert len(consumed) == 1
|
||||
|
||||
# Acknowledge it
|
||||
await ack_email(fake_client, msg_id)
|
||||
|
||||
# Check pending count for this consumer group
|
||||
pending = await fake_client.xpending(STREAM_KEY, CONSUMER_GROUP)
|
||||
assert pending is None or pending["pending"] == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_consumer_group_idempotent(fake_client):
|
||||
"""Calling ensure_consumer_group twice does not error."""
|
||||
await ensure_consumer_group(fake_client)
|
||||
# Calling again should not raise
|
||||
await ensure_consumer_group(fake_client)
|
||||
@@ -0,0 +1,188 @@
|
||||
"""Tests for email_worker."""
|
||||
|
||||
from decimal import Decimal
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fakeredis import aioredis as fake_aioredis
|
||||
|
||||
from receiptwitness.parsers.email.base import EmailReceipt
|
||||
from receiptwitness.queue.email import (
|
||||
EmailJob,
|
||||
)
|
||||
from receiptwitness.worker.email_worker import (
|
||||
process_job,
|
||||
resolve_user,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def fake_redis():
|
||||
"""Fake async Redis client for queue testing."""
|
||||
client = fake_aioredis.FakeRedis(decode_responses=True)
|
||||
yield client
|
||||
await client.aclose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_email_job():
|
||||
"""Sample EmailJob matching DragonflyDB queue schema."""
|
||||
return EmailJob(
|
||||
user_id="token-abc-123",
|
||||
sender="no-reply@meijer.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Meijer Receipt",
|
||||
body_html="<html><body>Total: $42.00</body></html>",
|
||||
body_plain="Total: $42.00",
|
||||
received_at="2026-04-01T12:00:00Z",
|
||||
message_id="msg-xyz-789",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_email():
|
||||
"""Sample EmailReceipt for parser testing."""
|
||||
return EmailReceipt(
|
||||
sender="no-reply@meijer.com",
|
||||
recipient="user@example.com",
|
||||
subject="Your Meijer Receipt",
|
||||
body_html="<html><body>Total: $42.00<br/>Receipt #12345</body></html>",
|
||||
body_plain="Total: $42.00",
|
||||
received_at="2026-04-01T12:00:00Z",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# resolve_user tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_user_valid_token():
|
||||
"""Valid token returns user_id string."""
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = "user-uuid-42"
|
||||
mock_session.execute.return_value = mock_result
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
factory = MagicMock(return_value=mock_session)
|
||||
|
||||
with patch(
|
||||
"receiptwitness.worker.email_worker.get_async_session_factory",
|
||||
return_value=factory,
|
||||
):
|
||||
user_id = await resolve_user("token-abc-123")
|
||||
|
||||
assert user_id == "user-uuid-42"
|
||||
factory.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_resolve_user_invalid_token():
|
||||
"""Invalid token returns None."""
|
||||
mock_session = AsyncMock()
|
||||
mock_result = MagicMock()
|
||||
mock_result.scalar_one_or_none.return_value = None
|
||||
mock_session.execute.return_value = mock_result
|
||||
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
|
||||
mock_session.__aexit__ = AsyncMock(return_value=None)
|
||||
|
||||
factory = MagicMock(return_value=mock_session)
|
||||
|
||||
with patch(
|
||||
"receiptwitness.worker.email_worker.get_async_session_factory",
|
||||
return_value=factory,
|
||||
):
|
||||
user_id = await resolve_user("bad-token")
|
||||
|
||||
assert user_id is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# process_job tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_job_unknown_retailer(sample_email_job):
|
||||
"""Unknown retailer logs warning and returns True (ack, no retry)."""
|
||||
unknown_job = EmailJob(
|
||||
user_id="token-abc-123",
|
||||
sender="no-reply@unknownretailer.com",
|
||||
recipient="user@example.com",
|
||||
subject="Receipt",
|
||||
body_html="<html></html>",
|
||||
body_plain="",
|
||||
received_at="2026-04-01T12:00:00Z",
|
||||
message_id="msg-xyz-789",
|
||||
)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"receiptwitness.worker.email_worker.resolve_user",
|
||||
return_value="user-uuid-42",
|
||||
),
|
||||
patch(
|
||||
"receiptwitness.worker.email_worker.publish_receipt_ingested",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_publish,
|
||||
):
|
||||
result = await process_job("msg-id-1", unknown_job)
|
||||
|
||||
assert result is True
|
||||
mock_publish.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_process_job_success(sample_email_job, sample_email):
|
||||
"""Known retailer: full pipeline runs — parse, normalize, publish event."""
|
||||
parsed_data = {
|
||||
"receipt_id": "RCP-999",
|
||||
"purchase_date": "2026-04-01",
|
||||
"total": Decimal("42.00"),
|
||||
"items": [
|
||||
{
|
||||
"product_name_raw": "ORGANIC BANANAS",
|
||||
"quantity": Decimal("1"),
|
||||
"unit_price": Decimal("0.69"),
|
||||
"extended_price": Decimal("0.69"),
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
mock_parser = MagicMock()
|
||||
mock_parser.parse.return_value = parsed_data
|
||||
|
||||
with (
|
||||
patch(
|
||||
"receiptwitness.worker.email_worker.resolve_user",
|
||||
return_value="user-uuid-42",
|
||||
),
|
||||
patch.dict(
|
||||
"receiptwitness.worker.email_worker.PARSERS",
|
||||
{"meijer": mock_parser},
|
||||
clear=False,
|
||||
),
|
||||
patch(
|
||||
"receiptwitness.worker.email_worker.publish_receipt_ingested",
|
||||
new_callable=AsyncMock,
|
||||
) as mock_publish,
|
||||
):
|
||||
result = await process_job("msg-id-1", sample_email_job)
|
||||
|
||||
assert result is True
|
||||
mock_parser.parse.assert_called_once()
|
||||
mock_publish.assert_called_once_with(
|
||||
user_id="user-uuid-42",
|
||||
store_slug="meijer",
|
||||
purchase_id="RCP-999",
|
||||
purchase_date="2026-04-01",
|
||||
item_count=1,
|
||||
total=Decimal("42.00"),
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
# seed-dev-job.yaml
|
||||
# K8s Job to run the CartSnitch seed runner against the dev database.
|
||||
#
|
||||
# Usage:
|
||||
# kubectl apply -f seed-dev-job.yaml -n cartsnitch-dev
|
||||
#
|
||||
# To view logs:
|
||||
# kubectl logs -n cartsnitch-dev job/seed-dev -f
|
||||
#
|
||||
# To re-run after fixing issues:
|
||||
# kubectl delete -f seed-dev-job.yaml -n cartsnitch-dev && kubectl apply -f seed-dev-job.yaml -n cartsnitch-dev
|
||||
#
|
||||
apiVersion: batch/v1
|
||||
kind: Job
|
||||
metadata:
|
||||
name: seed-dev
|
||||
namespace: cartsnitch-dev
|
||||
labels:
|
||||
app: cartsnitch
|
||||
component: seed
|
||||
environment: dev
|
||||
annotations:
|
||||
description: "Runs cartsnitch-common seed runner to populate dev database with realistic test data."
|
||||
spec:
|
||||
# Prevent retries — a failed seed run should be investigated, not auto-repeated.
|
||||
backoffLimit: 0
|
||||
# Do not run concurrently; sequential runs are safer for truncate+reseed.
|
||||
concurrencyPolicy: Forbid
|
||||
template:
|
||||
metadata:
|
||||
labels:
|
||||
app: cartsnitch
|
||||
component: seed
|
||||
environment: dev
|
||||
spec:
|
||||
restartPolicy: Never
|
||||
containers:
|
||||
- name: seed
|
||||
# Use slim Python image with the cartsnitch-common package installed from git.
|
||||
# The common repo is public; no additional secret is needed for the pip install.
|
||||
image: python:3.12-slim
|
||||
command:
|
||||
- sh
|
||||
- -c
|
||||
- |
|
||||
pip install --no-cache-dir "cartsnitch-common @ git+https://github.com/cartsnitch/common.git@main" && \
|
||||
python -m cartsnitch_common.seed --database-url "$${DATABASE_URL}"
|
||||
env:
|
||||
- name: DATABASE_URL
|
||||
valueFrom:
|
||||
secretKeyRef:
|
||||
name: cartsnitch-secrets
|
||||
key: database-url-pg
|
||||
optional: false
|
||||
resources:
|
||||
requests:
|
||||
cpu: 100m
|
||||
memory: 256Mi
|
||||
limits:
|
||||
cpu: 500m
|
||||
memory: 512Mi
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user