Compare commits
93
Commits
759a1c745e
..
mongo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3e9201afd | ||
|
|
da7ed41938 | ||
|
|
c9c1fa18a2 | ||
|
|
d4800be3b7 | ||
|
|
11f68c7f76 | ||
|
|
96992cf918 | ||
|
|
8e4f89f4ec | ||
|
|
cc1189cd9b | ||
|
|
541bed3a8a | ||
|
|
f7b00fc7c4 | ||
|
|
d910a6bc65 | ||
|
|
7f0326eff1 | ||
|
|
06d17e3d34 | ||
|
|
e2bb9cd6b9 | ||
|
|
d147bd6f27 | ||
|
|
ec4912aa4a | ||
|
|
0b3d1d5ed0 | ||
|
|
229b6b6f7a | ||
|
|
63e92756fe | ||
|
|
08dda6c6b0 | ||
|
|
0f7efc8961 | ||
|
|
f510dea09d | ||
|
|
a6944ad59c | ||
|
|
ad8a8bf867 | ||
|
|
5392e5af70 | ||
|
|
eb775ba7d8 | ||
|
|
082097b4f9 | ||
|
|
2e1a0ab2fa | ||
|
|
ce3d1b3d54 | ||
|
|
75d3d6dc39 | ||
|
|
3d882656e3 | ||
|
|
8308d205e8 | ||
|
|
e77254eabf | ||
|
|
a68a86a6a6 | ||
|
|
4ac83dcf17 | ||
|
|
ab0d32c6b0 | ||
|
|
28f5c43349 | ||
|
|
a2b464af7b | ||
|
|
6bf10fda2f | ||
|
|
c840825549 | ||
|
|
9936cfd544 | ||
|
|
b42c36ebdd | ||
|
|
395199f9b2 | ||
|
|
a37b259f47 | ||
|
|
0606b71890 | ||
|
|
b4fc4fc955 | ||
|
|
8774e85529 | ||
|
|
2b2f4f46c0 | ||
|
|
c5306a15d1 | ||
|
|
fa1a422747 | ||
|
|
4093e79e50 | ||
|
|
39a547ca9c | ||
|
|
127378797c | ||
|
|
db846f4e31 | ||
|
|
6d43fb23ad | ||
|
|
6a29e263aa | ||
|
|
89de4fd869 | ||
|
|
d34910faa7 | ||
|
|
1a8181b8e5 | ||
|
|
902e6cadc9 | ||
|
|
c9a4f92337 | ||
|
|
ea308b28a9 | ||
|
|
8907184fde | ||
|
|
6982fa561f | ||
|
|
bc481527c8 | ||
|
|
60dbb8a129 | ||
|
|
6b3e2cd9ba | ||
|
|
3d599243c7 | ||
|
|
cdfaf7ead1 | ||
|
|
8da61ce335 | ||
|
|
3d2577e4ec | ||
|
|
2c7e9b8b5e | ||
|
|
f48845c1d0 | ||
|
|
4ee5367742 | ||
|
|
ee16b49020 | ||
|
|
b529ddaa02 | ||
|
|
fd28c89cbf | ||
|
|
d7b1962903 | ||
|
|
9efbb455d7 | ||
|
|
d3ce54a1ff | ||
|
|
3d5f84579b | ||
|
|
5e4f7b030e | ||
|
|
91e3c45b5a | ||
|
|
0a6551368b | ||
|
|
cf2ac4b5e8 | ||
|
|
f5dfdfbb42 | ||
|
|
43d647a712 | ||
|
|
308bf0cc72 | ||
|
|
ad2bdf4c4f | ||
|
|
0d50a324a3 | ||
|
|
ea0166d198 | ||
|
|
3116295980 | ||
|
|
876d3c5531 |
@@ -44,15 +44,15 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version: "20.19.0"
|
node-version: "20.19.0"
|
||||||
cache: "npm"
|
cache: "npm"
|
||||||
cache-dependency-path: frontend/vue-app/package-lock.json
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
run: npm ci
|
run: npm ci
|
||||||
working-directory: frontend/vue-app
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Run frontend unit tests
|
- name: Run frontend unit tests
|
||||||
run: npm run test:unit --if-present
|
run: npm run test:unit --if-present
|
||||||
working-directory: frontend/vue-app
|
working-directory: frontend
|
||||||
|
|
||||||
- name: Build Backend Docker Image
|
- name: Build Backend Docker Image
|
||||||
run: |
|
run: |
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Build Frontend Docker Image
|
- name: Build Frontend Docker Image
|
||||||
run: |
|
run: |
|
||||||
docker build -t git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} ./frontend/vue-app
|
docker build -t git.ryankegel.com:3000/kegel/chores/frontend:${{ steps.vars.outputs.tag }} ./frontend
|
||||||
|
|
||||||
- name: Log in to Registry
|
- name: Log in to Registry
|
||||||
uses: docker/login-action@v2
|
uses: docker/login-action@v2
|
||||||
@@ -139,6 +139,18 @@ jobs:
|
|||||||
cat > .env << EOF
|
cat > .env << EOF
|
||||||
SECRET_KEY=${{ secrets.SECRET_KEY }}
|
SECRET_KEY=${{ secrets.SECRET_KEY }}
|
||||||
REFRESH_TOKEN_EXPIRY_DAYS=1
|
REFRESH_TOKEN_EXPIRY_DAYS=1
|
||||||
|
DIGEST_TOKEN_SECRET=${{ secrets.DIGEST_TOKEN_SECRET }}
|
||||||
|
VAPID_PUBLIC_KEY=${{ secrets.VAPID_PUBLIC_KEY }}
|
||||||
|
VAPID_PRIVATE_KEY=${{ secrets.VAPID_PRIVATE_KEY }}
|
||||||
|
SEED_EMAIL=${{ secrets.SEED_EMAIL }}
|
||||||
|
SEED_PASSWORD=${{ secrets.SEED_PASSWORD }}
|
||||||
|
SEED_PIN=${{ secrets.SEED_PIN }}
|
||||||
|
SEED_FIRST_NAME=${{ secrets.SEED_FIRST_NAME }}
|
||||||
|
SEED_LAST_NAME=${{ secrets.SEED_LAST_NAME }}
|
||||||
|
ADMIN_EMAIL=${{ secrets.ADMIN_EMAIL }}
|
||||||
|
ADMIN_PASSWORD=${{ secrets.ADMIN_PASSWORD }}
|
||||||
|
ADMIN_FIRST_NAME=${{ secrets.ADMIN_FIRST_NAME }}
|
||||||
|
ADMIN_LAST_NAME=${{ secrets.ADMIN_LAST_NAME }}
|
||||||
EOF
|
EOF
|
||||||
|
|
||||||
echo "SECRET_KEY is set: $(grep -q 'SECRET_KEY=' .env && echo YES || echo NO)"
|
echo "SECRET_KEY is set: $(grep -q 'SECRET_KEY=' .env && echo YES || echo NO)"
|
||||||
@@ -149,6 +161,13 @@ jobs:
|
|||||||
docker-compose -f docker-compose.test.yml pull
|
docker-compose -f docker-compose.test.yml pull
|
||||||
docker-compose -f docker-compose.test.yml up -d
|
docker-compose -f docker-compose.test.yml up -d
|
||||||
|
|
||||||
|
echo "Waiting for backend to be ready..."
|
||||||
|
sleep 10
|
||||||
|
echo "Seeding test user..."
|
||||||
|
docker-compose -f docker-compose.test.yml exec -T chores-test-app-backend python scripts/seed_test_user.py
|
||||||
|
echo "Creating admin user..."
|
||||||
|
docker-compose -f docker-compose.test.yml exec -T chores-test-app-backend python scripts/create_admin.py
|
||||||
|
|
||||||
- name: Send mail
|
- name: Send mail
|
||||||
if: always() # Runs on success or failure
|
if: always() # Runs on success or failure
|
||||||
uses: dawidd6/action-send-mail@v3
|
uses: dawidd6/action-send-mail@v3
|
||||||
|
|||||||
@@ -0,0 +1,503 @@
|
|||||||
|
name: Promote Master to Production
|
||||||
|
run-name: ${{ gitea.actor }} promoting ${{ github.event.inputs.target_ref || 'master' }} [${{ gitea.sha }}]
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
inputs:
|
||||||
|
target_ref:
|
||||||
|
description: "Git ref/branch to promote"
|
||||||
|
required: true
|
||||||
|
default: "master"
|
||||||
|
run_backend_tests:
|
||||||
|
description: "Run backend pytest gate"
|
||||||
|
required: true
|
||||||
|
default: "true"
|
||||||
|
run_frontend_tests:
|
||||||
|
description: "Run frontend unit test gate"
|
||||||
|
required: true
|
||||||
|
default: "true"
|
||||||
|
run_playwright_tests:
|
||||||
|
description: "Run Playwright E2E gate"
|
||||||
|
required: true
|
||||||
|
default: "true"
|
||||||
|
skip_tests:
|
||||||
|
description: "Skip the entire tests stage"
|
||||||
|
required: true
|
||||||
|
default: "false"
|
||||||
|
deploy:
|
||||||
|
description: "Run production deploy over SSH"
|
||||||
|
required: true
|
||||||
|
default: "true"
|
||||||
|
create_tag:
|
||||||
|
description: "Create and push git release tag after successful deploy"
|
||||||
|
required: true
|
||||||
|
default: "true"
|
||||||
|
require_manual_approval:
|
||||||
|
description: "Require approval token for deploy job"
|
||||||
|
required: true
|
||||||
|
default: "false"
|
||||||
|
approval_token:
|
||||||
|
description: "Approval token value when manual approval is required"
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
rollback_on_failed_healthcheck:
|
||||||
|
description: "Auto-rollback if post-deploy health checks fail"
|
||||||
|
required: true
|
||||||
|
default: "false"
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: promotion-production
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
prepare:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
outputs:
|
||||||
|
target_ref: ${{ steps.resolve.outputs.target_ref }}
|
||||||
|
version: ${{ steps.version.outputs.version }}
|
||||||
|
release_tag: ${{ steps.version.outputs.release_tag }}
|
||||||
|
commit_sha: ${{ steps.commit.outputs.commit_sha }}
|
||||||
|
steps:
|
||||||
|
- name: Resolve target ref
|
||||||
|
id: resolve
|
||||||
|
run: |
|
||||||
|
target_ref="${{ github.event.inputs.target_ref }}"
|
||||||
|
if [ -z "$target_ref" ]; then
|
||||||
|
target_ref="master"
|
||||||
|
fi
|
||||||
|
echo "target_ref=$target_ref" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
ref: ${{ steps.resolve.outputs.target_ref }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Resolve version and release tag
|
||||||
|
id: version
|
||||||
|
run: |
|
||||||
|
version=$(python -c "import sys; sys.path.append('./backend'); from config.version import BASE_VERSION; print(BASE_VERSION)")
|
||||||
|
echo "version=$version" >> "$GITHUB_OUTPUT"
|
||||||
|
echo "release_tag=v$version" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Resolve promoted commit SHA
|
||||||
|
id: commit
|
||||||
|
run: |
|
||||||
|
echo "commit_sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||||
|
|
||||||
|
- name: Guard against duplicate release tags
|
||||||
|
if: ${{ github.event.inputs.create_tag != 'false' }}
|
||||||
|
run: |
|
||||||
|
release_tag="${{ steps.version.outputs.release_tag }}"
|
||||||
|
if git ls-remote --exit-code --tags origin "refs/tags/${release_tag}" >/dev/null 2>&1; then
|
||||||
|
echo "Release tag ${release_tag} already exists on origin; aborting promotion."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 120
|
||||||
|
needs: prepare
|
||||||
|
steps:
|
||||||
|
- name: Skip tests stage
|
||||||
|
if: ${{ github.event.inputs.skip_tests == 'true' }}
|
||||||
|
run: echo "Skipping tests stage because skip_tests=true"
|
||||||
|
|
||||||
|
- name: Check out promoted ref
|
||||||
|
if: ${{ github.event.inputs.skip_tests != 'true' }}
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.prepare.outputs.target_ref }}
|
||||||
|
|
||||||
|
- name: Set up Python for backend tests
|
||||||
|
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_backend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
|
||||||
|
uses: actions/setup-python@v4
|
||||||
|
with:
|
||||||
|
python-version: "3.11"
|
||||||
|
|
||||||
|
- name: Install backend dependencies (runner python)
|
||||||
|
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_backend_tests != 'false' }}
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r backend/requirements.txt
|
||||||
|
|
||||||
|
- name: Run backend unit tests
|
||||||
|
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_backend_tests != 'false' }}
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
pytest -q
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: "20.19.0"
|
||||||
|
cache: "npm"
|
||||||
|
cache-dependency-path: frontend/package-lock.json
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
if: ${{ github.event.inputs.skip_tests != 'true' && (github.event.inputs.run_frontend_tests != 'false' || github.event.inputs.run_playwright_tests != 'false') }}
|
||||||
|
run: npm ci
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Run frontend unit tests
|
||||||
|
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_frontend_tests != 'false' }}
|
||||||
|
run: npm run test:unit --if-present
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Create backend venv for Playwright webServer
|
||||||
|
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
|
||||||
|
run: |
|
||||||
|
python -m venv backend/.venv
|
||||||
|
backend/.venv/bin/python -m pip install --upgrade pip
|
||||||
|
backend/.venv/bin/python -m pip install -r backend/requirements.txt
|
||||||
|
|
||||||
|
- name: Install Playwright browsers
|
||||||
|
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
|
||||||
|
run: npx playwright install --with-deps
|
||||||
|
working-directory: frontend
|
||||||
|
|
||||||
|
- name: Run Playwright tests
|
||||||
|
if: ${{ github.event.inputs.skip_tests != 'true' && github.event.inputs.run_playwright_tests != 'false' }}
|
||||||
|
run: npx playwright test --reporter=line
|
||||||
|
working-directory: frontend
|
||||||
|
env:
|
||||||
|
CI: "true"
|
||||||
|
PLAYWRIGHT_BASE_URL: "https://localhost:5173"
|
||||||
|
E2E_ACCESS_TOKEN_EXPIRY_MINUTES: "180"
|
||||||
|
|
||||||
|
deploy:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 45
|
||||||
|
needs:
|
||||||
|
- prepare
|
||||||
|
- tests
|
||||||
|
if: ${{ github.event.inputs.deploy != 'false' }}
|
||||||
|
steps:
|
||||||
|
- name: Validate manual approval token
|
||||||
|
if: ${{ github.event.inputs.require_manual_approval != 'false' }}
|
||||||
|
run: |
|
||||||
|
if [ -z "${{ secrets.PROD_APPROVAL_TOKEN }}" ]; then
|
||||||
|
echo "PROD_APPROVAL_TOKEN secret is required when manual approval is enabled."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ -z "${{ github.event.inputs.approval_token }}" ]; then
|
||||||
|
echo "approval_token input is required when manual approval is enabled."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [ "${{ github.event.inputs.approval_token }}" != "${{ secrets.PROD_APPROVAL_TOKEN }}" ]; then
|
||||||
|
echo "Approval token does not match PROD_APPROVAL_TOKEN."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Deploy to production homeserver
|
||||||
|
uses: appleboy/ssh-action@v1.0.3
|
||||||
|
with:
|
||||||
|
host: ${{ secrets.DEPLOY_PROD_HOST }}
|
||||||
|
username: ${{ secrets.DEPLOY_PROD_USER }}
|
||||||
|
key: ${{ secrets.PROD_SSH_PRIVATE_KEY }}
|
||||||
|
port: 22
|
||||||
|
script: |
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_dir="${{ secrets.DEPLOY_PROD_PATH }}"
|
||||||
|
if [ -z "$repo_dir" ]; then
|
||||||
|
repo_dir="$HOME/chore"
|
||||||
|
fi
|
||||||
|
|
||||||
|
repo_parent=$(dirname "$repo_dir")
|
||||||
|
if [ ! -d "$repo_parent" ]; then
|
||||||
|
mkdir -p "$repo_parent"
|
||||||
|
fi
|
||||||
|
if [ ! -w "$repo_parent" ]; then
|
||||||
|
echo "Deploy user $(whoami) cannot write to ${repo_parent}."
|
||||||
|
echo "Set DEPLOY_PROD_PATH to a writable location or grant write access before rerunning promotion."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -d "$repo_dir/.git" ]; then
|
||||||
|
cd "$repo_dir"
|
||||||
|
git fetch --all --tags
|
||||||
|
else
|
||||||
|
git clone --branch "${{ needs.prepare.outputs.target_ref }}" https://git.ryankegel.com/ryan/chore.git "$repo_dir"
|
||||||
|
cd "$repo_dir"
|
||||||
|
fi
|
||||||
|
|
||||||
|
git checkout "${{ needs.prepare.outputs.target_ref }}"
|
||||||
|
git reset --hard "origin/${{ needs.prepare.outputs.target_ref }}"
|
||||||
|
|
||||||
|
timestamp=$(date +%Y%m%d-%H%M%S)
|
||||||
|
mkdir -p backups
|
||||||
|
|
||||||
|
# Capture currently running images for rollback.
|
||||||
|
backend_prev_id=""
|
||||||
|
frontend_prev_id=""
|
||||||
|
if docker ps --format '{{.Names}}' | grep -q '^chores-app-backend-prod$'; then
|
||||||
|
backend_prev_id=$(docker inspect -f '{{.Image}}' chores-app-backend-prod)
|
||||||
|
docker tag "$backend_prev_id" git.ryankegel.com:3000/kegel/chores/backend:predeploy-backup || true
|
||||||
|
fi
|
||||||
|
if docker ps --format '{{.Names}}' | grep -q '^chores-app-frontend-prod$'; then
|
||||||
|
frontend_prev_id=$(docker inspect -f '{{.Image}}' chores-app-frontend-prod)
|
||||||
|
docker tag "$frontend_prev_id" git.ryankegel.com:3000/kegel/chores/frontend:predeploy-backup || true
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > .rollback-meta << EOF
|
||||||
|
BACKEND_PREV_ID=${backend_prev_id}
|
||||||
|
FRONTEND_PREV_ID=${frontend_prev_id}
|
||||||
|
CREATED_AT=${timestamp}
|
||||||
|
PROMOTED_SHA=${{ needs.prepare.outputs.commit_sha }}
|
||||||
|
RELEASE_TAG=${{ needs.prepare.outputs.release_tag }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
# Backup TinyDB volume before replacement.
|
||||||
|
docker run --rm \
|
||||||
|
-v chores-app-backend-data:/data \
|
||||||
|
-v "$PWD/backups:/backup" \
|
||||||
|
alpine sh -c "tar czf /backup/chores-backend-data-${timestamp}.tar.gz -C /data ." || true
|
||||||
|
|
||||||
|
# Keep long-lived cryptographic values stable; fall back to previous .env if secrets are absent.
|
||||||
|
PREV_SECRET_KEY=""
|
||||||
|
PREV_DIGEST_TOKEN_SECRET=""
|
||||||
|
PREV_VAPID_PUBLIC_KEY=""
|
||||||
|
PREV_VAPID_PRIVATE_KEY=""
|
||||||
|
PREV_REFRESH_TOKEN_EXPIRY_DAYS=""
|
||||||
|
if [ -f .env ]; then
|
||||||
|
PREV_SECRET_KEY=$(grep '^SECRET_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||||
|
PREV_DIGEST_TOKEN_SECRET=$(grep '^DIGEST_TOKEN_SECRET=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||||
|
PREV_VAPID_PUBLIC_KEY=$(grep '^VAPID_PUBLIC_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||||
|
PREV_VAPID_PRIVATE_KEY=$(grep '^VAPID_PRIVATE_KEY=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||||
|
PREV_REFRESH_TOKEN_EXPIRY_DAYS=$(grep '^REFRESH_TOKEN_EXPIRY_DAYS=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||||
|
fi
|
||||||
|
|
||||||
|
SECRET_KEY_VALUE="${{ secrets.PROD_SECRET_KEY }}"
|
||||||
|
DIGEST_TOKEN_SECRET_VALUE="${{ secrets.PROD_DIGEST_TOKEN_SECRET }}"
|
||||||
|
VAPID_PUBLIC_KEY_VALUE="${{ secrets.PROD_VAPID_PUBLIC_KEY }}"
|
||||||
|
VAPID_PRIVATE_KEY_VALUE="${{ secrets.PROD_VAPID_PRIVATE_KEY }}"
|
||||||
|
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="${{ secrets.PROD_REFRESH_TOKEN_EXPIRY_DAYS }}"
|
||||||
|
|
||||||
|
if [ -z "$SECRET_KEY_VALUE" ]; then
|
||||||
|
if [ -n "$PREV_SECRET_KEY" ]; then
|
||||||
|
SECRET_KEY_VALUE="$PREV_SECRET_KEY"
|
||||||
|
else
|
||||||
|
SECRET_KEY_VALUE=$(openssl rand -hex 32)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$DIGEST_TOKEN_SECRET_VALUE" ]; then
|
||||||
|
if [ -n "$PREV_DIGEST_TOKEN_SECRET" ]; then
|
||||||
|
DIGEST_TOKEN_SECRET_VALUE="$PREV_DIGEST_TOKEN_SECRET"
|
||||||
|
else
|
||||||
|
DIGEST_TOKEN_SECRET_VALUE=$(openssl rand -hex 32)
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$VAPID_PUBLIC_KEY_VALUE" ] && [ -n "$PREV_VAPID_PUBLIC_KEY" ]; then
|
||||||
|
VAPID_PUBLIC_KEY_VALUE="$PREV_VAPID_PUBLIC_KEY"
|
||||||
|
fi
|
||||||
|
if [ -z "$VAPID_PRIVATE_KEY_VALUE" ] && [ -n "$PREV_VAPID_PRIVATE_KEY" ]; then
|
||||||
|
VAPID_PRIVATE_KEY_VALUE="$PREV_VAPID_PRIVATE_KEY"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$VAPID_PUBLIC_KEY_VALUE" ] || [ -z "$VAPID_PRIVATE_KEY_VALUE" ]; then
|
||||||
|
echo "VAPID keys are required (set PROD_VAPID_PUBLIC_KEY/PROD_VAPID_PRIVATE_KEY or keep previous .env values)."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -z "$REFRESH_TOKEN_EXPIRY_DAYS_VALUE" ]; then
|
||||||
|
if [ -n "$PREV_REFRESH_TOKEN_EXPIRY_DAYS" ]; then
|
||||||
|
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="$PREV_REFRESH_TOKEN_EXPIRY_DAYS"
|
||||||
|
else
|
||||||
|
REFRESH_TOKEN_EXPIRY_DAYS_VALUE="90"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! printf '%s' "$REFRESH_TOKEN_EXPIRY_DAYS_VALUE" | grep -Eq '^[0-9]+$'; then
|
||||||
|
echo "REFRESH_TOKEN_EXPIRY_DAYS must be a positive integer, got: $REFRESH_TOKEN_EXPIRY_DAYS_VALUE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
cat > .env << EOF
|
||||||
|
FRONTEND_URL=${{ secrets.PROD_FRONTEND_URL }}
|
||||||
|
BACKEND_HOST_PORT=${{ secrets.PROD_BACKEND_HOST_PORT }}
|
||||||
|
FRONTEND_HOST_PORT=${{ secrets.PROD_FRONTEND_HOST_PORT }}
|
||||||
|
SECRET_KEY=${SECRET_KEY_VALUE}
|
||||||
|
REFRESH_TOKEN_EXPIRY_DAYS=${REFRESH_TOKEN_EXPIRY_DAYS_VALUE}
|
||||||
|
DIGEST_TOKEN_SECRET=${DIGEST_TOKEN_SECRET_VALUE}
|
||||||
|
VAPID_PUBLIC_KEY=${VAPID_PUBLIC_KEY_VALUE}
|
||||||
|
VAPID_PRIVATE_KEY=${VAPID_PRIVATE_KEY_VALUE}
|
||||||
|
ADMIN_EMAIL=${{ secrets.PROD_ADMIN_EMAIL }}
|
||||||
|
ADMIN_PASSWORD=${{ secrets.PROD_ADMIN_PASSWORD }}
|
||||||
|
ADMIN_FIRST_NAME=${{ secrets.PROD_ADMIN_FIRST_NAME }}
|
||||||
|
ADMIN_LAST_NAME=${{ secrets.PROD_ADMIN_LAST_NAME }}
|
||||||
|
EOF
|
||||||
|
|
||||||
|
chmod 600 .env .rollback-meta
|
||||||
|
|
||||||
|
backend_host_port=$(grep '^BACKEND_HOST_PORT=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||||
|
frontend_host_port=$(grep '^FRONTEND_HOST_PORT=' .env | head -n1 | cut -d '=' -f2- || true)
|
||||||
|
if [ -z "$backend_host_port" ]; then
|
||||||
|
backend_host_port="5001"
|
||||||
|
fi
|
||||||
|
if [ -z "$frontend_host_port" ]; then
|
||||||
|
frontend_host_port="4601"
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
wait_for_container_removal() {
|
||||||
|
container_name="$1"
|
||||||
|
attempts=0
|
||||||
|
while docker ps -a --format '{{.Names}}' | grep -qx "$container_name"; do
|
||||||
|
attempts=$((attempts + 1))
|
||||||
|
if [ "$attempts" -ge 12 ]; then
|
||||||
|
echo "Container ${container_name} still exists after waiting for teardown."
|
||||||
|
docker ps -a --filter "name=^${container_name}$"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Waiting for ${container_name} to be removed..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_port_release() {
|
||||||
|
port="$1"
|
||||||
|
attempts=0
|
||||||
|
while docker ps -a --format '{{.ID}} {{.Names}} {{.Ports}} {{.Status}}' | grep -E "(^|[[:space:]])0\.0\.0\.0:${port}->|:::${port}->" >/dev/null 2>&1; do
|
||||||
|
attempts=$((attempts + 1))
|
||||||
|
if [ "$attempts" -ge 12 ]; then
|
||||||
|
echo "Port ${port} is still allocated after teardown. Blocking container(s):"
|
||||||
|
docker ps -a --format '{{.ID}} {{.Names}} {{.Ports}} {{.Status}}' | grep -E "(^|[[:space:]])0\.0\.0\.0:${port}->|:::${port}->" || true
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
echo "Waiting for port ${port} to be released..."
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
wait_for_container_removal chores-app-frontend-prod
|
||||||
|
wait_for_container_removal chores-app-backend-prod
|
||||||
|
wait_for_port_release "$frontend_host_port"
|
||||||
|
wait_for_port_release "$backend_host_port"
|
||||||
|
|
||||||
|
docker-compose pull
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
sleep 20
|
||||||
|
|
||||||
|
backend_running=$(docker inspect -f '{{.State.Running}}' chores-app-backend-prod 2>/dev/null || echo false)
|
||||||
|
frontend_running=$(docker inspect -f '{{.State.Running}}' chores-app-frontend-prod 2>/dev/null || echo false)
|
||||||
|
|
||||||
|
backend_ok=false
|
||||||
|
if curl -fsS "http://localhost:${backend_host_port}/version" >/dev/null 2>&1; then
|
||||||
|
backend_ok=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
frontend_ok=false
|
||||||
|
if curl -fsS "http://localhost:${frontend_host_port}/" >/dev/null 2>&1; then
|
||||||
|
frontend_ok=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$backend_running" != "true" ] || [ "$frontend_running" != "true" ] || [ "$backend_ok" != "true" ] || [ "$frontend_ok" != "true" ]; then
|
||||||
|
echo "Post-deploy health check failed."
|
||||||
|
docker-compose ps
|
||||||
|
echo "--- Backend logs (last 200 lines) ---"
|
||||||
|
docker logs --tail 200 chores-app-backend-prod || true
|
||||||
|
echo "--- Frontend logs (last 120 lines) ---"
|
||||||
|
docker logs --tail 120 chores-app-frontend-prod || true
|
||||||
|
|
||||||
|
if [ "${{ github.event.inputs.rollback_on_failed_healthcheck }}" = "true" ]; then
|
||||||
|
echo "Attempting rollback using predeploy-backup image tags..."
|
||||||
|
docker tag git.ryankegel.com:3000/kegel/chores/backend:predeploy-backup git.ryankegel.com:3000/kegel/chores/backend:latest || true
|
||||||
|
docker tag git.ryankegel.com:3000/kegel/chores/frontend:predeploy-backup git.ryankegel.com:3000/kegel/chores/frontend:latest || true
|
||||||
|
docker-compose up -d
|
||||||
|
sleep 20
|
||||||
|
curl -fsS "http://localhost:${backend_host_port}/version" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
docker-compose ps
|
||||||
|
|
||||||
|
create-release-tag:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 10
|
||||||
|
needs:
|
||||||
|
- prepare
|
||||||
|
- tests
|
||||||
|
- deploy
|
||||||
|
if: ${{ github.event.inputs.create_tag != 'false' && github.event.inputs.deploy != 'false' }}
|
||||||
|
steps:
|
||||||
|
- name: Check out promoted ref
|
||||||
|
uses: actions/checkout@v3
|
||||||
|
with:
|
||||||
|
ref: ${{ needs.prepare.outputs.target_ref }}
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Create and push annotated release tag
|
||||||
|
run: |
|
||||||
|
tag_name="${{ needs.prepare.outputs.release_tag }}"
|
||||||
|
git config user.name "gitea-actions"
|
||||||
|
git config user.email "gitea-actions@local"
|
||||||
|
git tag -a "$tag_name" -m "Release $tag_name"
|
||||||
|
git push origin "$tag_name"
|
||||||
|
|
||||||
|
notify-on-failure:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
needs:
|
||||||
|
- prepare
|
||||||
|
- tests
|
||||||
|
- deploy
|
||||||
|
- create-release-tag
|
||||||
|
if: ${{ always() }}
|
||||||
|
steps:
|
||||||
|
- name: Send email when promotion fails
|
||||||
|
if: ${{ needs.prepare.result == 'failure' || needs.tests.result == 'failure' || needs.deploy.result == 'failure' || needs.create-release-tag.result == 'failure' }}
|
||||||
|
uses: dawidd6/action-send-mail@v3
|
||||||
|
with:
|
||||||
|
server_address: smtp.gmail.com
|
||||||
|
server_port: 465
|
||||||
|
username: ${{ secrets.MAIL_USER }}
|
||||||
|
password: ${{ secrets.MAIL_PASSWORD }}
|
||||||
|
secure: true
|
||||||
|
to: ${{ secrets.MAIL_TO }}
|
||||||
|
from: Gitea <git@git.ryankegel.com>
|
||||||
|
subject: Promotion failed - ${{ gitea.repository }} [${{ needs.prepare.outputs.target_ref }}@${{ needs.prepare.outputs.commit_sha }}]
|
||||||
|
convert_markdown: true
|
||||||
|
html_body: |
|
||||||
|
### Production promotion failed
|
||||||
|
|
||||||
|
- Repository: ${{ gitea.repository }}
|
||||||
|
- Ref: ${{ needs.prepare.outputs.target_ref }}
|
||||||
|
- Commit: ${{ needs.prepare.outputs.commit_sha }}
|
||||||
|
- Intended tag: ${{ needs.prepare.outputs.release_tag }}
|
||||||
|
- prepare: ${{ needs.prepare.result }}
|
||||||
|
- tests: ${{ needs.tests.result }}
|
||||||
|
- deploy: ${{ needs.deploy.result }}
|
||||||
|
- tag: ${{ needs.create-release-tag.result }}
|
||||||
|
|
||||||
|
- name: Send email when promotion succeeds
|
||||||
|
if: ${{ needs.prepare.result == 'success' && needs.tests.result == 'success' && (needs.deploy.result == 'success' || needs.deploy.result == 'skipped') && (needs.create-release-tag.result == 'success' || needs.create-release-tag.result == 'skipped') }}
|
||||||
|
uses: dawidd6/action-send-mail@v3
|
||||||
|
with:
|
||||||
|
server_address: smtp.gmail.com
|
||||||
|
server_port: 465
|
||||||
|
username: ${{ secrets.MAIL_USER }}
|
||||||
|
password: ${{ secrets.MAIL_PASSWORD }}
|
||||||
|
secure: true
|
||||||
|
to: ${{ secrets.MAIL_TO }}
|
||||||
|
from: Gitea <git@git.ryankegel.com>
|
||||||
|
subject: Promotion succeeded - ${{ gitea.repository }} [${{ needs.prepare.outputs.target_ref }}@${{ needs.prepare.outputs.commit_sha }}]
|
||||||
|
convert_markdown: true
|
||||||
|
html_body: |
|
||||||
|
### Production promotion succeeded
|
||||||
|
|
||||||
|
- Repository: ${{ gitea.repository }}
|
||||||
|
- Ref: ${{ needs.prepare.outputs.target_ref }}
|
||||||
|
- Commit: ${{ needs.prepare.outputs.commit_sha }}
|
||||||
|
- Release tag: ${{ needs.prepare.outputs.release_tag }}
|
||||||
|
- prepare: ${{ needs.prepare.result }}
|
||||||
|
- tests: ${{ needs.tests.result }}
|
||||||
|
- deploy: ${{ needs.deploy.result }}
|
||||||
|
- tag: ${{ needs.create-release-tag.result }}
|
||||||
@@ -17,7 +17,7 @@ You are a Senior UI/UX Architect specializing in clean, highly reactive web appl
|
|||||||
|
|
||||||
This is the **Reward** app — a family chore/reward tracker. Vue 3 (Composition API / `<script setup lang="ts">`) frontend. Key files:
|
This is the **Reward** app — a family chore/reward tracker. Vue 3 (Composition API / `<script setup lang="ts">`) frontend. Key files:
|
||||||
|
|
||||||
- Theme tokens: `frontend/vue-app/src/assets/colors.css` — **always read this first** when reviewing a component.
|
- Theme tokens: `frontend/src/assets/colors.css` — **always read this first** when reviewing a component.
|
||||||
- Layout wrappers: `ParentLayout` (admin views) and `ChildLayout` (child dashboard views).
|
- Layout wrappers: `ParentLayout` (admin views) and `ChildLayout` (child dashboard views).
|
||||||
- All `.vue` files use `<style scoped>`. Child component styling uses `:deep()` selectors.
|
- All `.vue` files use `<style scoped>`. Child component styling uses `:deep()` selectors.
|
||||||
- File order within `.vue` files: `<template>`, then `<script>`, then `<style>`.
|
- File order within `.vue` files: `<template>`, then `<script>`, then `<style>`.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
- **Stack**: Flask (Python, backend) + Vue 3 (TypeScript, frontend) + TinyDB (JSON, thread-safe, see `db/`).
|
- **Stack**: Flask (Python, backend) + Vue 3 (TypeScript, frontend) + TinyDB (JSON, thread-safe, see `db/`).
|
||||||
- **API**: RESTful endpoints in `api/`, grouped by entity (child, reward, task, user, image, etc). Each API file maps to a business domain.
|
- **API**: RESTful endpoints in `api/`, grouped by entity (child, reward, task, user, image, etc). Each API file maps to a business domain.
|
||||||
- **Nginx Proxy**: Frontend nginx proxies `/api/*` to backend, stripping the `/api` prefix. Backend endpoints should NOT include `/api` in their route definitions. Example: Backend defines `@app.route('/user')`, frontend calls `/api/user`.
|
- **Nginx Proxy**: Frontend nginx proxies `/api/*` to backend, stripping the `/api` prefix. Backend endpoints should NOT include `/api` in their route definitions. Example: Backend defines `@app.route('/user')`, frontend calls `/api/user`.
|
||||||
- **Models**: Maintain strict 1:1 mapping between Python `@dataclass`es (`backend/models/`) and TypeScript interfaces (`frontend/vue-app/src/common/models.ts`).
|
- **Models**: Maintain strict 1:1 mapping between Python `@dataclass`es (`backend/models/`) and TypeScript interfaces (`frontend/src/common/models.ts`).
|
||||||
- **Database**: Use TinyDB with `from_dict()`/`to_dict()` for serialization. All logic should operate on model instances, not raw dicts.
|
- **Database**: Use TinyDB with `from_dict()`/`to_dict()` for serialization. All logic should operate on model instances, not raw dicts.
|
||||||
- **Events**: Real-time updates via Server-Sent Events (SSE). Every mutation (add/edit/delete/trigger) must call `send_event_for_current_user` (see `backend/events/`).
|
- **Events**: Real-time updates via Server-Sent Events (SSE). Every mutation (add/edit/delete/trigger) must call `send_event_for_current_user` (see `backend/events/`).
|
||||||
- **Changes**: Do not use comments to replace code. All changes must be reflected in both backend and frontend files as needed.
|
- **Changes**: Do not use comments to replace code. All changes must be reflected in both backend and frontend files as needed.
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
|
|
||||||
## 🚦 Frontend Logic & Event Bus
|
## 🚦 Frontend Logic & Event Bus
|
||||||
|
|
||||||
- **SSE Event Management**: Register listeners in `onMounted`, clean up in `onUnmounted`. Listen for events like `child_task_triggered`, `child_reward_request`, `task_modified`, etc. See `frontend/vue-app/src/common/backendEvents.ts` and `components/BackendEventsListener.vue`.
|
- **SSE Event Management**: Register listeners in `onMounted`, clean up in `onUnmounted`. Listen for events like `child_task_triggered`, `child_reward_request`, `task_modified`, etc. See `frontend/src/common/backendEvents.ts` and `components/BackendEventsListener.vue`.
|
||||||
- **Layout Hierarchy**: Use `ParentLayout` for admin/management, `ChildLayout` for dashboard/focus views.
|
- **Layout Hierarchy**: Use `ParentLayout` for admin/management, `ChildLayout` for dashboard/focus views.
|
||||||
|
|
||||||
## ⚖️ Business Logic & Safeguards
|
## ⚖️ Business Logic & Safeguards
|
||||||
@@ -37,9 +37,9 @@
|
|||||||
|
|
||||||
- **Backend**: Run Flask with `python -m flask run --host=0.0.0.0 --port=5000` from the `backend/` directory. Main entry: `backend/main.py`.
|
- **Backend**: Run Flask with `python -m flask run --host=0.0.0.0 --port=5000` from the `backend/` directory. Main entry: `backend/main.py`.
|
||||||
- **Virtual Env**: Python is running from a virtual environment located at `backend/.venv/`.
|
- **Virtual Env**: Python is running from a virtual environment located at `backend/.venv/`.
|
||||||
- **Frontend**: From `frontend/vue-app/`, run `npm install` then `npm run dev`.
|
- **Frontend**: From `frontend/`, run `npm install` then `npm run dev`.
|
||||||
- **Tests**: Run backend tests with `pytest` in `backend/tests/`. Frontend component tests: `npm run test` in `frontend/vue-app/components/__tests__/`. E2E tests: `npx playwright test` from `frontend/vue-app/` — requires both servers running (use the `flask-backend` and `vue-frontend` skills).
|
- **Tests**: Run backend tests with `pytest` in `backend/tests/`. Frontend component tests: `npm run test` in `frontend/components/__tests__/`. E2E tests: `npx playwright test` from `frontend/` — requires both servers running (use the `flask-backend` and `vue-frontend` skills).
|
||||||
- **E2E Setup**: Playwright config is at `frontend/vue-app/playwright.config.ts`. Tests live in `frontend/vue-app/tests/`. The `globalSetup` in `playwright.config.ts` seeds the database and logs in once; all tests receive a pre-authenticated session via `storageState` — do NOT navigate to `/auth/login` in tests. Import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` rather than hardcoding credentials. The backend must be started with `DB_ENV=e2e DATA_ENV=e2e` (the `flask-backend` skill does this) so test data goes to `backend/test_data/` and never touches production data.
|
- **E2E Setup**: Playwright config is at `frontend/playwright.config.ts`. Tests live in `frontend/tests/`. The `globalSetup` in `playwright.config.ts` seeds the database and logs in once; all tests receive a pre-authenticated session via `storageState` — do NOT navigate to `/auth/login` in tests. Import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` rather than hardcoding credentials. The backend must be started with `DB_ENV=e2e DATA_ENV=e2e` (the `flask-backend` skill does this) so test data goes to `backend/test_data/` and never touches production data.
|
||||||
- **Debugging**: Use VS Code launch configs or run Flask/Vue dev servers directly. For SSE, use browser dev tools to inspect event streams.
|
- **Debugging**: Use VS Code launch configs or run Flask/Vue dev servers directly. For SSE, use browser dev tools to inspect event streams.
|
||||||
|
|
||||||
## 📁 Key Files & Directories
|
## 📁 Key Files & Directories
|
||||||
@@ -48,10 +48,10 @@
|
|||||||
- `backend/models/` — Python dataclasses (business logic, serialization)
|
- `backend/models/` — Python dataclasses (business logic, serialization)
|
||||||
- `backend/db/` — TinyDB setup and helpers
|
- `backend/db/` — TinyDB setup and helpers
|
||||||
- `backend/events/` — SSE event types, broadcaster, payloads
|
- `backend/events/` — SSE event types, broadcaster, payloads
|
||||||
- `frontend/vue-app/` — Vue 3 frontend (see `src/common/`, `src/components/`, `src/layout/`) - Where tests are run from
|
- `frontend/` — Vue 3 frontend (see `src/common/`, `src/components/`, `src/layout/`) - Where tests are run from
|
||||||
- `frontend/vue-app/src/common/models.ts` — TypeScript interfaces (mirror Python models)
|
- `frontend/src/common/models.ts` — TypeScript interfaces (mirror Python models)
|
||||||
- `frontend/vue-app/src/common/api.ts` — API helpers, error parsing, validation
|
- `frontend/src/common/api.ts` — API helpers, error parsing, validation
|
||||||
- `frontend/vue-app/src/common/backendEvents.ts` — SSE event types and handlers
|
- `frontend/src/common/backendEvents.ts` — SSE event types and handlers
|
||||||
|
|
||||||
## 🧠 Integration & Cross-Component Patterns
|
## 🧠 Integration & Cross-Component Patterns
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: playwright-default
|
||||||
|
description: Provides Playwright test generation and analysis for E2E testing.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Role: Senior QA Automation Engineer
|
||||||
|
|
||||||
|
You are a Playwright expert. Your goal is to create robust, flake-free E2E tests.
|
||||||
|
|
||||||
|
# Test Implementation & Healing Workflow
|
||||||
|
|
||||||
|
When you receive a test plan:
|
||||||
|
|
||||||
|
1. **Implement**: Generate the `.spec.ts` files in `/tests` using standard Playwright patterns.
|
||||||
|
2. **Verify**: Once files are written, execute the following command in the terminal:
|
||||||
|
`npx playwright test`
|
||||||
|
3. **Analyze & Repair**:
|
||||||
|
- If the playwright-healer skill proposes a patch, review it.
|
||||||
|
- If the test still fails after healing, check the **Flask backend logs** to see if it's an API error rather than a UI error.
|
||||||
|
4. **Final Check**: Only mark the task as "Complete" once `npx playwright test` returns a clean pass.
|
||||||
|
|
||||||
|
## Rules of Engagement
|
||||||
|
|
||||||
|
1. **Locators:** Prioritize `getByRole`, `getByLabel`, and `getByText`. Avoid CSS selectors unless necessary.
|
||||||
|
2. **Page Objects:** Always use the Page Object Model (POM). Check `tests/pages/` for existing objects before creating new ones.
|
||||||
|
3. **Environment:** The app runs at `https://localhost:5173` (HTTPS — self-signed cert). The backend runs at `http://localhost:5000`.
|
||||||
|
4. **Authentication:** Auth is handled globally via `storageState`. Do NOT navigate to `/auth/login` in any test — you are already logged in. Never hardcode credentials; import `E2E_EMAIL` and `E2E_PASSWORD` from `tests/global-setup.ts` if needed.
|
||||||
@@ -8,10 +8,10 @@ disable-model-invocation: true
|
|||||||
|
|
||||||
Use this skill when the user wants to "start the frontend," "run vue," or "launch the dev server."
|
Use this skill when the user wants to "start the frontend," "run vue," or "launch the dev server."
|
||||||
|
|
||||||
1. **Verify Directory:** Navigate to `./frontend/vue-app`.
|
1. **Verify Directory:** Navigate to `./frontend`.
|
||||||
- _Self-Correction:_ If the directory doesn't exist, search the workspace for `package.json` files and ask for clarification.
|
- _Self-Correction:_ If the directory doesn't exist, search the workspace for `package.json` files and ask for clarification.
|
||||||
|
|
||||||
2. **Check Dependencies:** - Before running, check if `node_modules` exists in `./frontend/vue-app`.
|
2. **Check Dependencies:** - Before running, check if `node_modules` exists in `./frontend`.
|
||||||
- If missing, ask the user: "Should I run `npm install` first?"
|
- If missing, ask the user: "Should I run `npm install` first?"
|
||||||
|
|
||||||
3. **Execution:** - Run the command: `npm run dev`
|
3. **Execution:** - Run the command: `npm run dev`
|
||||||
|
|||||||
+9
-3
@@ -1,8 +1,14 @@
|
|||||||
.env
|
.env
|
||||||
backend/test_data/
|
backend/test_data/
|
||||||
logs/
|
logs/
|
||||||
resources/
|
frontend/resources/
|
||||||
frontend/vue-app/playwright-report/
|
frontend/playwright-report/
|
||||||
frontend/vue-app/test-results/
|
frontend/test-results/
|
||||||
backend/test-results/
|
backend/test-results/
|
||||||
.vscode/keybindings.json
|
.vscode/keybindings.json
|
||||||
|
.DS_Store
|
||||||
|
**/.DS_Store
|
||||||
|
frontend/cert.pem
|
||||||
|
frontend/key.pem
|
||||||
|
tmp/
|
||||||
|
backend/.env*
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: architect
|
||||||
|
description: "Defines system requirements, data contracts, and architectural blueprints."
|
||||||
|
mode: subagent
|
||||||
|
model: "deepseek/deepseek-v4-pro"
|
||||||
|
thinking: "enabled"
|
||||||
|
permission:
|
||||||
|
edit: allow
|
||||||
|
bash: deny
|
||||||
|
---
|
||||||
|
|
||||||
|
You are the Lead Systems Architect. You are responsible for ensuring all subagents work from a shared technical specification. Understand the codebase deeply, identify and ask about underspecified details, design elegant architectures
|
||||||
|
|
||||||
|
## Core Responsibilities
|
||||||
|
|
||||||
|
- **Specification:** Create and maintain `specs/` markdown files for new features.
|
||||||
|
- **Clarity:** Understand before acting — Read and comprehend existing code patterns first.
|
||||||
|
- **Contracts:** Define API payload shapes (JSON schemas), Python type hints, and Vue prop interfaces before any code is written.
|
||||||
|
- **Decision Log:** Maintain a `decisions.md` file to track _why_ certain architectural choices were made (e.g., why you chose a specific Vue state management pattern).
|
||||||
|
|
||||||
|
## Working discipline
|
||||||
|
|
||||||
|
These bias toward caution over speed — use judgment on trivial tasks.
|
||||||
|
|
||||||
|
- **Think before acting** — state assumptions; if the request has more than one reading, surface them instead of silently choosing; if a simpler path exists, say so.
|
||||||
|
- **Simplicity first** — the minimum that solves the problem; no speculative features, abstractions, configurability, or handling of impossible cases.
|
||||||
|
- **Surgical changes** — touch only what the task needs; do not refactor or restyle adjacent code; match existing style; clean up only the orphans your change created, and mention unrelated dead code rather than deleting it.
|
||||||
|
- **Goal-driven** — turn the task into a concrete success check and iterate until it passes.
|
||||||
|
|
||||||
|
## Phase 1: Discovery
|
||||||
|
|
||||||
|
Goal: Understand what needs to be built.
|
||||||
|
|
||||||
|
1. Create a todo list covering all seven phases.
|
||||||
|
2. If the feature is unclear, ask the user:
|
||||||
|
- What problem are they solving?
|
||||||
|
- What should the feature do?
|
||||||
|
- Any constraints or requirements?
|
||||||
|
3. Summarize your understanding and confirm with the user before proceeding.
|
||||||
|
|
||||||
|
## Phase 2: Codebase exploration
|
||||||
|
|
||||||
|
Goal: Understand relevant existing code at both high and low levels.
|
||||||
|
|
||||||
|
1. Dispatch 2–3 `code-explorer` sub-tasks in parallel. Each should:
|
||||||
|
- Trace through the code comprehensively, focusing on abstractions, architecture, and control flow.
|
||||||
|
- Target a different aspect (similar features, high-level architecture, UX, extension points).
|
||||||
|
- Return a list of 5–10 key files to read.
|
||||||
|
2. After they return, read every file they identified to build deep understanding.
|
||||||
|
3. Present a comprehensive summary of findings and patterns to the user.
|
||||||
|
|
||||||
|
## Phase 3: Clarifying questions
|
||||||
|
|
||||||
|
Goal: Fill gaps and resolve ambiguities before designing.
|
||||||
|
|
||||||
|
**This is one of the most important phases. Do not skip.**
|
||||||
|
|
||||||
|
1. Review the codebase findings and the original feature request.
|
||||||
|
2. Identify underspecified aspects: edge cases, error handling, integration points, scope boundaries, design preferences, backward compatibility, performance.
|
||||||
|
3. Present all questions to the user as a clear, organized list.
|
||||||
|
4. **Wait for answers** before moving to architecture.
|
||||||
|
|
||||||
|
If the user says "whatever you think is best", make your recommendation explicit and get confirmation.
|
||||||
|
|
||||||
|
## Phase 4: Architecture design
|
||||||
|
|
||||||
|
Goal: Design multiple implementation approaches with different trade-offs.
|
||||||
|
|
||||||
|
1. Dispatch 2–3 `code-architect` sub-tasks in parallel, each with a different focus:
|
||||||
|
- **Minimal changes** — smallest diff, maximum reuse of existing code.
|
||||||
|
- **Clean architecture** — maintainability, elegant abstractions.
|
||||||
|
- **Pragmatic balance** — speed plus quality.
|
||||||
|
2. Review all approaches and form an opinion on which fits best for this task. Consider scope (small fix vs. large feature), urgency, complexity, and team context.
|
||||||
|
3. Present to the user: a brief summary of each approach, a trade-offs comparison, your recommendation with reasoning, and concrete differences in implementation.
|
||||||
|
4. **Ask the user which approach they prefer.**
|
||||||
|
|
||||||
|
## Phase 5: Create Spec
|
||||||
|
|
||||||
|
Goal: Build the spec.
|
||||||
|
**Do not start without explicit user approval.**
|
||||||
|
|
||||||
|
1. Wait for approval.
|
||||||
|
2. Re-read all relevant files identified earlier.
|
||||||
|
3. Spec following the chosen architecture. We are not writing code, just the specification.
|
||||||
|
4. Strictly follow codebase conventions (naming, style, error-handling patterns).
|
||||||
|
5. Update todos as you progress.
|
||||||
|
|
||||||
|
## Phase 6: Summary
|
||||||
|
|
||||||
|
Goal: Document what was accomplished.
|
||||||
|
|
||||||
|
1. Mark all todos complete.
|
||||||
|
2. Save spec to specs/[feature-name].md
|
||||||
|
3. Summarize:
|
||||||
|
- What was built
|
||||||
|
- Key decisions made
|
||||||
|
- Files modified
|
||||||
|
- Suggest running the @feature-pipeline skill to begin implementation
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
---
|
||||||
|
name: Developer
|
||||||
|
description: Implements core application features across Python backends and Vue frontends.
|
||||||
|
mode: subagent
|
||||||
|
model: moonshotai/kimi-k2.7-code
|
||||||
|
temperature: 0.2
|
||||||
|
maxSteps: 50
|
||||||
|
permission:
|
||||||
|
edit: allow
|
||||||
|
bash: allow
|
||||||
|
options:
|
||||||
|
reasoningEffort: high
|
||||||
|
thinking:
|
||||||
|
type: enabled
|
||||||
|
---
|
||||||
|
|
||||||
|
You are an expert full-stack developer focused on building clean, modular features.
|
||||||
|
|
||||||
|
### Technical Stack Focus
|
||||||
|
|
||||||
|
- **Backend:** Python. Prioritize clean architecture, explicit type hinting, and robust error/exception handling.
|
||||||
|
- **Frontend:** Vue 3. Utilize the Composition API, structured reactive state management, and semantic components.
|
||||||
|
|
||||||
|
### Core Instructions
|
||||||
|
|
||||||
|
1. Maintain a strong separation of concerns between business logic and the transport layer.
|
||||||
|
2. Match the established formatting, design tokens, and architectural conventions of the existing codebase.
|
||||||
|
3. Avoid pulling in heavy external dependencies when clean native implementations are straightforward.
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
name: reviewer
|
||||||
|
description: "Performs read-only code reviews, security audits, and architectural soundness checks on Python/Vue code."
|
||||||
|
mode: "subagent"
|
||||||
|
model: deepseek/deepseek-v4-pro
|
||||||
|
temperature: 0.2
|
||||||
|
maxSteps: 50
|
||||||
|
permission:
|
||||||
|
edit: deny
|
||||||
|
bash: allow
|
||||||
|
options:
|
||||||
|
reasoningEffort: max
|
||||||
|
thinking:
|
||||||
|
type: enabled
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a specialized code reviewer subagent.
|
||||||
|
|
||||||
|
### Strict Constraints
|
||||||
|
|
||||||
|
- **Read-Only Context:** Your role is to analyze, critique, and guide. Do not use `write`, `edit`, or patch tools to modify the workspace files directly.
|
||||||
|
|
||||||
|
### Audit Focus Areas
|
||||||
|
|
||||||
|
- Ensure asynchronous tasks in your Vue components balance resource utilization correctly.
|
||||||
|
- Catch containerization bottlenecks or environment sync gaps in Docker configurations.
|
||||||
|
- Verify strict typing boundaries between backend Python data models and frontend components.
|
||||||
|
|
||||||
|
Provide feedback by explicitly noting the file, logical block, and detected issue.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
---
|
||||||
|
name: tester
|
||||||
|
description: "Holistic QA: Manages unit, integration, and writes and auto-repairs E2E test suites."
|
||||||
|
mode: "subagent"
|
||||||
|
model: "moonshotai/kimi-k2.7-code"
|
||||||
|
permission:
|
||||||
|
edit: allow
|
||||||
|
bash:
|
||||||
|
"pytest *": "allow"
|
||||||
|
"npx playwright *": "allow"
|
||||||
|
"playwright-cli *": "allow"
|
||||||
|
"npm *": "ask"
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a comprehensive QA Engineer. You own the quality of the entire repository.
|
||||||
|
|
||||||
|
## Operational Directives
|
||||||
|
|
||||||
|
- **Unit Testing:** Audit the Developer's unit tests. If you identify missing coverage for edge cases, write the additional unit tests yourself.
|
||||||
|
- **Front End Testing:** Audit the Developers frontend tests. If you identify missing coverage for edge cases, write the additional unit tests yourself.
|
||||||
|
- **E2E Ownership:** Author and maintain all Playwright E2E suites using the playwright-cli skills. Prioritize user-facing locators (`getByRole`, `getByLabel`).
|
||||||
|
- **Gatekeeping:** Before any task is considered "Done," run the full suite (unit + E2E). If a test fails, you own the investigation.
|
||||||
|
- **Verification:** When a failure occurs, do not just notify. Trace the stack trace, identify the breaking commit or configuration change, and suggest a fix.
|
||||||
|
|
||||||
|
## Autonomous Repair Protocol
|
||||||
|
|
||||||
|
When executing tests (especially `playwright`):
|
||||||
|
|
||||||
|
1. **Analyze Failure:** If a test fails, do not just report. Parse the stack trace, identifying if the error is a `locator` issue, a `timeout` issue, or a `logic` error.
|
||||||
|
2. **The "Application Bug" Check:** If the failure indicates that the _application code_ is incorrect (rather than the test locator), **STOP**. Ask the user if you should fix the application logic or if the test is wrong.
|
||||||
|
3. **Looping:** You have permission to fix the test (e.g., update a locator) and re-run.
|
||||||
|
4. **Safety Limit:** Do not run more than 3 repair attempts per test file. If it fails 3 times, stop, output the logs, and ask for help.
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
---
|
||||||
|
description: "Drafts and updates technical documentation, architecture guides, and API specs."
|
||||||
|
mode: "subagent"
|
||||||
|
model: "deepseek/deepseek/deepseek-v4-flash"
|
||||||
|
permission:
|
||||||
|
edit: allow
|
||||||
|
bash: deny
|
||||||
|
options:
|
||||||
|
thinking:
|
||||||
|
type: disabled
|
||||||
|
---
|
||||||
|
|
||||||
|
You are a technical writer who communicates complex software architectures with pinpoint precision.
|
||||||
|
|
||||||
|
### Deliverables
|
||||||
|
|
||||||
|
- Clear, architectural READMEs, system setup guides, and internal team runbooks.
|
||||||
|
- Clean API documentation maps outlining payload shapes, status codes, and endpoint routing.
|
||||||
|
|
||||||
|
### Style Guide
|
||||||
|
|
||||||
|
1. Keep prose technical, precise, and highly scannable.
|
||||||
|
2. Avoid generic corporate or marketing phrases. Lead with the technical details immediately.
|
||||||
|
3. Maximize the use of Markdown tables, bulleted structural lists, and code blocks for readability.
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
description: Design a feature architecture by analyzing existing codebase patterns and conventions, then provide a comprehensive implementation blueprint with specific files to create or modify, component designs, data flows, and a build sequence. Use this skill when the user asks for an architecture design, an implementation plan for a non-trivial feature, or when dispatched as a sub-task during feature-dev architecture phase.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Code Architect
|
||||||
|
|
||||||
|
You are a senior software architect who delivers comprehensive, actionable architecture blueprints by deeply understanding codebases and making confident architectural decisions.
|
||||||
|
|
||||||
|
## Working discipline
|
||||||
|
|
||||||
|
These bias toward caution over speed — use judgment on trivial tasks.
|
||||||
|
|
||||||
|
- **Think before acting** — state assumptions; if the request has more than one reading, surface them instead of silently choosing; if a simpler path exists, say so.
|
||||||
|
- **Simplicity first** — the minimum that solves the problem; no speculative features, abstractions, configurability, or handling of impossible cases.
|
||||||
|
- **Surgical changes** — touch only what the task needs; do not refactor or restyle adjacent code; match existing style; clean up only the orphans your change created, and mention unrelated dead code rather than deleting it.
|
||||||
|
- **Goal-driven** — turn the task into a concrete success check and iterate until it passes.
|
||||||
|
|
||||||
|
## Core process
|
||||||
|
|
||||||
|
### 1. Codebase pattern analysis
|
||||||
|
|
||||||
|
Extract existing patterns, conventions, and architectural decisions. Identify:
|
||||||
|
|
||||||
|
- The technology stack
|
||||||
|
- Module boundaries and abstraction layers
|
||||||
|
- Project guidelines (`CLAUDE.md` / `AGENTS.md`)
|
||||||
|
- Similar features already implemented — how were they structured?
|
||||||
|
- Key abstractions the codebase already provides
|
||||||
|
|
||||||
|
### 2. Architecture design
|
||||||
|
|
||||||
|
Based on patterns found, design the complete feature architecture:
|
||||||
|
|
||||||
|
- Make decisive choices. Pick one approach and commit to it.
|
||||||
|
- Ensure seamless integration with existing code.
|
||||||
|
- Design for testability, performance, and maintainability.
|
||||||
|
|
||||||
|
### 3. Complete implementation blueprint
|
||||||
|
|
||||||
|
Specify every file to create or modify, component responsibilities, integration points, and data flow. Break the implementation into clear phases.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Deliver a decisive, complete architecture blueprint. Include:
|
||||||
|
|
||||||
|
- **Patterns & conventions found** — list existing patterns with `file:line` references, similar features, and key abstractions to leverage.
|
||||||
|
- **Architecture decision** — your chosen approach with rationale and trade-offs.
|
||||||
|
- **Component design** — each component with its file path, responsibilities, dependencies, and interfaces.
|
||||||
|
- **Implementation map** — specific files to create or modify, with detailed change descriptions.
|
||||||
|
- **Data flow** — complete flow from entry points through transformations to outputs.
|
||||||
|
- **Build sequence** — phased implementation steps as a checklist.
|
||||||
|
- **Critical details** — error handling, state management, testing approach, performance, security.
|
||||||
|
|
||||||
|
Make confident architectural choices. Be specific and actionable: provide file paths, function names, and concrete steps. Avoid presenting multiple equally-weighted options unless the user specifically asked for trade-off analysis.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**User arguments:** $ARGUMENTS
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
---
|
||||||
|
description: Deeply analyze an existing codebase feature by tracing execution paths, mapping architecture layers, understanding patterns and abstractions, and documenting dependencies. Use this skill when you need to understand how a feature works before modifying or extending it, when dispatched as a sub-task during feature-dev exploration, or when the user asks "how does X work in this codebase".
|
||||||
|
---
|
||||||
|
|
||||||
|
# Code Explorer
|
||||||
|
|
||||||
|
You are an expert code analyst specializing in tracing and understanding feature implementations across codebases.
|
||||||
|
|
||||||
|
## Core mission
|
||||||
|
|
||||||
|
Provide a complete understanding of how a specific feature works by tracing its implementation from entry points to data storage, through all abstraction layers.
|
||||||
|
|
||||||
|
## Analysis approach
|
||||||
|
|
||||||
|
### 1. Feature discovery
|
||||||
|
|
||||||
|
- Find entry points: APIs, UI components, CLI commands.
|
||||||
|
- Locate core implementation files.
|
||||||
|
- Map feature boundaries and configuration surface.
|
||||||
|
|
||||||
|
### 2. Code-flow tracing
|
||||||
|
|
||||||
|
- Follow call chains from entry to output.
|
||||||
|
- Trace data transformations at each step.
|
||||||
|
- Identify all dependencies and integrations.
|
||||||
|
- Document state changes and side effects.
|
||||||
|
|
||||||
|
### 3. Architecture analysis
|
||||||
|
|
||||||
|
- Map abstraction layers: presentation → business logic → data.
|
||||||
|
- Identify design patterns and architectural decisions.
|
||||||
|
- Document interfaces between components.
|
||||||
|
- Note cross-cutting concerns: auth, logging, caching, observability.
|
||||||
|
|
||||||
|
### 4. Implementation details
|
||||||
|
|
||||||
|
- Key algorithms and data structures.
|
||||||
|
- Error handling and edge cases.
|
||||||
|
- Performance considerations.
|
||||||
|
- Technical debt or improvement areas.
|
||||||
|
|
||||||
|
## Output
|
||||||
|
|
||||||
|
Deliver a comprehensive analysis that helps developers understand the feature deeply enough to modify or extend it. Always include:
|
||||||
|
|
||||||
|
- **Entry points** with `file:line` references
|
||||||
|
- **Step-by-step execution flow** with data transformations
|
||||||
|
- **Key components** and their responsibilities
|
||||||
|
- **Architecture insights** — patterns, layers, design decisions
|
||||||
|
- **Dependencies** — internal and external
|
||||||
|
- **Observations** about strengths, issues, or opportunities
|
||||||
|
- **Essential files list** — the files a developer absolutely must read to understand this topic
|
||||||
|
|
||||||
|
Structure the response for maximum clarity and usefulness. Always cite specific file paths and line numbers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**User arguments:** $ARGUMENTS
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
---
|
||||||
|
name: bugfix
|
||||||
|
description: "Executes a bugfix pipeline on one or more gitea issues: Developer -> Tester -> Reviewer"
|
||||||
|
---
|
||||||
|
|
||||||
|
## What I do
|
||||||
|
|
||||||
|
I orchestrate a sequential bugfix and verification pipeline - I will retrieve issues(s) from Gitea (title, body, images, comments, etc...). I will then forward information from the issues to the respective subagents.
|
||||||
|
|
||||||
|
Use gitea-mcp-server to interact with Gitea. Verify that the server is running and accessible.
|
||||||
|
|
||||||
|
If an issue is not provided, ask the user for the issue number(s).
|
||||||
|
|
||||||
|
1. **Developer**: Provides a code fix for each issue.
|
||||||
|
2. **Reviewer**: Audits the code and architectural soundness.
|
||||||
|
3. **Tester**: Runs tests related to the bugfix and determines if new unit tests, integration tests, or end-to-end tests are needed. If so, implement. Verify by running the test suite.
|
||||||
|
|
||||||
|
## Execution Rules
|
||||||
|
|
||||||
|
- Stop and ask the user for clarification if a step fails or is ambiguous.
|
||||||
|
- Use the `@` mention to trigger the respective subagents sequentially.
|
||||||
|
- Pass the context from the previous stage to the next stage to ensure consistency.
|
||||||
|
- Use multiple subagents to handle different aspects of the bugfix process if it will help.
|
||||||
|
|
||||||
|
## When to use me
|
||||||
|
|
||||||
|
Invoke me when you are ready to fix a Gitea issue or multiple issues.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
name: e2e-repair
|
||||||
|
description: "Runs playwright tests, captures errors, and triggers auto-repair."
|
||||||
|
---
|
||||||
|
|
||||||
|
## Logic
|
||||||
|
|
||||||
|
1. Execute: `npx playwright test [test_file]`
|
||||||
|
2. If Success:
|
||||||
|
- Report success.
|
||||||
|
- Exit.
|
||||||
|
3. If Failure:
|
||||||
|
- Capture output.
|
||||||
|
- Pass logs to @tester agent.
|
||||||
|
- @tester analyzes error and edits file.
|
||||||
|
- Repeat until success or max_retries reached.
|
||||||
|
|
||||||
|
## Safety Guardrails
|
||||||
|
|
||||||
|
- Make use of playwright-cli skills for test execution and repair.
|
||||||
|
- Max Retries: 3 per file.
|
||||||
|
- If the error persists after 3 retries, report: "Repair exhausted: Please review logs."
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
---
|
||||||
|
name: feature-pipeline
|
||||||
|
description: "Executes the full dev-to-docs pipeline: Developer -> Tester -> Reviewer -> Writer."
|
||||||
|
---
|
||||||
|
|
||||||
|
## What I do
|
||||||
|
|
||||||
|
I orchestrate a sequential feature implementation and verification pipeline:
|
||||||
|
|
||||||
|
1. **Developer**: Implements the feature based on the spec.
|
||||||
|
2. **Reviewer**: Audits the code and architectural soundness.
|
||||||
|
3. **Tester**: Runs full unit/E2E test suites; repairs failures if found.
|
||||||
|
4. **Writer**: Updates README and API docs based on verified code.
|
||||||
|
|
||||||
|
## Execution Rules
|
||||||
|
|
||||||
|
- Stop and ask the user for clarification if a step fails or is ambiguous.
|
||||||
|
- Use the `@` mention to trigger the respective subagents sequentially.
|
||||||
|
- Pass the context from the previous stage to the next stage to ensure consistency.
|
||||||
|
|
||||||
|
## When to use me
|
||||||
|
|
||||||
|
Invoke me when you are ready to begin a new feature or when the Architect has finished a specification.
|
||||||
@@ -0,0 +1,420 @@
|
|||||||
|
---
|
||||||
|
name: playwright-cli
|
||||||
|
description: Automate browser interactions, test web pages and work with Playwright tests.
|
||||||
|
allowed-tools: Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*)
|
||||||
|
---
|
||||||
|
|
||||||
|
# Browser Automation with playwright-cli
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# open new browser
|
||||||
|
playwright-cli open
|
||||||
|
# navigate to a page
|
||||||
|
playwright-cli goto https://playwright.dev
|
||||||
|
# interact with the page using refs from the snapshot
|
||||||
|
playwright-cli click e15
|
||||||
|
playwright-cli type "page.click"
|
||||||
|
playwright-cli press Enter
|
||||||
|
# take a screenshot (rarely used, as snapshot is more common)
|
||||||
|
playwright-cli screenshot
|
||||||
|
# close the browser
|
||||||
|
playwright-cli close
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Core
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli open
|
||||||
|
# open and navigate right away
|
||||||
|
playwright-cli open https://example.com/
|
||||||
|
playwright-cli goto https://playwright.dev
|
||||||
|
playwright-cli type "search query"
|
||||||
|
playwright-cli click e3
|
||||||
|
playwright-cli dblclick e7
|
||||||
|
# --submit presses Enter after filling the element
|
||||||
|
playwright-cli fill e5 "user@example.com" --submit
|
||||||
|
playwright-cli drag e2 e8
|
||||||
|
# drop files or data onto an element (from outside the page)
|
||||||
|
playwright-cli drop e4 --path=./image.png
|
||||||
|
playwright-cli drop e4 --data="text/plain=hello world"
|
||||||
|
playwright-cli hover e4
|
||||||
|
playwright-cli select e9 "option-value"
|
||||||
|
playwright-cli upload ./document.pdf
|
||||||
|
playwright-cli check e12
|
||||||
|
playwright-cli uncheck e12
|
||||||
|
playwright-cli snapshot
|
||||||
|
# search the snapshot for text or a regexp, returns matching nodes with surrounding context
|
||||||
|
playwright-cli find "Sign in"
|
||||||
|
playwright-cli find --regex "Sign (in|up)"
|
||||||
|
# wrap the regexp in slashes to add flags, e.g. /i for case-insensitive
|
||||||
|
playwright-cli find --regex "/sign (in|up)/i"
|
||||||
|
playwright-cli eval "document.title"
|
||||||
|
playwright-cli eval "el => el.textContent" e5
|
||||||
|
# get element id, class, or any attribute not visible in the snapshot
|
||||||
|
playwright-cli eval "el => el.id" e5
|
||||||
|
playwright-cli eval "el => el.getAttribute('data-testid')" e5
|
||||||
|
playwright-cli dialog-accept
|
||||||
|
playwright-cli dialog-accept "confirmation text"
|
||||||
|
playwright-cli dialog-dismiss
|
||||||
|
playwright-cli resize 1920 1080
|
||||||
|
playwright-cli close
|
||||||
|
```
|
||||||
|
|
||||||
|
### Navigation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli go-back
|
||||||
|
playwright-cli go-forward
|
||||||
|
playwright-cli reload
|
||||||
|
```
|
||||||
|
|
||||||
|
### Keyboard
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli press Enter
|
||||||
|
playwright-cli press ArrowDown
|
||||||
|
playwright-cli keydown Shift
|
||||||
|
playwright-cli keyup Shift
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mouse
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli mousemove 150 300
|
||||||
|
playwright-cli mousedown
|
||||||
|
playwright-cli mousedown right
|
||||||
|
playwright-cli mouseup
|
||||||
|
playwright-cli mouseup right
|
||||||
|
playwright-cli mousewheel 0 100
|
||||||
|
```
|
||||||
|
|
||||||
|
### Save as
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli screenshot
|
||||||
|
playwright-cli screenshot e5
|
||||||
|
playwright-cli screenshot --filename=page.png
|
||||||
|
playwright-cli screenshot --hires
|
||||||
|
playwright-cli pdf --filename=page.pdf
|
||||||
|
```
|
||||||
|
|
||||||
|
### Tabs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli tab-list
|
||||||
|
playwright-cli tab-new
|
||||||
|
playwright-cli tab-new https://example.com/page
|
||||||
|
playwright-cli tab-close
|
||||||
|
playwright-cli tab-close 2
|
||||||
|
playwright-cli tab-select 0
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli state-save
|
||||||
|
playwright-cli state-save auth.json
|
||||||
|
playwright-cli state-load auth.json
|
||||||
|
|
||||||
|
# Cookies
|
||||||
|
playwright-cli cookie-list
|
||||||
|
playwright-cli cookie-list --domain=example.com
|
||||||
|
playwright-cli cookie-get session_id
|
||||||
|
playwright-cli cookie-set session_id abc123
|
||||||
|
playwright-cli cookie-set session_id abc123 --domain=example.com --httpOnly --secure
|
||||||
|
playwright-cli cookie-delete session_id
|
||||||
|
playwright-cli cookie-clear
|
||||||
|
|
||||||
|
# LocalStorage
|
||||||
|
playwright-cli localstorage-list
|
||||||
|
playwright-cli localstorage-get theme
|
||||||
|
playwright-cli localstorage-set theme dark
|
||||||
|
playwright-cli localstorage-delete theme
|
||||||
|
playwright-cli localstorage-clear
|
||||||
|
|
||||||
|
# SessionStorage
|
||||||
|
playwright-cli sessionstorage-list
|
||||||
|
playwright-cli sessionstorage-get step
|
||||||
|
playwright-cli sessionstorage-set step 3
|
||||||
|
playwright-cli sessionstorage-delete step
|
||||||
|
playwright-cli sessionstorage-clear
|
||||||
|
```
|
||||||
|
|
||||||
|
### Network
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli route "**/*.jpg" --status=404
|
||||||
|
playwright-cli route "https://api.example.com/**" --body='{"mock": true}'
|
||||||
|
playwright-cli route-list
|
||||||
|
playwright-cli unroute "**/*.jpg"
|
||||||
|
playwright-cli unroute
|
||||||
|
```
|
||||||
|
|
||||||
|
### DevTools
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli console
|
||||||
|
playwright-cli console warning
|
||||||
|
playwright-cli requests
|
||||||
|
playwright-cli request 5
|
||||||
|
playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])"
|
||||||
|
playwright-cli run-code --filename=script.js
|
||||||
|
playwright-cli tracing-start
|
||||||
|
playwright-cli tracing-stop
|
||||||
|
playwright-cli video-start video.webm
|
||||||
|
playwright-cli video-chapter "Chapter Title" --description="Details" --duration=2000
|
||||||
|
playwright-cli video-stop
|
||||||
|
|
||||||
|
# annotate each subsequent action (click, type, ...) with a callout naming the action and highlighting the target
|
||||||
|
playwright-cli video-show-actions --duration=600 --position=top-right
|
||||||
|
playwright-cli video-hide-actions
|
||||||
|
|
||||||
|
# launch the dashboard for UI review / design feedback — user annotates the page, you receive the annotated screenshot, snapshot, and notes
|
||||||
|
playwright-cli show --annotate
|
||||||
|
|
||||||
|
# generate a Playwright locator for an element from its ref or selector
|
||||||
|
playwright-cli generate-locator e5 --raw
|
||||||
|
|
||||||
|
# show a persistent highlight overlay for an element, optionally with a custom style
|
||||||
|
playwright-cli highlight e5
|
||||||
|
playwright-cli highlight e5 --style="outline: 3px dashed red"
|
||||||
|
# hide a single element highlight, or all page highlights when no target is given
|
||||||
|
playwright-cli highlight e5 --hide
|
||||||
|
playwright-cli highlight --hide
|
||||||
|
```
|
||||||
|
|
||||||
|
## Raw output
|
||||||
|
|
||||||
|
The global `--raw` option strips page status, generated code, and snapshot sections from the output, returning only the result value. Use it to pipe command output into other tools. Commands that don't produce output return nothing.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli --raw eval "JSON.stringify(performance.timing)" | jq '.loadEventEnd - .navigationStart'
|
||||||
|
playwright-cli --raw eval "JSON.stringify([...document.querySelectorAll('a')].map(a => a.href))" > links.json
|
||||||
|
playwright-cli --raw snapshot > before.yml
|
||||||
|
playwright-cli click e5
|
||||||
|
playwright-cli --raw snapshot > after.yml
|
||||||
|
diff before.yml after.yml
|
||||||
|
TOKEN=$(playwright-cli --raw cookie-get session_id)
|
||||||
|
playwright-cli --raw localstorage-get theme
|
||||||
|
```
|
||||||
|
|
||||||
|
For structured output wrapping every reply as JSON, pass --json
|
||||||
|
```bash
|
||||||
|
playwright-cli list --json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Open parameters
|
||||||
|
```bash
|
||||||
|
# Use specific browser when creating session
|
||||||
|
playwright-cli open --browser=chrome
|
||||||
|
playwright-cli open --browser=firefox
|
||||||
|
playwright-cli open --browser=webkit
|
||||||
|
playwright-cli open --browser=msedge
|
||||||
|
|
||||||
|
# Emulate a generic mobile device (Pixel 10 for Chromium, iPhone 17 for WebKit).
|
||||||
|
# Prefer this when a mobile layout is acceptable: mobile pages are usually
|
||||||
|
# lighter, so snapshots are smaller and cheaper.
|
||||||
|
playwright-cli open --mobile
|
||||||
|
playwright-cli open --device="iPhone 15"
|
||||||
|
|
||||||
|
# Use persistent profile (by default profile is in-memory)
|
||||||
|
playwright-cli open --persistent
|
||||||
|
# Use persistent profile with custom directory
|
||||||
|
playwright-cli open --profile=/path/to/profile
|
||||||
|
|
||||||
|
# Connect to browser via Playwright Extension
|
||||||
|
playwright-cli attach --extension=chrome
|
||||||
|
|
||||||
|
# Connect to a running Chrome or Edge by channel name
|
||||||
|
playwright-cli attach --cdp=chrome
|
||||||
|
playwright-cli attach --cdp=msedge
|
||||||
|
|
||||||
|
# Connect to a running browser via CDP endpoint
|
||||||
|
playwright-cli attach --cdp=http://localhost:9222
|
||||||
|
|
||||||
|
# Start with config file
|
||||||
|
playwright-cli open --config=my-config.json
|
||||||
|
|
||||||
|
# Close the browser
|
||||||
|
playwright-cli close
|
||||||
|
# Detach from an attached browser (leaves the external browser running)
|
||||||
|
playwright-cli -s=msedge detach
|
||||||
|
# Delete user data for the default session
|
||||||
|
playwright-cli delete-data
|
||||||
|
```
|
||||||
|
|
||||||
|
## URLs with `&` on Windows
|
||||||
|
|
||||||
|
On Windows, `cmd.exe` and PowerShell treat `&` as a command separator, so URLs with multiple query parameters get truncated before `playwright-cli` runs. Escape `&` with `^&` in `cmd.exe`, or use `--%` in PowerShell:
|
||||||
|
|
||||||
|
```batch
|
||||||
|
playwright-cli goto "https://example.com/?a=1^&b=2"
|
||||||
|
```
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
playwright-cli --% goto "https://example.com/?a=1&b=2"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Snapshots
|
||||||
|
|
||||||
|
After each command, playwright-cli provides a snapshot of the current browser state.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
> playwright-cli goto https://example.com
|
||||||
|
### Page
|
||||||
|
- Page URL: https://example.com/
|
||||||
|
- Page Title: Example Domain
|
||||||
|
### Snapshot
|
||||||
|
[Snapshot](.playwright-cli/page-2026-02-14T19-22-42-679Z.yml)
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also take a snapshot on demand using `playwright-cli snapshot` command. All the options below can be combined as needed.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# default - save to a file with timestamp-based name
|
||||||
|
playwright-cli snapshot
|
||||||
|
|
||||||
|
# save to file, use when snapshot is a part of the workflow result
|
||||||
|
playwright-cli snapshot --filename=after-click.yaml
|
||||||
|
|
||||||
|
# snapshot an element instead of the whole page
|
||||||
|
playwright-cli snapshot "#main"
|
||||||
|
|
||||||
|
# limit snapshot depth for efficiency, take a partial snapshot afterwards
|
||||||
|
playwright-cli snapshot --depth=4
|
||||||
|
playwright-cli snapshot e34
|
||||||
|
|
||||||
|
# include each element's bounding box as [box=x,y,width,height]
|
||||||
|
playwright-cli snapshot --boxes
|
||||||
|
|
||||||
|
# search a large snapshot instead of capturing it all — returns matching nodes
|
||||||
|
# with 3 lines of context around each match (like grep -C)
|
||||||
|
playwright-cli find "Add to cart"
|
||||||
|
playwright-cli find --regex "\\$[0-9]+\\.[0-9]{2}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Targeting elements
|
||||||
|
|
||||||
|
By default, use refs from the snapshot to interact with page elements.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# get snapshot with refs
|
||||||
|
playwright-cli snapshot
|
||||||
|
|
||||||
|
# interact using a ref
|
||||||
|
playwright-cli click e15
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also use css selectors or Playwright locators.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# css selector
|
||||||
|
playwright-cli click "#main > button.submit"
|
||||||
|
|
||||||
|
# role locator
|
||||||
|
playwright-cli click "getByRole('button', { name: 'Submit' })"
|
||||||
|
|
||||||
|
# test id
|
||||||
|
playwright-cli click "getByTestId('submit-button')"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser Sessions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# create new browser session named "mysession" with persistent profile
|
||||||
|
playwright-cli -s=mysession open example.com --persistent
|
||||||
|
# same with manually specified profile directory (use when requested explicitly)
|
||||||
|
playwright-cli -s=mysession open example.com --profile=/path/to/profile
|
||||||
|
playwright-cli -s=mysession click e6
|
||||||
|
playwright-cli -s=mysession close # stop a named browser
|
||||||
|
playwright-cli -s=mysession delete-data # delete user data for persistent session
|
||||||
|
|
||||||
|
playwright-cli list
|
||||||
|
# Close all browsers
|
||||||
|
playwright-cli close-all
|
||||||
|
# Forcefully kill all browser processes
|
||||||
|
playwright-cli kill-all
|
||||||
|
```
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
If global `playwright-cli` command is not available, try a local version via `npx playwright cli`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npx --no-install playwright --version
|
||||||
|
```
|
||||||
|
|
||||||
|
When local version is available, use `npx playwright cli` in all commands. Otherwise, install `playwright-cli` as a global command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -g @playwright/cli@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Form submission
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli open https://example.com/form
|
||||||
|
playwright-cli snapshot
|
||||||
|
|
||||||
|
playwright-cli fill e1 "user@example.com"
|
||||||
|
playwright-cli fill e2 "password123"
|
||||||
|
playwright-cli click e3
|
||||||
|
playwright-cli snapshot
|
||||||
|
playwright-cli close
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Multi-tab workflow
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli tab-new https://example.com/other
|
||||||
|
playwright-cli tab-list
|
||||||
|
playwright-cli tab-select 0
|
||||||
|
playwright-cli snapshot
|
||||||
|
playwright-cli close
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Debugging with DevTools
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli click e4
|
||||||
|
playwright-cli fill e7 "test"
|
||||||
|
playwright-cli console
|
||||||
|
playwright-cli requests
|
||||||
|
playwright-cli close
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli tracing-start
|
||||||
|
playwright-cli click e4
|
||||||
|
playwright-cli fill e7 "test"
|
||||||
|
playwright-cli tracing-stop
|
||||||
|
playwright-cli close
|
||||||
|
```
|
||||||
|
|
||||||
|
## Example: Interactive session
|
||||||
|
|
||||||
|
Ask the user for UI review or design feedback. The user draws boxes on the live page and types comments; you receive the annotated screenshot, the snapshot of the marked region, and the user's notes. Use this whenever the user asks for "UI review", "design feedback", or to "ask the user what they think / want / mean":
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli show --annotate
|
||||||
|
```
|
||||||
|
|
||||||
|
## Specific tasks
|
||||||
|
|
||||||
|
* **Running and Debugging Playwright tests** [references/playwright-tests.md](references/playwright-tests.md)
|
||||||
|
* **Request mocking** [references/request-mocking.md](references/request-mocking.md)
|
||||||
|
* **Running Playwright code** [references/running-code.md](references/running-code.md)
|
||||||
|
* **Browser session management** [references/session-management.md](references/session-management.md)
|
||||||
|
* **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md)
|
||||||
|
* **Test generation (plan / generate / heal)** [references/test-generation.md](references/test-generation.md)
|
||||||
|
* **Tracing** [references/tracing.md](references/tracing.md)
|
||||||
|
* **Video recording** [references/video-recording.md](references/video-recording.md)
|
||||||
|
* **Inspecting element attributes** [references/element-attributes.md](references/element-attributes.md)
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# Inspecting Element Attributes
|
||||||
|
|
||||||
|
When the snapshot doesn't show an element's `id`, `class`, `data-*` attributes, or other DOM properties, use `eval` to inspect them.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli snapshot
|
||||||
|
# snapshot shows a button as e7 but doesn't reveal its id or data attributes
|
||||||
|
|
||||||
|
# get the element's id
|
||||||
|
playwright-cli eval "el => el.id" e7
|
||||||
|
|
||||||
|
# get all CSS classes
|
||||||
|
playwright-cli eval "el => el.className" e7
|
||||||
|
|
||||||
|
# get a specific attribute
|
||||||
|
playwright-cli eval "el => el.getAttribute('data-testid')" e7
|
||||||
|
playwright-cli eval "el => el.getAttribute('aria-label')" e7
|
||||||
|
|
||||||
|
# get a computed style property
|
||||||
|
playwright-cli eval "el => getComputedStyle(el).display" e7
|
||||||
|
```
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# Running Playwright Tests
|
||||||
|
|
||||||
|
To run Playwright tests, use the `npx playwright test` command, or a package manager script. To avoid opening the interactive html report, use `PLAYWRIGHT_HTML_OPEN=never` environment variable.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run all tests
|
||||||
|
PLAYWRIGHT_HTML_OPEN=never npx playwright test
|
||||||
|
|
||||||
|
# Run all tests through a custom npm script
|
||||||
|
PLAYWRIGHT_HTML_OPEN=never npm run special-test-command
|
||||||
|
```
|
||||||
|
|
||||||
|
# Debugging Playwright Tests
|
||||||
|
|
||||||
|
To debug a failing Playwright test, run it with `--debug=cli` option. This command will pause the test at the start and print the debugging instructions.
|
||||||
|
|
||||||
|
**IMPORTANT**: run the command in the background and check the output until "Debugging Instructions" is printed. Make sure to stop the command after you have finished.
|
||||||
|
|
||||||
|
Once instructions containing a session name are printed, use `playwright-cli` to attach the session and explore the page.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Run the test
|
||||||
|
PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli
|
||||||
|
# ...
|
||||||
|
# ... debugging instructions for "tw-abcdef" session ...
|
||||||
|
# ...
|
||||||
|
|
||||||
|
# Attach to the test
|
||||||
|
playwright-cli attach tw-abcdef
|
||||||
|
```
|
||||||
|
|
||||||
|
Keep the test running in the background while you explore and look for a fix.
|
||||||
|
The test is paused at the start, so you should step over or pause at a particular location
|
||||||
|
where the problem is most likely to be.
|
||||||
|
|
||||||
|
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code.
|
||||||
|
This code appears in the output and can be copied directly into the test. Most of the time, a specific locator or an expectation should be updated, but it could also be a bug in the app. Use your judgement.
|
||||||
|
|
||||||
|
After fixing the test, stop the background test run. Rerun to check that test passes.
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
# Request Mocking
|
||||||
|
|
||||||
|
Intercept, mock, modify, and block network requests.
|
||||||
|
|
||||||
|
## CLI Route Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mock with custom status
|
||||||
|
playwright-cli route "**/*.jpg" --status=404
|
||||||
|
|
||||||
|
# Mock with JSON body
|
||||||
|
playwright-cli route "**/api/users" --body='[{"id":1,"name":"Alice"}]' --content-type=application/json
|
||||||
|
|
||||||
|
# Mock with custom headers
|
||||||
|
playwright-cli route "**/api/data" --body='{"ok":true}' --header="X-Custom: value"
|
||||||
|
|
||||||
|
# Remove headers from requests
|
||||||
|
playwright-cli route "**/*" --remove-header=cookie,authorization
|
||||||
|
|
||||||
|
# List active routes
|
||||||
|
playwright-cli route-list
|
||||||
|
|
||||||
|
# Remove a route or all routes
|
||||||
|
playwright-cli unroute "**/*.jpg"
|
||||||
|
playwright-cli unroute
|
||||||
|
```
|
||||||
|
|
||||||
|
## URL Patterns
|
||||||
|
|
||||||
|
```
|
||||||
|
**/api/users - Exact path match
|
||||||
|
**/api/*/details - Wildcard in path
|
||||||
|
**/*.{png,jpg,jpeg} - Match file extensions
|
||||||
|
**/search?q=* - Match query parameters
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Mocking with run-code
|
||||||
|
|
||||||
|
For conditional responses, request body inspection, response modification, or delays:
|
||||||
|
|
||||||
|
### Conditional Response Based on Request
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.route('**/api/login', route => {
|
||||||
|
const body = route.request().postDataJSON();
|
||||||
|
if (body.username === 'admin') {
|
||||||
|
route.fulfill({ body: JSON.stringify({ token: 'mock-token' }) });
|
||||||
|
} else {
|
||||||
|
route.fulfill({ status: 401, body: JSON.stringify({ error: 'Invalid' }) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modify Real Response
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.route('**/api/user', async route => {
|
||||||
|
const response = await route.fetch();
|
||||||
|
const json = await response.json();
|
||||||
|
json.isPremium = true;
|
||||||
|
await route.fulfill({ response, json });
|
||||||
|
});
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Simulate Network Failures
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.route('**/api/offline', route => route.abort('internetdisconnected'));
|
||||||
|
}"
|
||||||
|
# Options: connectionrefused, timedout, connectionreset, internetdisconnected
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delayed Response
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.route('**/api/slow', async route => {
|
||||||
|
await new Promise(r => setTimeout(r, 3000));
|
||||||
|
route.fulfill({ body: JSON.stringify({ data: 'loaded' }) });
|
||||||
|
});
|
||||||
|
}"
|
||||||
|
```
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
# Running Custom Playwright Code
|
||||||
|
|
||||||
|
Use `run-code` to execute arbitrary Playwright code for advanced scenarios not covered by CLI commands.
|
||||||
|
|
||||||
|
## Syntax
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
// Your Playwright code here
|
||||||
|
// Access page.context() for browser context operations
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also load the function from a file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code --filename=./my-script.js
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
The code must be a single function expression, it is wrapped in `(...)` and evaluated.
|
||||||
|
import/export/require syntax is not supported.
|
||||||
|
|
||||||
|
## Geolocation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Grant geolocation permission and set location
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.context().grantPermissions(['geolocation']);
|
||||||
|
await page.context().setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Set location to London
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.context().grantPermissions(['geolocation']);
|
||||||
|
await page.context().setGeolocation({ latitude: 51.5074, longitude: -0.1278 });
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Clear geolocation override
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.context().clearPermissions();
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Grant multiple permissions
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.context().grantPermissions([
|
||||||
|
'geolocation',
|
||||||
|
'notifications',
|
||||||
|
'camera',
|
||||||
|
'microphone'
|
||||||
|
]);
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Grant permissions for specific origin
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.context().grantPermissions(['clipboard-read'], {
|
||||||
|
origin: 'https://example.com'
|
||||||
|
});
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Media Emulation
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Emulate dark color scheme
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.emulateMedia({ colorScheme: 'dark' });
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Emulate light color scheme
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.emulateMedia({ colorScheme: 'light' });
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Emulate reduced motion
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.emulateMedia({ reducedMotion: 'reduce' });
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Emulate print media
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.emulateMedia({ media: 'print' });
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Wait Strategies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Wait for network idle
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.waitForLoadState('networkidle');
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Wait for specific element
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.locator('.loading').waitFor({ state: 'hidden' });
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Wait for function to return true
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.waitForFunction(() => window.appReady === true);
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Wait with timeout
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.locator('.result').waitFor({ timeout: 10000 });
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frames and Iframes
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Work with iframe
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
const frame = page.locator('iframe#my-iframe').contentFrame();
|
||||||
|
await frame.locator('button').click();
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Get all frames
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
const frames = page.frames();
|
||||||
|
return frames.map(f => f.url());
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Downloads
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Handle file download
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
const downloadPromise = page.waitForEvent('download');
|
||||||
|
await page.getByRole('link', { name: 'Download' }).click();
|
||||||
|
const download = await downloadPromise;
|
||||||
|
await download.saveAs('./downloaded-file.pdf');
|
||||||
|
return download.suggestedFilename();
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Clipboard
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Read clipboard (requires permission)
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.context().grantPermissions(['clipboard-read']);
|
||||||
|
return await page.evaluate(() => navigator.clipboard.readText());
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Write to clipboard
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.evaluate(text => navigator.clipboard.writeText(text), 'Hello clipboard!');
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Page Information
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get page title
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
return await page.title();
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Get current URL
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
return page.url();
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Get page content
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
return await page.content();
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Get viewport size
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
return page.viewportSize();
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## JavaScript Execution
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Execute JavaScript and return result
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
return await page.evaluate(() => {
|
||||||
|
return {
|
||||||
|
userAgent: navigator.userAgent,
|
||||||
|
language: navigator.language,
|
||||||
|
cookiesEnabled: navigator.cookieEnabled
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Pass arguments to evaluate
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
const multiplier = 5;
|
||||||
|
return await page.evaluate(m => document.querySelectorAll('li').length * m, multiplier);
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Try-catch in run-code
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
try {
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click({ timeout: 1000 });
|
||||||
|
return 'clicked';
|
||||||
|
} catch (e) {
|
||||||
|
return 'element not found';
|
||||||
|
}
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Complex Workflows
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Login and save state
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.goto('https://example.com/login');
|
||||||
|
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||||
|
await page.getByRole('textbox', { name: 'Password' }).fill('secret');
|
||||||
|
await page.getByRole('button', { name: 'Sign in' }).click();
|
||||||
|
await page.waitForURL('**/dashboard');
|
||||||
|
await page.context().storageState({ path: 'auth.json' });
|
||||||
|
return 'Login successful';
|
||||||
|
}"
|
||||||
|
|
||||||
|
# Scrape data from multiple pages
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
const results = [];
|
||||||
|
for (let i = 1; i <= 3; i++) {
|
||||||
|
await page.goto(\`https://example.com/page/\${i}\`);
|
||||||
|
const items = await page.locator('.item').allTextContents();
|
||||||
|
results.push(...items);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}"
|
||||||
|
```
|
||||||
@@ -0,0 +1,225 @@
|
|||||||
|
# Browser Session Management
|
||||||
|
|
||||||
|
Run multiple isolated browser sessions concurrently with state persistence.
|
||||||
|
|
||||||
|
## Named Browser Sessions
|
||||||
|
|
||||||
|
Use `-s` flag to isolate browser contexts:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Browser 1: Authentication flow
|
||||||
|
playwright-cli -s=auth open https://app.example.com/login
|
||||||
|
|
||||||
|
# Browser 2: Public browsing (separate cookies, storage)
|
||||||
|
playwright-cli -s=public open https://example.com
|
||||||
|
|
||||||
|
# Commands are isolated by browser session
|
||||||
|
playwright-cli -s=auth fill e1 "user@example.com"
|
||||||
|
playwright-cli -s=public snapshot
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser Session Isolation Properties
|
||||||
|
|
||||||
|
Each browser session has independent:
|
||||||
|
- Cookies
|
||||||
|
- LocalStorage / SessionStorage
|
||||||
|
- IndexedDB
|
||||||
|
- Cache
|
||||||
|
- Browsing history
|
||||||
|
- Open tabs
|
||||||
|
|
||||||
|
## Browser Session Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List all browser sessions
|
||||||
|
playwright-cli list
|
||||||
|
|
||||||
|
# Stop a browser session (close the browser)
|
||||||
|
playwright-cli close # stop the default browser
|
||||||
|
playwright-cli -s=mysession close # stop a named browser
|
||||||
|
|
||||||
|
# Stop all browser sessions
|
||||||
|
playwright-cli close-all
|
||||||
|
|
||||||
|
# Forcefully kill all daemon processes (for stale/zombie processes)
|
||||||
|
playwright-cli kill-all
|
||||||
|
|
||||||
|
# Delete browser session user data (profile directory)
|
||||||
|
playwright-cli delete-data # delete default browser data
|
||||||
|
playwright-cli -s=mysession delete-data # delete named browser data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Environment Variable
|
||||||
|
|
||||||
|
Set a default browser session name via environment variable:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export PLAYWRIGHT_CLI_SESSION="mysession"
|
||||||
|
playwright-cli open example.com # Uses "mysession" automatically
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Concurrent Scraping
|
||||||
|
|
||||||
|
```bash
|
||||||
|
#!/bin/bash
|
||||||
|
# Scrape multiple sites concurrently
|
||||||
|
|
||||||
|
# Start all browsers
|
||||||
|
playwright-cli -s=site1 open https://site1.com &
|
||||||
|
playwright-cli -s=site2 open https://site2.com &
|
||||||
|
playwright-cli -s=site3 open https://site3.com &
|
||||||
|
wait
|
||||||
|
|
||||||
|
# Take snapshots from each
|
||||||
|
playwright-cli -s=site1 snapshot
|
||||||
|
playwright-cli -s=site2 snapshot
|
||||||
|
playwright-cli -s=site3 snapshot
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
playwright-cli close-all
|
||||||
|
```
|
||||||
|
|
||||||
|
### A/B Testing Sessions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Test different user experiences
|
||||||
|
playwright-cli -s=variant-a open "https://app.com?variant=a"
|
||||||
|
playwright-cli -s=variant-b open "https://app.com?variant=b"
|
||||||
|
|
||||||
|
# Compare
|
||||||
|
playwright-cli -s=variant-a screenshot
|
||||||
|
playwright-cli -s=variant-b screenshot
|
||||||
|
```
|
||||||
|
|
||||||
|
### Persistent Profile
|
||||||
|
|
||||||
|
By default, browser profile is kept in memory only. Use `--persistent` flag on `open` to persist the browser profile to disk:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use persistent profile (auto-generated location)
|
||||||
|
playwright-cli open https://example.com --persistent
|
||||||
|
|
||||||
|
# Use persistent profile with custom directory
|
||||||
|
playwright-cli open https://example.com --profile=/path/to/profile
|
||||||
|
```
|
||||||
|
|
||||||
|
## Attaching to a Running Browser
|
||||||
|
|
||||||
|
Use `attach` to connect to a browser that is already running, instead of launching a new one.
|
||||||
|
|
||||||
|
### Attach by channel name
|
||||||
|
|
||||||
|
Connect to a running Chrome or Edge instance by its channel name. The browser must have remote debugging enabled — navigate to `chrome://inspect/#remote-debugging` in the target browser and check "Allow remote debugging for this browser instance".
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Attach to Chrome
|
||||||
|
playwright-cli attach --cdp=chrome
|
||||||
|
|
||||||
|
# Attach to Chrome Canary
|
||||||
|
playwright-cli attach --cdp=chrome-canary
|
||||||
|
|
||||||
|
# Attach to Microsoft Edge
|
||||||
|
playwright-cli attach --cdp=msedge
|
||||||
|
|
||||||
|
# Attach to Edge Dev
|
||||||
|
playwright-cli attach --cdp=msedge-dev
|
||||||
|
```
|
||||||
|
|
||||||
|
Supported channels: `chrome`, `chrome-beta`, `chrome-dev`, `chrome-canary`, `msedge`, `msedge-beta`, `msedge-dev`, `msedge-canary`.
|
||||||
|
|
||||||
|
When `--session` is not provided, the session is named after the channel (e.g. `--cdp=msedge` creates a session called `msedge`), so parallel attaches to Chrome and Edge don't collide on `default`. Pass `--session=<name>` to override.
|
||||||
|
|
||||||
|
### Attach via CDP endpoint
|
||||||
|
|
||||||
|
Connect to a browser that exposes a Chrome DevTools Protocol endpoint:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli attach --cdp=http://localhost:9222
|
||||||
|
```
|
||||||
|
|
||||||
|
### Attach via browser extension
|
||||||
|
|
||||||
|
Connect to a browser with the Playwright extension installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli attach --extension
|
||||||
|
```
|
||||||
|
|
||||||
|
### Detach
|
||||||
|
|
||||||
|
Tear down an attached session without affecting the external browser:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Detach the default attached session
|
||||||
|
playwright-cli detach
|
||||||
|
|
||||||
|
# Detach a specific attached session
|
||||||
|
playwright-cli -s=msedge detach
|
||||||
|
```
|
||||||
|
|
||||||
|
`detach` only works on sessions created via `attach`. For sessions created via `open`, use `close`.
|
||||||
|
|
||||||
|
## Default Browser Session
|
||||||
|
|
||||||
|
When `-s` is omitted, commands use the default browser session:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# These use the same default browser session
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli snapshot
|
||||||
|
playwright-cli close # Stops default browser
|
||||||
|
```
|
||||||
|
|
||||||
|
## Browser Session Configuration
|
||||||
|
|
||||||
|
Configure a browser session with specific settings when opening:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Open with config file
|
||||||
|
playwright-cli open https://example.com --config=.playwright/my-cli.json
|
||||||
|
|
||||||
|
# Open with specific browser
|
||||||
|
playwright-cli open https://example.com --browser=firefox
|
||||||
|
|
||||||
|
# Open in headed mode
|
||||||
|
playwright-cli open https://example.com --headed
|
||||||
|
|
||||||
|
# Open with persistent profile
|
||||||
|
playwright-cli open https://example.com --persistent
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Name Browser Sessions Semantically
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# GOOD: Clear purpose
|
||||||
|
playwright-cli -s=github-auth open https://github.com
|
||||||
|
playwright-cli -s=docs-scrape open https://docs.example.com
|
||||||
|
|
||||||
|
# AVOID: Generic names
|
||||||
|
playwright-cli -s=s1 open https://github.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Always Clean Up
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop browsers when done
|
||||||
|
playwright-cli -s=auth close
|
||||||
|
playwright-cli -s=scrape close
|
||||||
|
|
||||||
|
# Or stop all at once
|
||||||
|
playwright-cli close-all
|
||||||
|
|
||||||
|
# If browsers become unresponsive or zombie processes remain
|
||||||
|
playwright-cli kill-all
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Delete Stale Browser Data
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Remove old browser data to free disk space
|
||||||
|
playwright-cli -s=oldsession delete-data
|
||||||
|
```
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
# Storage Management
|
||||||
|
|
||||||
|
Manage cookies, localStorage, sessionStorage, and browser storage state.
|
||||||
|
|
||||||
|
## Storage State
|
||||||
|
|
||||||
|
Save and restore complete browser state including cookies and storage.
|
||||||
|
|
||||||
|
### Save Storage State
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Save to auto-generated filename (storage-state-{timestamp}.json)
|
||||||
|
playwright-cli state-save
|
||||||
|
|
||||||
|
# Save to specific filename
|
||||||
|
playwright-cli state-save my-auth-state.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restore Storage State
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Load storage state from file
|
||||||
|
playwright-cli state-load my-auth-state.json
|
||||||
|
|
||||||
|
# Reload page to apply cookies
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Storage State File Format
|
||||||
|
|
||||||
|
The saved file contains:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"cookies": [
|
||||||
|
{
|
||||||
|
"name": "session_id",
|
||||||
|
"value": "abc123",
|
||||||
|
"domain": "example.com",
|
||||||
|
"path": "/",
|
||||||
|
"expires": 1893456000,
|
||||||
|
"httpOnly": true,
|
||||||
|
"secure": true,
|
||||||
|
"sameSite": "Lax"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"origins": [
|
||||||
|
{
|
||||||
|
"origin": "https://example.com",
|
||||||
|
"localStorage": [
|
||||||
|
{ "name": "theme", "value": "dark" },
|
||||||
|
{ "name": "user_id", "value": "12345" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cookies
|
||||||
|
|
||||||
|
### List All Cookies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli cookie-list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter Cookies by Domain
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli cookie-list --domain=example.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### Filter Cookies by Path
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli cookie-list --path=/api
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Specific Cookie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli cookie-get session_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set a Cookie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Basic cookie
|
||||||
|
playwright-cli cookie-set session abc123
|
||||||
|
|
||||||
|
# Cookie with options
|
||||||
|
playwright-cli cookie-set session abc123 --domain=example.com --path=/ --httpOnly --secure --sameSite=Lax
|
||||||
|
|
||||||
|
# Cookie with expiration (Unix timestamp)
|
||||||
|
playwright-cli cookie-set remember_me token123 --expires=1893456000
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete a Cookie
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli cookie-delete session_id
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clear All Cookies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli cookie-clear
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced: Multiple Cookies or Custom Options
|
||||||
|
|
||||||
|
For complex scenarios like adding multiple cookies at once, use `run-code`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.context().addCookies([
|
||||||
|
{ name: 'session_id', value: 'sess_abc123', domain: 'example.com', path: '/', httpOnly: true },
|
||||||
|
{ name: 'preferences', value: JSON.stringify({ theme: 'dark' }), domain: 'example.com', path: '/' }
|
||||||
|
]);
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Local Storage
|
||||||
|
|
||||||
|
### List All localStorage Items
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli localstorage-list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Single Value
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli localstorage-get token
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set Value
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli localstorage-set theme dark
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set JSON Value
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli localstorage-set user_settings '{"theme":"dark","language":"en"}'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Single Item
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli localstorage-delete token
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clear All localStorage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli localstorage-clear
|
||||||
|
```
|
||||||
|
|
||||||
|
### Advanced: Multiple Operations
|
||||||
|
|
||||||
|
For complex scenarios like setting multiple values at once, use `run-code`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
localStorage.setItem('token', 'jwt_abc123');
|
||||||
|
localStorage.setItem('user_id', '12345');
|
||||||
|
localStorage.setItem('expires_at', Date.now() + 3600000);
|
||||||
|
});
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Session Storage
|
||||||
|
|
||||||
|
### List All sessionStorage Items
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli sessionstorage-list
|
||||||
|
```
|
||||||
|
|
||||||
|
### Get Single Value
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli sessionstorage-get form_data
|
||||||
|
```
|
||||||
|
|
||||||
|
### Set Value
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli sessionstorage-set step 3
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Single Item
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli sessionstorage-delete step
|
||||||
|
```
|
||||||
|
|
||||||
|
### Clear sessionStorage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli sessionstorage-clear
|
||||||
|
```
|
||||||
|
|
||||||
|
## IndexedDB
|
||||||
|
|
||||||
|
### List Databases
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
return await page.evaluate(async () => {
|
||||||
|
const databases = await indexedDB.databases();
|
||||||
|
return databases;
|
||||||
|
});
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Delete Database
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli run-code "async page => {
|
||||||
|
await page.evaluate(() => {
|
||||||
|
indexedDB.deleteDatabase('myDatabase');
|
||||||
|
});
|
||||||
|
}"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
### Authentication State Reuse
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Step 1: Login and save state
|
||||||
|
playwright-cli open https://app.example.com/login
|
||||||
|
playwright-cli snapshot
|
||||||
|
playwright-cli fill e1 "user@example.com"
|
||||||
|
playwright-cli fill e2 "password123"
|
||||||
|
playwright-cli click e3
|
||||||
|
|
||||||
|
# Save the authenticated state
|
||||||
|
playwright-cli state-save auth.json
|
||||||
|
|
||||||
|
# Step 2: Later, restore state and skip login
|
||||||
|
playwright-cli state-load auth.json
|
||||||
|
playwright-cli open https://app.example.com/dashboard
|
||||||
|
# Already logged in!
|
||||||
|
```
|
||||||
|
|
||||||
|
### Save and Restore Roundtrip
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Set up authentication state
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli eval "() => { document.cookie = 'session=abc123'; localStorage.setItem('user', 'john'); }"
|
||||||
|
|
||||||
|
# Save state to file
|
||||||
|
playwright-cli state-save my-session.json
|
||||||
|
|
||||||
|
# ... later, in a new session ...
|
||||||
|
|
||||||
|
# Restore state
|
||||||
|
playwright-cli state-load my-session.json
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
# Cookies and localStorage are restored!
|
||||||
|
```
|
||||||
|
|
||||||
|
## Security Notes
|
||||||
|
|
||||||
|
- Never commit storage state files containing auth tokens
|
||||||
|
- Add `*.auth-state.json` to `.gitignore`
|
||||||
|
- Delete state files after automation completes
|
||||||
|
- Use environment variables for sensitive data
|
||||||
|
- By default, sessions run in-memory mode which is safer for sensitive operations
|
||||||
@@ -0,0 +1,433 @@
|
|||||||
|
# Test generation (plan → generate → heal)
|
||||||
|
|
||||||
|
End-to-end workflow for authoring and maintaining Playwright tests with `playwright-cli`. Every `playwright-cli` action emits the equivalent Playwright TypeScript, and that generated code is the raw material for every test. The sections below can be used independently:
|
||||||
|
|
||||||
|
- **How generation works** — the core mechanic everything else relies on: actions become TypeScript, plus how to add assertions.
|
||||||
|
- **Plan** — explore the app, produce a spec file describing what to test.
|
||||||
|
- **Generate** — turn a spec into Playwright test files. Update the spec if it's vague or stale.
|
||||||
|
- **Heal** — diagnose failing tests, fix the code, reconcile the spec with reality.
|
||||||
|
|
||||||
|
Plan / generate / heal lean on the same mechanic: run `npx playwright test --debug=cli` in the background, then `playwright-cli attach tw-XXXX` to drive the paused page interactively. See [playwright-tests.md](playwright-tests.md) for the debug/attach mechanics.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 0. How generation works
|
||||||
|
|
||||||
|
Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code. This code appears in the output and can be copied directly into your test files.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start a session
|
||||||
|
playwright-cli open https://example.com/login
|
||||||
|
|
||||||
|
# Take a snapshot to see elements
|
||||||
|
playwright-cli snapshot
|
||||||
|
# Output shows: e1 [textbox "Email"], e2 [textbox "Password"], e3 [button "Sign In"]
|
||||||
|
|
||||||
|
# Fill form fields - generates code automatically
|
||||||
|
playwright-cli fill e1 "user@example.com"
|
||||||
|
# Ran Playwright code:
|
||||||
|
# await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||||
|
|
||||||
|
playwright-cli fill e2 "password123"
|
||||||
|
# Ran Playwright code:
|
||||||
|
# await page.getByRole('textbox', { name: 'Password' }).fill('password123');
|
||||||
|
|
||||||
|
playwright-cli click e3
|
||||||
|
# Ran Playwright code:
|
||||||
|
# await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Building a test file
|
||||||
|
|
||||||
|
Collect the generated code into a Playwright test:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
|
test('login flow', async ({ page }) => {
|
||||||
|
// Generated code from playwright-cli session:
|
||||||
|
await page.goto('https://example.com/login');
|
||||||
|
await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
|
||||||
|
await page.getByRole('textbox', { name: 'Password' }).fill('password123');
|
||||||
|
await page.getByRole('button', { name: 'Sign In' }).click();
|
||||||
|
|
||||||
|
// Add assertions
|
||||||
|
await expect(page).toHaveURL(/.*dashboard/);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Use semantic locators
|
||||||
|
|
||||||
|
The generated code uses role-based locators when possible, which are more resilient:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Generated (good - semantic)
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
|
// Avoid (fragile - CSS selectors)
|
||||||
|
await page.locator('#submit-btn').click();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Explore before recording
|
||||||
|
|
||||||
|
Take snapshots to understand the page structure before recording actions:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli snapshot
|
||||||
|
# Review the element structure
|
||||||
|
playwright-cli click e5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Add assertions manually
|
||||||
|
|
||||||
|
Generated code captures actions but not assertions. Add expectations in your test using one of the recommended matchers:
|
||||||
|
|
||||||
|
- `toBeVisible()` — element is rendered and visible
|
||||||
|
- `toHaveText(text)` — element text content matches
|
||||||
|
- `toHaveValue(value) / toBeEmpty()` — input/select value matches
|
||||||
|
- `toBeChecked() / toBeUnchecked()` — checkbox state matches
|
||||||
|
- `toMatchAriaSnapshot(snapshot)` — page (or locator) matches a partial accessibility snapshot
|
||||||
|
|
||||||
|
Use `playwright-cli generate-locator <target>` to produce the locator expression for the assertion, and the snapshot/eval commands to capture the expected value.
|
||||||
|
|
||||||
|
When asserting text content, make sure that generated locator does not contain text from the element itself. `getByTestId()` or `getByLabel()` usually work well with asserting text. When locator is text-based, prefer `toBeVisible()` instead.
|
||||||
|
|
||||||
|
Snapshot to be matched does not have to contain all the information - only capture what's necessary for the assertion. You can use regular expressions for unstable values.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Get a stable locator for an element ref to use in the assertion
|
||||||
|
playwright-cli --raw generate-locator e5
|
||||||
|
# getByRole('button', { name: 'Submit' })
|
||||||
|
|
||||||
|
# Capture expected text content for toHaveText
|
||||||
|
playwright-cli --raw eval "el => el.textContent" e5
|
||||||
|
|
||||||
|
# Capture expected input value for toHaveValue/toBeEmpty
|
||||||
|
playwright-cli --raw eval "el => el.value" e5
|
||||||
|
|
||||||
|
# Capture expected aria snapshot for toMatchAriaSnapshot/toBeChecked
|
||||||
|
# (whole page, or use a ref to scope to a region)
|
||||||
|
playwright-cli --raw snapshot
|
||||||
|
playwright-cli --raw snapshot e5
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Generated action
|
||||||
|
await page.getByRole('button', { name: 'Submit' }).click();
|
||||||
|
|
||||||
|
// Manual assertions using the outputs above:
|
||||||
|
await expect(page.getByRole('alert', { name: 'Success' })).toBeVisible();
|
||||||
|
await expect(page.getByTestId('main-header')).toHaveText('Welcome, user');
|
||||||
|
await expect(page.getByRole('textbox', { name: 'Email' })).toHaveValue('user@example.com');
|
||||||
|
await expect(page.getByRole('checkbox', { name: 'Enable notifications' })).toBeChecked();
|
||||||
|
|
||||||
|
// toMatchAriaSnapshot on the whole page, finds a matching region
|
||||||
|
await expect(page).toMatchAriaSnapshot(`
|
||||||
|
- heading "Welcome, user"
|
||||||
|
- link /\\d+ new messages?/
|
||||||
|
- button "Sign out"
|
||||||
|
`);
|
||||||
|
|
||||||
|
// toMatchAriaSnapshot scoped to a region
|
||||||
|
await expect(page.getByRole('navigation')).toMatchAriaSnapshot(`
|
||||||
|
- link "Home"
|
||||||
|
- link /\\d+ new messages?/
|
||||||
|
- link "Profile"
|
||||||
|
`);
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Planning
|
||||||
|
|
||||||
|
Goal: produce a spec file (e.g. `specs/<feature>.plan.md`) that enumerates the scenarios to test. **Always** write the spec to a file.
|
||||||
|
|
||||||
|
### 1.1 Prerequisite: workspace
|
||||||
|
|
||||||
|
Check the workspace has Playwright installed before anything else:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Either of these confirms a workspace:
|
||||||
|
test -f playwright.config.ts || test -f playwright.config.js
|
||||||
|
npx --no-install playwright --version
|
||||||
|
```
|
||||||
|
|
||||||
|
If there is no Playwright install, bootstrap one and let the user pick the defaults:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm init playwright@latest
|
||||||
|
```
|
||||||
|
|
||||||
|
### 1.2 Prerequisite: seed test
|
||||||
|
|
||||||
|
A **seed test** is a minimal test that lands the page in the state every scenario starts from: navigation to the app, any required login, feature flags, etc. Scenarios assume a fresh start *after* the seed. `--debug=cli` pauses *inside* this test, so the seed is where every planning and generation session begins.
|
||||||
|
|
||||||
|
Minimum viable seed:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// tests/seed.spec.ts
|
||||||
|
import { test } from '@playwright/test';
|
||||||
|
|
||||||
|
test('seed', async ({ page }) => {
|
||||||
|
await page.goto('https://example.com/');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Preferred — push navigation into a fixture so scenario tests reuse it:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// tests/fixtures.ts
|
||||||
|
import { test as baseTest } from '@playwright/test';
|
||||||
|
export { expect } from '@playwright/test';
|
||||||
|
|
||||||
|
export const test = baseTest.extend({
|
||||||
|
page: async ({ page }, use) => {
|
||||||
|
await page.goto('https://example.com/');
|
||||||
|
await use(page);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// tests/seed.spec.ts
|
||||||
|
import { test } from './fixtures';
|
||||||
|
|
||||||
|
test('seed', async ({ page }) => {
|
||||||
|
// Fixture already navigates. This empty body tells agents where to start.
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
If no seed exists, create one that at least navigates to the app.
|
||||||
|
|
||||||
|
### 1.3 Explore the app
|
||||||
|
|
||||||
|
Launch the app via the seed in the background and attach:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/seed.spec.ts --debug=cli
|
||||||
|
# wait for "Debugging Instructions" and the session name tw-XXXX
|
||||||
|
playwright-cli attach tw-XXXX
|
||||||
|
```
|
||||||
|
|
||||||
|
Resume so the seed runs, then probe the app:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli resume # resume so that seed test runs fully
|
||||||
|
playwright-cli snapshot # inventory of interactive elements
|
||||||
|
playwright-cli click e5 # follow a flow
|
||||||
|
playwright-cli eval "location.href" # read URL / state
|
||||||
|
playwright-cli show --annotate # ask the user to point at something
|
||||||
|
```
|
||||||
|
|
||||||
|
Map out:
|
||||||
|
|
||||||
|
- Interactive surfaces (forms, buttons, lists, filters, modals).
|
||||||
|
- Primary user journeys end-to-end.
|
||||||
|
- Edge cases: empty states, validation errors, very long input, boundary values.
|
||||||
|
- Persistence: reload, local/session storage, URL fragments.
|
||||||
|
- Navigation: which controls change the URL, back/forward behaviour.
|
||||||
|
|
||||||
|
**Important**: Do not just open the app url with playwright-cli, always go through the test to capture any custom setup done there.
|
||||||
|
**Important**: Stop the background test when done exploring.
|
||||||
|
|
||||||
|
### 1.4 Write the spec file
|
||||||
|
|
||||||
|
Save under `specs/<feature>.plan.md`. Use this structure:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# <Feature> Test Plan
|
||||||
|
|
||||||
|
## Application Overview
|
||||||
|
|
||||||
|
<One paragraph describing what the feature does and why it matters.>
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
### 1. <Group Name>
|
||||||
|
|
||||||
|
**Seed:** `tests/seed.spec.ts`
|
||||||
|
|
||||||
|
#### 1.1. <kebab-case-scenario-name>
|
||||||
|
|
||||||
|
**File:** `tests/<group>/<kebab-case-scenario-name>.spec.ts`
|
||||||
|
|
||||||
|
**Steps:**
|
||||||
|
1. <Concrete user step>
|
||||||
|
- expect: <observable outcome>
|
||||||
|
- expect: <another observable outcome>
|
||||||
|
2. <Next step>
|
||||||
|
- expect: <outcome>
|
||||||
|
|
||||||
|
#### 1.2. <next-scenario>
|
||||||
|
...
|
||||||
|
|
||||||
|
### 2. <Next Group>
|
||||||
|
|
||||||
|
**Seed:** `tests/seed.spec.ts`
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Guidelines:
|
||||||
|
|
||||||
|
- Each scenario is independent and starts from the seed's fresh state — never chain scenarios.
|
||||||
|
- Scenario names are kebab-case and match the test file name (`should-add-single-todo` → `should-add-single-todo.spec.ts`).
|
||||||
|
- Cover happy path, edge cases, validation, negative flows, persistence.
|
||||||
|
- Write steps at the user level ("Type 'Buy milk' into the input"), not the API level ("call `fill`").
|
||||||
|
- Put observable outcomes in `- expect:` bullets; each becomes an assertion during generation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Generate
|
||||||
|
|
||||||
|
Goal: take a spec file and produce Playwright test files. Optionally update the spec if it has drifted.
|
||||||
|
|
||||||
|
### 2.1 Inputs
|
||||||
|
|
||||||
|
- **Spec file**, e.g. `specs/basic-operations.plan.md`.
|
||||||
|
- **Target**: either a single scenario (e.g. `1.2`), a whole group (`1`), or all.
|
||||||
|
- **Seed file**, read from the `**Seed:**` line of the scenario's group.
|
||||||
|
|
||||||
|
### 2.2 Generate one scenario
|
||||||
|
|
||||||
|
For each target scenario, in sequence (never in parallel — scenarios share the seed session):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PLAYWRIGHT_HTML_OPEN=never npx playwright test <seed-file> --debug=cli # background
|
||||||
|
playwright-cli attach tw-XXXX
|
||||||
|
# resume
|
||||||
|
```
|
||||||
|
|
||||||
|
**Do not** just open the app url with playwright-cli, always go through the test to capture any custom setup done there.
|
||||||
|
|
||||||
|
Walk the scenario's `Steps:` one by one with `playwright-cli`, treating the spec as the plan and the live app as the source of truth. If a step is vague ("click the button" — which button?), references an element that no longer exists, or contradicts the app's actual behaviour, use your judgement: update the spec to match what the app really does, then keep going. Editing the spec mid-generation is expected.
|
||||||
|
|
||||||
|
Every action prints the equivalent Playwright TypeScript (see [How generation works](#0-how-generation-works)):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli snapshot # find refs
|
||||||
|
playwright-cli fill e3 "John Doe" # -> page.getByRole('textbox', {...}).fill(...)
|
||||||
|
playwright-cli press Enter
|
||||||
|
playwright-cli click e7
|
||||||
|
```
|
||||||
|
|
||||||
|
For each `- expect:` bullet, add an explicit assertion. See [How generation works](#0-how-generation-works) for details.
|
||||||
|
|
||||||
|
Collect the generated code and write the test file at the path given in the spec:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
// spec: specs/basic-operations.plan.md
|
||||||
|
// seed: tests/seed.spec.ts
|
||||||
|
import { test, expect } from './fixtures'; // or '@playwright/test' if no fixtures file
|
||||||
|
|
||||||
|
test.describe('Signing in and out', () => {
|
||||||
|
test('should sign in', async ({ page }) => {
|
||||||
|
// 1. Navigate to the application
|
||||||
|
// (handled by the seed fixture)
|
||||||
|
|
||||||
|
// 2. Type 'John Doe' into the username field
|
||||||
|
await page.getByRole('textbox', { name: 'username' }).fill('John Doe');
|
||||||
|
|
||||||
|
// 3. Type password
|
||||||
|
await page.getByRole('textbox', { name: 'password' }).fill('TestPassword');
|
||||||
|
|
||||||
|
// 4. Press Enter to submit
|
||||||
|
await page.getByRole('textbox', { name: 'password' }).press('Enter');
|
||||||
|
|
||||||
|
await expect(page.getByRole('heading')).toContainText('Welcome, John Doe!');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- **One test per file.** File path, describe name, and test name come verbatim from the spec (minus the ordinal).
|
||||||
|
- Prefix each numbered step with a `// N. <step text>` comment before its actions.
|
||||||
|
- Use the describe group name verbatim from the spec (no `1.` ordinal).
|
||||||
|
- Import from `./fixtures` if the project has one; otherwise `@playwright/test`.
|
||||||
|
- **Important**: close the CLI session and stop the background test before moving to the next scenario.
|
||||||
|
|
||||||
|
### 2.3 Generate multiple scenarios
|
||||||
|
|
||||||
|
Loop 2.2 over the targeted scenarios one at a time, restarting the seed between each so every test starts from a clean page. This is safe to parallelise due to unique generated session names - just make sure each test run is stopped.
|
||||||
|
|
||||||
|
### 2.4 Run generated tests
|
||||||
|
|
||||||
|
After generation, run the new tests once:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/<group>/<scenario>.spec.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Any failure goes to Section 3.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Heal
|
||||||
|
|
||||||
|
Goal: fix failing tests, and update the spec if the app's intended behaviour changed.
|
||||||
|
|
||||||
|
### 3.1 Find failing tests
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PLAYWRIGHT_HTML_OPEN=never npx playwright test
|
||||||
|
```
|
||||||
|
|
||||||
|
Record the list of failing `<file>:<line>` entries and process them one at a time. Do not attempt parallel fixes — shared state and the single CLI session make that fragile.
|
||||||
|
|
||||||
|
### 3.2 Debug one failure
|
||||||
|
|
||||||
|
Run the single failing test in debug mode in the background, then attach:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
PLAYWRIGHT_HTML_OPEN=never npx playwright test tests/<group>/<scenario>.spec.ts:<line> --debug=cli
|
||||||
|
# wait for "Debugging Instructions" and the tw-XXXX session name
|
||||||
|
playwright-cli attach tw-XXXX
|
||||||
|
```
|
||||||
|
|
||||||
|
The test is paused at the start. Step forward or run to until just before the failing action or assertion, then diagnose:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli snapshot # did the element change / move / rename?
|
||||||
|
playwright-cli console # app-side errors?
|
||||||
|
playwright-cli requests # failed request? wrong payload?
|
||||||
|
playwright-cli show --annotate # ask the user to point somewhere
|
||||||
|
```
|
||||||
|
|
||||||
|
Common causes: selector drift, new wrapper element, label/ARIA rename, timing (transition, async load), assertion text updated in the app, test data leaking between runs.
|
||||||
|
|
||||||
|
Rehearse the corrected interaction with `playwright-cli` — the generated code in the output is what you paste back into the test.
|
||||||
|
|
||||||
|
### 3.3 Apply the fix
|
||||||
|
|
||||||
|
Edit the test file: update the locator, assertion, step order, or inputs to match the corrected behaviour. Stop the background debug run. Rerun the single test to confirm green.
|
||||||
|
|
||||||
|
Never skip hooks or add sleeps as a fix. Never use `networkidle`.
|
||||||
|
|
||||||
|
### 3.4 Reconcile with the spec
|
||||||
|
|
||||||
|
Open the spec referenced by the `// spec:` header in the test file and locate the scenario that matches the test.
|
||||||
|
|
||||||
|
- **Fix was purely technical** (locator drift, better assertion shape) and the spec's user-level behaviour still matches the app → leave the spec alone.
|
||||||
|
- **Fix changed user-visible steps, inputs, order, or expected outcomes** that the spec describes → update the spec to match reality. Keep the scenario id and file path stable; only the step / expect lines change.
|
||||||
|
- **Unclear whether the app change is intentional** (spec is stale) **or a regression** (test was right, app is wrong) → **stop and ask the user**. Provide:
|
||||||
|
- the scenario id (e.g. `2.3`),
|
||||||
|
- the spec lines that no longer match,
|
||||||
|
- the observed app behaviour (quote a snapshot excerpt or a concrete outcome).
|
||||||
|
|
||||||
|
Only after the user answers, either update the spec (intentional change) or file/flag the test as covering a bug (regression).
|
||||||
|
|
||||||
|
### 3.5 Iteration and giving up
|
||||||
|
|
||||||
|
- Fix failures one at a time; rerun after each.
|
||||||
|
- If after thorough investigation you are confident the test is correct but the app is wrong *and* the user has confirmed it's a bug: mark the test `test.fixme(...)` with a comment pointing at the user's decision or issue link. Never silently skip.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Cross-references
|
||||||
|
|
||||||
|
| For... | See |
|
||||||
|
|---|---|
|
||||||
|
| `--debug=cli` / attach mechanics | [playwright-tests.md](playwright-tests.md) |
|
||||||
|
| Mocking requests during exploration/generation | [request-mocking.md](request-mocking.md) |
|
||||||
|
| Managing the CLI browser session | [session-management.md](session-management.md) |
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
# Tracing
|
||||||
|
|
||||||
|
Capture detailed execution traces for debugging and analysis. Traces include DOM snapshots, screenshots, network activity, and console logs.
|
||||||
|
|
||||||
|
## Basic Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start trace recording
|
||||||
|
playwright-cli tracing-start
|
||||||
|
|
||||||
|
# Perform actions
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
playwright-cli click e1
|
||||||
|
playwright-cli fill e2 "test"
|
||||||
|
|
||||||
|
# Stop trace recording
|
||||||
|
playwright-cli tracing-stop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trace Output Files
|
||||||
|
|
||||||
|
When you start tracing, Playwright creates a `traces/` directory with several files:
|
||||||
|
|
||||||
|
### `trace-{timestamp}.trace`
|
||||||
|
|
||||||
|
**Action log** - The main trace file containing:
|
||||||
|
- Every action performed (clicks, fills, navigations)
|
||||||
|
- DOM snapshots before and after each action
|
||||||
|
- Screenshots at each step
|
||||||
|
- Timing information
|
||||||
|
- Console messages
|
||||||
|
- Source locations
|
||||||
|
|
||||||
|
### `trace-{timestamp}.network`
|
||||||
|
|
||||||
|
**Network log** - Complete network activity:
|
||||||
|
- All HTTP requests and responses
|
||||||
|
- Request headers and bodies
|
||||||
|
- Response headers and bodies
|
||||||
|
- Timing (DNS, connect, TLS, TTFB, download)
|
||||||
|
- Resource sizes
|
||||||
|
- Failed requests and errors
|
||||||
|
|
||||||
|
### `resources/`
|
||||||
|
|
||||||
|
**Resources directory** - Cached resources:
|
||||||
|
- Images, fonts, stylesheets, scripts
|
||||||
|
- Response bodies for replay
|
||||||
|
- Assets needed to reconstruct page state
|
||||||
|
|
||||||
|
## What Traces Capture
|
||||||
|
|
||||||
|
| Category | Details |
|
||||||
|
|----------|---------|
|
||||||
|
| **Actions** | Clicks, fills, hovers, keyboard input, navigations |
|
||||||
|
| **DOM** | Full DOM snapshot before/after each action |
|
||||||
|
| **Screenshots** | Visual state at each step |
|
||||||
|
| **Network** | All requests, responses, headers, bodies, timing |
|
||||||
|
| **Console** | All console.log, warn, error messages |
|
||||||
|
| **Timing** | Precise timing for each operation |
|
||||||
|
|
||||||
|
## Use Cases
|
||||||
|
|
||||||
|
### Debugging Failed Actions
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli tracing-start
|
||||||
|
playwright-cli open https://app.example.com
|
||||||
|
|
||||||
|
# This click fails - why?
|
||||||
|
playwright-cli click e5
|
||||||
|
|
||||||
|
playwright-cli tracing-stop
|
||||||
|
# Open trace to see DOM state when click was attempted
|
||||||
|
```
|
||||||
|
|
||||||
|
### Analyzing Performance
|
||||||
|
|
||||||
|
```bash
|
||||||
|
playwright-cli tracing-start
|
||||||
|
playwright-cli open https://slow-site.com
|
||||||
|
playwright-cli tracing-stop
|
||||||
|
|
||||||
|
# View network waterfall to identify slow resources
|
||||||
|
```
|
||||||
|
|
||||||
|
### Capturing Evidence
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Record a complete user flow for documentation
|
||||||
|
playwright-cli tracing-start
|
||||||
|
|
||||||
|
playwright-cli open https://app.example.com/checkout
|
||||||
|
playwright-cli fill e1 "4111111111111111"
|
||||||
|
playwright-cli fill e2 "12/25"
|
||||||
|
playwright-cli fill e3 "123"
|
||||||
|
playwright-cli click e4
|
||||||
|
|
||||||
|
playwright-cli tracing-stop
|
||||||
|
# Trace shows exact sequence of events
|
||||||
|
```
|
||||||
|
|
||||||
|
## Trace vs Video vs Screenshot
|
||||||
|
|
||||||
|
| Feature | Trace | Video | Screenshot |
|
||||||
|
|---------|-------|-------|------------|
|
||||||
|
| **Format** | .trace file | .webm video | .png/.jpeg image |
|
||||||
|
| **DOM inspection** | Yes | No | No |
|
||||||
|
| **Network details** | Yes | No | No |
|
||||||
|
| **Step-by-step replay** | Yes | Continuous | Single frame |
|
||||||
|
| **File size** | Medium | Large | Small |
|
||||||
|
| **Best for** | Debugging | Demos | Quick capture |
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Start Tracing Before the Problem
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Trace the entire flow, not just the failing step
|
||||||
|
playwright-cli tracing-start
|
||||||
|
playwright-cli open https://example.com
|
||||||
|
# ... all steps leading to the issue ...
|
||||||
|
playwright-cli tracing-stop
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Clean Up Old Traces
|
||||||
|
|
||||||
|
Traces can consume significant disk space:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Remove traces older than 7 days
|
||||||
|
find .playwright-cli/traces -mtime +7 -delete
|
||||||
|
```
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Traces add overhead to automation
|
||||||
|
- Large traces can consume significant disk space
|
||||||
|
- Some dynamic content may not replay perfectly
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
# Video Recording
|
||||||
|
|
||||||
|
Capture browser automation sessions as video for debugging, documentation, or verification. Produces WebM (VP8/VP9 codec).
|
||||||
|
|
||||||
|
## Basic Recording
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Open browser first
|
||||||
|
playwright-cli open
|
||||||
|
|
||||||
|
# Start recording
|
||||||
|
playwright-cli video-start demo.webm
|
||||||
|
|
||||||
|
# Add a chapter marker for section transitions
|
||||||
|
playwright-cli video-chapter "Getting Started" --description="Opening the homepage" --duration=2000
|
||||||
|
|
||||||
|
# Navigate and perform actions
|
||||||
|
playwright-cli goto https://example.com
|
||||||
|
playwright-cli snapshot
|
||||||
|
playwright-cli click e1
|
||||||
|
|
||||||
|
# Add another chapter
|
||||||
|
playwright-cli video-chapter "Filling Form" --description="Entering test data" --duration=2000
|
||||||
|
playwright-cli fill e2 "test input"
|
||||||
|
|
||||||
|
# Stop and save
|
||||||
|
playwright-cli video-stop
|
||||||
|
```
|
||||||
|
|
||||||
|
## Best Practices
|
||||||
|
|
||||||
|
### 1. Use Descriptive Filenames
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Include context in filename
|
||||||
|
playwright-cli video-start recordings/login-flow-2024-01-15.webm
|
||||||
|
playwright-cli video-start recordings/checkout-test-run-42.webm
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Record entire hero scripts.
|
||||||
|
|
||||||
|
When recording a video for the user or as a proof of work, it is best to create a code snippet and execute it with run-code.
|
||||||
|
It allows inserting appropriate pauses between the actions and annotating the video. There are new Playwright APIs for that.
|
||||||
|
|
||||||
|
1) Perform scenario using CLI and take note of all locators and actions. You'll need those locators to request their bounding boxes for highlight.
|
||||||
|
2) Create a file with the intended script for video (below). Use pressSequentially w/ delay for nice typing, make reasonable pauses.
|
||||||
|
3) Use playwright-cli run-code --filename your-script.js
|
||||||
|
|
||||||
|
**Important**: Overlays are `pointer-events: none` — they do not interfere with page interactions. You can safely keep sticky overlays visible while clicking, filling, or performing any actions on the page.
|
||||||
|
|
||||||
|
```js
|
||||||
|
async page => {
|
||||||
|
await page.screencast.start({ path: 'video.webm', size: { width: 1280, height: 800 } });
|
||||||
|
await page.goto('https://demo.playwright.dev/todomvc');
|
||||||
|
|
||||||
|
// Show a chapter card — blurs the page and shows a dialog.
|
||||||
|
// Blocks until duration expires, then auto-removes.
|
||||||
|
// Use this for simple use cases, but always feel free to hand-craft your own beautiful
|
||||||
|
// overlay via await page.screencast.showOverlay().
|
||||||
|
await page.screencast.showChapter('Adding Todo Items', {
|
||||||
|
description: 'We will add several items to the todo list.',
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Perform action
|
||||||
|
await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Walk the dog', { delay: 60 });
|
||||||
|
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
|
||||||
|
await page.waitForTimeout(1000);
|
||||||
|
|
||||||
|
// Show next chapter
|
||||||
|
await page.screencast.showChapter('Verifying Results', {
|
||||||
|
description: 'Checking the item appeared in the list.',
|
||||||
|
duration: 2000,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add a sticky annotation that stays while you perform actions.
|
||||||
|
// Overlays are pointer-events: none, so they won't block clicks.
|
||||||
|
const annotation = await page.screencast.showOverlay(`
|
||||||
|
<div style="position: absolute; top: 8px; right: 8px;
|
||||||
|
padding: 6px 12px; background: rgba(0,0,0,0.7);
|
||||||
|
border-radius: 8px; font-size: 13px; color: white;">
|
||||||
|
✓ Item added successfully
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
|
||||||
|
// Perform more actions while the annotation is visible
|
||||||
|
await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Buy groceries', { delay: 60 });
|
||||||
|
await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
|
||||||
|
await page.waitForTimeout(1500);
|
||||||
|
|
||||||
|
// Remove the annotation when done
|
||||||
|
await annotation.dispose();
|
||||||
|
|
||||||
|
// You can also highlight relevant locators and provide contextual annotations.
|
||||||
|
const bounds = await page.getByText('Walk the dog').boundingBox();
|
||||||
|
await page.screencast.showOverlay(`
|
||||||
|
<div style="position: absolute;
|
||||||
|
top: ${bounds.y}px;
|
||||||
|
left: ${bounds.x}px;
|
||||||
|
width: ${bounds.width}px;
|
||||||
|
height: ${bounds.height}px;
|
||||||
|
border: 1px solid red;">
|
||||||
|
</div>
|
||||||
|
<div style="position: absolute;
|
||||||
|
top: ${bounds.y + bounds.height + 5}px;
|
||||||
|
left: ${bounds.x + bounds.width / 2}px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 6px;
|
||||||
|
background: #808080;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 14px;
|
||||||
|
color: white;">Check it out, it is right above this text
|
||||||
|
</div>
|
||||||
|
`, { duration: 2000 });
|
||||||
|
|
||||||
|
await page.screencast.stop();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Embrace creativity, overlays are powerful.
|
||||||
|
|
||||||
|
### Overlay API Summary
|
||||||
|
|
||||||
|
| Method | Use Case |
|
||||||
|
|--------|----------|
|
||||||
|
| `page.screencast.showChapter(title, { description?, duration?, styleSheet? })` | Full-screen chapter card with blurred backdrop — ideal for section transitions |
|
||||||
|
| `page.screencast.showOverlay(html, { duration? })` | Custom HTML overlay — use for callouts, labels, highlights |
|
||||||
|
| `disposable.dispose()` | Remove a sticky overlay added without duration |
|
||||||
|
| `page.screencast.hideOverlays()` / `page.screencast.showOverlays()` | Temporarily hide/show all overlays |
|
||||||
|
|
||||||
|
## Tracing vs Video
|
||||||
|
|
||||||
|
| Feature | Video | Tracing |
|
||||||
|
|---------|-------|---------|
|
||||||
|
| Output | WebM file | Trace file (viewable in Trace Viewer) |
|
||||||
|
| Shows | Visual recording | DOM snapshots, network, console, actions |
|
||||||
|
| Use case | Demos, documentation | Debugging, analysis |
|
||||||
|
| Size | Larger | Smaller |
|
||||||
|
|
||||||
|
## Limitations
|
||||||
|
|
||||||
|
- Recording adds slight overhead to automation
|
||||||
|
- Large recordings can consume significant disk space
|
||||||
Vendored
+4
-9
@@ -7,12 +7,7 @@
|
|||||||
"request": "launch",
|
"request": "launch",
|
||||||
"module": "flask",
|
"module": "flask",
|
||||||
"python": "${command:python.interpreterPath}",
|
"python": "${command:python.interpreterPath}",
|
||||||
"env": {
|
"envFile": "${workspaceFolder}/backend/.env",
|
||||||
"FLASK_APP": "backend/main.py",
|
|
||||||
"FLASK_DEBUG": "1",
|
|
||||||
"SECRET_KEY": "dev-secret-key-change-in-production",
|
|
||||||
"REFRESH_TOKEN_EXPIRY_DAYS": "90"
|
|
||||||
},
|
|
||||||
"args": [
|
"args": [
|
||||||
"run",
|
"run",
|
||||||
"--host=0.0.0.0",
|
"--host=0.0.0.0",
|
||||||
@@ -32,7 +27,7 @@
|
|||||||
"run",
|
"run",
|
||||||
"dev"
|
"dev"
|
||||||
],
|
],
|
||||||
"cwd": "${workspaceFolder}/frontend/vue-app",
|
"cwd": "${workspaceFolder}/frontend",
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"serverReadyAction": {
|
"serverReadyAction": {
|
||||||
"pattern": "Local:.*https://localhost:([0-9]+)",
|
"pattern": "Local:.*https://localhost:([0-9]+)",
|
||||||
@@ -45,7 +40,7 @@
|
|||||||
"type": "pwa-chrome",
|
"type": "pwa-chrome",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"url": "http://localhost:5173",
|
"url": "http://localhost:5173",
|
||||||
"webRoot": "${workspaceFolder}/frontend/vue-app"
|
"webRoot": "${workspaceFolder}/frontend"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Python: Backend Tests",
|
"name": "Python: Backend Tests",
|
||||||
@@ -73,7 +68,7 @@
|
|||||||
"run",
|
"run",
|
||||||
"test:unit"
|
"test:unit"
|
||||||
],
|
],
|
||||||
"cwd": "${workspaceFolder}/frontend/vue-app",
|
"cwd": "${workspaceFolder}/frontend",
|
||||||
"console": "integratedTerminal",
|
"console": "integratedTerminal",
|
||||||
"osx": {
|
"osx": {
|
||||||
"env": {
|
"env": {
|
||||||
|
|||||||
Vendored
+3
-3
@@ -28,7 +28,7 @@
|
|||||||
"run",
|
"run",
|
||||||
"dev"
|
"dev"
|
||||||
],
|
],
|
||||||
"cwd": "${workspaceFolder}/frontend/vue-app",
|
"cwd": "${workspaceFolder}/frontend",
|
||||||
"console": "integratedTerminal"
|
"console": "integratedTerminal"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
"type": "chrome",
|
"type": "chrome",
|
||||||
"request": "launch",
|
"request": "launch",
|
||||||
"url": "https://localhost:5173", // or your Vite dev server port
|
"url": "https://localhost:5173", // or your Vite dev server port
|
||||||
"webRoot": "${workspaceFolder}/frontend/vue-app"
|
"webRoot": "${workspaceFolder}/frontend"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Python: Backend Tests",
|
"name": "Python: Backend Tests",
|
||||||
@@ -60,7 +60,7 @@
|
|||||||
"runtimeArgs": [
|
"runtimeArgs": [
|
||||||
"vitest"
|
"vitest"
|
||||||
],
|
],
|
||||||
"cwd": "${workspaceFolder}/frontend/vue-app",
|
"cwd": "${workspaceFolder}/frontend",
|
||||||
"console": "integratedTerminal"
|
"console": "integratedTerminal"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|||||||
Vendored
+16
-3
@@ -8,8 +8,21 @@
|
|||||||
"run-test-mcp-server",
|
"run-test-mcp-server",
|
||||||
"--config=playwright.config.ts"
|
"--config=playwright.config.ts"
|
||||||
],
|
],
|
||||||
"cwd": "frontend/vue-app"
|
"cwd": "frontend"
|
||||||
|
},
|
||||||
|
"gitea": {
|
||||||
|
"command": "docker",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"-i",
|
||||||
|
"--rm",
|
||||||
|
"-e",
|
||||||
|
"GITEA_ACCESS_TOKEN=${env:GITEA_ACCESS_TOKEN}",
|
||||||
|
"-e",
|
||||||
|
"GITEA_HOST=https://git.ryankegel.com",
|
||||||
|
"docker.gitea.com/gitea-mcp-server"
|
||||||
|
],
|
||||||
|
"type": "stdio"
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
"inputs": []
|
|
||||||
}
|
}
|
||||||
Vendored
+5
@@ -38,4 +38,9 @@
|
|||||||
"editor.fontFamily": "JetBrains Mono",
|
"editor.fontFamily": "JetBrains Mono",
|
||||||
"editor.fontSize": 13,
|
"editor.fontSize": 13,
|
||||||
"editor.fontLigatures": true,
|
"editor.fontLigatures": true,
|
||||||
|
"python.testing.pytestArgs": [
|
||||||
|
"backend"
|
||||||
|
],
|
||||||
|
"python.testing.unittestEnabled": false,
|
||||||
|
"python.testing.pytestEnabled": true,
|
||||||
}
|
}
|
||||||
Vendored
+11
-11
@@ -44,56 +44,56 @@
|
|||||||
{
|
{
|
||||||
"label": "PW: Task Modification Tests",
|
"label": "PW: Task Modification Tests",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "cd \"${workspaceFolder}/frontend/vue-app\" && npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification",
|
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification",
|
||||||
"isBackground": false,
|
"isBackground": false,
|
||||||
"group": "test"
|
"group": "test"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "PW: Task Modification Tests (PS)",
|
"label": "PW: Task Modification Tests (PS)",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "powershell -Command \"cd '$env:APPDATA/../../../d/Python Utilities/Reward/frontend/vue-app'; npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification\"",
|
"command": "powershell -Command \"cd '$env:APPDATA/../../../d/Python Utilities/Reward/frontend'; npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification\"",
|
||||||
"isBackground": false,
|
"isBackground": false,
|
||||||
"group": "test"
|
"group": "test"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "PW: Task Modification Tests (cmd)",
|
"label": "PW: Task Modification Tests (cmd)",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "cmd /c \"cd /d \\\"D:\\Python Utilities\\Reward\\frontend\\vue-app\\\" && npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification 2>&1\"",
|
"command": "cmd /c \"cd /d \\\"D:\\Python Utilities\\Reward\\frontend\\\" && npx playwright test e2e/mode_parent/task-modification --project=chromium-task-modification 2>&1\"",
|
||||||
"isBackground": false,
|
"isBackground": false,
|
||||||
"group": "test"
|
"group": "test"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "PW: User Profile Tests",
|
"label": "PW: User Profile Tests",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "cd \"${workspaceFolder}/frontend/vue-app\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
|
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
|
||||||
"isBackground": false,
|
"isBackground": false,
|
||||||
"group": "test"
|
"group": "test"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "PW: User Profile Tests 2",
|
"label": "PW: User Profile Tests 2",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "cd \"${workspaceFolder}/frontend/vue-app\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
|
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
|
||||||
"isBackground": false,
|
"isBackground": false,
|
||||||
"group": "test"
|
"group": "test"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "PW: User Profile Tests Final",
|
"label": "PW: User Profile Tests Final",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "cd \"${workspaceFolder}/frontend/vue-app\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
|
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test e2e/mode_parent/user-profile --project=chromium-user-profile --project=chromium-user-profile-pin --project=chromium-user-profile-delete",
|
||||||
"isBackground": false,
|
"isBackground": false,
|
||||||
"group": "test"
|
"group": "test"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "PW: Full Test Suite",
|
"label": "PW: Full Test Suite",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "cd \"${workspaceFolder}/frontend/vue-app\" && npx playwright test --reporter=line",
|
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test --reporter=line",
|
||||||
"isBackground": false,
|
"isBackground": false,
|
||||||
"group": "test"
|
"group": "test"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "PW: Full Suite",
|
"label": "PW: Full Suite",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "cd \"${workspaceFolder}/frontend/vue-app\" && npx playwright test --reporter=line",
|
"command": "cd \"${workspaceFolder}/frontend\" && npx playwright test --reporter=line",
|
||||||
"isBackground": false,
|
"isBackground": false,
|
||||||
"group": "test"
|
"group": "test"
|
||||||
},
|
},
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
"args": [
|
"args": [
|
||||||
"-NoProfile",
|
"-NoProfile",
|
||||||
"-Command",
|
"-Command",
|
||||||
"cd 'D:\\Python Utilities\\Reward\\frontend\\vue-app'; npx playwright test --reporter=line 2>&1"
|
"cd 'D:\\Python Utilities\\Reward\\frontend'; npx playwright test --reporter=line 2>&1"
|
||||||
],
|
],
|
||||||
"group": "test",
|
"group": "test",
|
||||||
"presentation": {
|
"presentation": {
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
"args": [
|
"args": [
|
||||||
"-NoProfile",
|
"-NoProfile",
|
||||||
"-Command",
|
"-Command",
|
||||||
"cd 'D:\\Python Utilities\\Reward\\frontend\\vue-app'; npx playwright test e2e/mode_parent/tasks/penalty-default.spec.ts --project=chromium-default-tasks --reporter=line 2>&1"
|
"cd 'D:\\Python Utilities\\Reward\\frontend'; npx playwright test e2e/mode_parent/tasks/penalty-default.spec.ts --project=chromium-default-tasks --reporter=line 2>&1"
|
||||||
],
|
],
|
||||||
"group": "test",
|
"group": "test",
|
||||||
"presentation": {
|
"presentation": {
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
"args": [
|
"args": [
|
||||||
"-NoProfile",
|
"-NoProfile",
|
||||||
"-Command",
|
"-Command",
|
||||||
"cd 'D:\\Python Utilities\\Reward\\frontend\\vue-app'; npx playwright test e2e/mode_parent/user-profile/profile-editing.spec.ts --project=chromium-user-profile --reporter=line 2>&1"
|
"cd 'D:\\Python Utilities\\Reward\\frontend'; npx playwright test e2e/mode_parent/user-profile/profile-editing.spec.ts --project=chromium-user-profile --reporter=line 2>&1"
|
||||||
],
|
],
|
||||||
"group": "test",
|
"group": "test",
|
||||||
"presentation": {
|
"presentation": {
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Family chore/reward manager. Flask + TinyDB backend (`backend/`), Vue 3 + TypeScript frontend (`frontend/`). Real-time updates over SSE.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Backend (run from `backend/`)
|
||||||
|
- Activate venv: `source .venv/bin/activate`
|
||||||
|
- Dev server: `python -m flask run --host=0.0.0.0 --port=5000` (entry: `main.py`)
|
||||||
|
- Required env vars: `SECRET_KEY`, `REFRESH_TOKEN_EXPIRY_DAYS`, `DIGEST_TOKEN_SECRET`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` — Flask raises `RuntimeError` on boot if any are missing
|
||||||
|
- Optional persistence switch: `USE_MONGODB` (`true` | `false`). Defaults to `true`; set `MONGO_URI` (and optionally `MONGO_DB_NAME`). Set to `false` to use TinyDB instead.
|
||||||
|
- Optional: `DB_ENV` / `DATA_ENV` (`prod` | `test` | `e2e`) — picks `data/` vs `test_data/` dir (see `config/paths.py`). For MongoDB these also select the default database name (`chore_db`, `chore_db_test`, `chore_db_e2e`) unless `MONGO_DB_NAME` is set.
|
||||||
|
- Tests: `pytest tests/` — `conftest.py` forces `DB_ENV=test`, `USE_MONGODB=true`, `MONGO_URI=mongomock`, and sets dummy secrets. Single test: `pytest tests/test_routine_api.py::test_name`
|
||||||
|
- Python imports assume `backend/` is on `sys.path` (set by `conftest.py` / `flask run` cwd). Run pytest from `backend/`.
|
||||||
|
- Create admin user: `python scripts/create_admin.py` (admin role cannot be set via signup)
|
||||||
|
|
||||||
|
### Frontend (run from `frontend/`)
|
||||||
|
- Dev: `npm run dev` (Vite, https://localhost:5173)
|
||||||
|
- Lint: `npm run lint`
|
||||||
|
- Type-check: `npm run type-check`
|
||||||
|
- Unit tests: `npm run test:unit` (Vitest). Single: `npx vitest run path/to/file.spec.ts`
|
||||||
|
- E2E: `npx playwright test` — config auto-starts both `npm run dev` and the Flask backend with `DB_ENV=e2e DATA_ENV=e2e USE_MONGODB=true MONGO_URI=mongomock`. Tests live in `e2e/`. `frontend/.env.test` contains the example MongoDB config.
|
||||||
|
- E2E buckets are Playwright projects (see `playwright.config.ts`) targeting directories under `e2e/mode_parent/`
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### API routing — the `/api` prefix
|
||||||
|
- Frontend nginx (and Vite dev proxy) strips `/api` before forwarding. **Backend routes must NOT include `/api`.** Backend defines `@app.route('/user')`, frontend calls `/api/user`.
|
||||||
|
- `auth_api` is the only blueprint registered with a prefix: `url_prefix='/auth'` in `main.py:67`.
|
||||||
|
- API errors return `{ error, code }` (codes in `backend/api/error_codes.py`). Frontend extracts them via `parseErrorResponse(res)` in `src/common/api.ts`.
|
||||||
|
|
||||||
|
### Models — strict 1:1 parity
|
||||||
|
- Python `@dataclass`es in `backend/models/`. TypeScript interfaces in `frontend/src/common/models.ts`. Any model change requires updating both.
|
||||||
|
- Persistence is MongoDB by default (`USE_MONGODB=true`), or TinyDB when `USE_MONGODB=false`. Both are accessed through the `LockedTable` / `MongoLockedTable` wrappers in `backend/db/db.py`. Operate on model instances with `from_dict()` / `to_dict()` — never raw dicts.
|
||||||
|
- MongoDB client initialization is lazy (`backend/db/mongo_client.py`). `backend/gunicorn.conf.py` provides the `post_fork` hook required for multi-worker Gunicorn deployments; `backend/Dockerfile` loads it with `-c gunicorn.conf.py`.
|
||||||
|
- Migration script: `cd backend && python -m scripts/migrate_to_mongodb [--dry-run]`. It reads TinyDB JSON files and writes them to MongoDB idempotently, backing up the originals to `<db_dir>/backups/<timestamp>/`.
|
||||||
|
|
||||||
|
### SSE event bus — mandatory for every mutation
|
||||||
|
- Every backend mutation (add/edit/delete/trigger) **must** call `send_event_for_current_user` from `api/utils.py`. Event types in `backend/events/types/` are mirrored in `frontend/src/common/backendEvents.ts`.
|
||||||
|
- Frontend: register listeners in `onMounted`, clean up in `onUnmounted`. SSE endpoint is `/events`.
|
||||||
|
|
||||||
|
### Background schedulers (started in `main.py` at boot)
|
||||||
|
- `start_deletion_scheduler` — runs hourly, deletes accounts marked for deletion after threshold
|
||||||
|
- `start_digest_scheduler` — email digests
|
||||||
|
- `start_state_expiry_scheduler` — expires stale state
|
||||||
|
- `start_chore_expiry_notification_scheduler` — chore expiry notifications
|
||||||
|
|
||||||
|
## Frontend conventions
|
||||||
|
- SFC file order: `<template>` → `<script>` → `<style scoped>`. TypeScript only in `<script>`. All styles must be `scoped`.
|
||||||
|
- Colors/spacing: use only `:root` CSS variables from `colors.css`. No hardcoded hex/px for themed properties.
|
||||||
|
- Layout shells: `ParentLayout` for admin/management, `ChildLayout` for child dashboard/focus.
|
||||||
|
- Images: models carry `image_id`; frontend resolves to `image_url` for rendering.
|
||||||
|
|
||||||
|
## Testing gotchas
|
||||||
|
- E2E tests use pre-authenticated sessions via `storageState` in `playwright.config.ts` — do **not** navigate to `/auth/login`. Import `E2E_EMAIL` / `E2E_PASSWORD` from `e2e/e2e-constants.ts`.
|
||||||
|
- E2E buckets that mutate shared state (default tasks, delete-account, create-child) use isolated users. Preserve this pattern when adding new buckets.
|
||||||
|
- Backend tests: `conftest.py` sets `DB_ENV=test` + dummy secrets. Test DB lands in `test_data/db/`, never touches production `data/`.
|
||||||
|
|
||||||
|
## Feature specs
|
||||||
|
Specs live in `.github/specs/`. If a spec has a checklist, all items must be marked done before the feature is complete.
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## Project
|
||||||
|
|
||||||
|
Family chore/reward manager. Flask + TinyDB backend (`backend/`), Vue 3 + TypeScript frontend (`frontend/`). Real-time updates flow over Server-Sent Events.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
### Backend (run from `backend/`)
|
||||||
|
- Activate venv first: `source .venv/bin/activate` (mac/linux) — Python runs from `backend/.venv/`.
|
||||||
|
- Dev server: `python -m flask run --host=0.0.0.0 --port=5000` (entry: `backend/main.py`).
|
||||||
|
- Required env vars at startup: `SECRET_KEY`, `REFRESH_TOKEN_EXPIRY_DAYS`, `DIGEST_TOKEN_SECRET`, `VAPID_PUBLIC_KEY`, `VAPID_PRIVATE_KEY` — Flask raises `RuntimeError` on boot if any are missing.
|
||||||
|
- Optional: `DB_ENV` / `DATA_ENV` (`prod` | `test` | `e2e`) — picks `data/` vs `test_data/` directory (see `backend/config/paths.py`).
|
||||||
|
- Tests: `pytest tests/` — `tests/conftest.py` forces `DB_ENV=test` and sets dummy secrets. Single test: `pytest tests/test_routine_api.py::test_name`.
|
||||||
|
- Create an admin user (cannot be done via signup): `python scripts/create_admin.py`.
|
||||||
|
|
||||||
|
### Frontend (run from `frontend/`)
|
||||||
|
- Dev: `npm run dev` (Vite, https://localhost:5173).
|
||||||
|
- Build: `npm run build`. Type-check: `npm run type-check`. Lint: `npm run lint`.
|
||||||
|
- Unit/component tests: `npm run test:unit` (Vitest). Single test: `npx vitest run path/to/file.spec.ts`.
|
||||||
|
- E2E: `npx playwright test` from `frontend/`. Config at `playwright.config.ts` auto-starts both `npm run dev` and the Flask backend with `DB_ENV=e2e DATA_ENV=e2e`, so test data lands in `backend/test_data/` and never touches production. The `globalSetup` seeds the DB and logs in; tests receive a pre-authenticated session via `storageState` — do **not** navigate to `/auth/login`. Import `E2E_EMAIL` / `E2E_PASSWORD` from `e2e/e2e-constants.ts` rather than hardcoding.
|
||||||
|
- E2E suite is split into Playwright "projects" in `playwright.config.ts` (`chromium-routines`, `chromium-task-assignment`, …) — each bucket targets a directory under `e2e/mode_parent/` and some use isolated users to avoid cross-bucket interference. Run a single bucket: `npx playwright test --project=chromium-routines`.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
### API surface and nginx proxy
|
||||||
|
- Each entity has its own Flask blueprint in `backend/api/` (`child_api.py`, `chore_api.py`, `routine_api.py`, …). Registered in `backend/main.py`.
|
||||||
|
- The frontend nginx (and Vite dev proxy) strips `/api` before forwarding. **Backend routes must NOT include `/api`** — backend defines `@app.route('/user')`, frontend calls `/api/user`.
|
||||||
|
- The `auth_api` blueprint is the only one mounted under a prefix (`/auth`).
|
||||||
|
- API errors return `{ error, code }`; codes live in `backend/api/error_codes.py`. Frontend extracts them via `parseErrorResponse(res)` in `frontend/src/common/api.ts`.
|
||||||
|
|
||||||
|
### Models — keep 1:1 parity
|
||||||
|
- Python `@dataclass`es live in `backend/models/`. TypeScript interfaces live in `frontend/src/common/models.ts`. Any model change requires updating **both**.
|
||||||
|
- Persistence is TinyDB (JSON files under `data/db/` or `test_data/db/`). All DB access goes through the thread-safe `LockedTable` wrapper in `backend/db/db.py`. Always operate on model instances using `from_dict()` / `to_dict()` — never raw dicts.
|
||||||
|
|
||||||
|
### SSE event bus (mandatory for every mutation)
|
||||||
|
- Every backend mutation (add/edit/delete/trigger) **must** call `send_event_for_current_user` (from `api/utils.py`). Event types live in `backend/events/types/` and `frontend/src/common/backendEvents.ts` (mirrored).
|
||||||
|
- Frontend state is event-driven: register listeners in `onMounted`, clean up in `onUnmounted`. See `components/BackendEventsListener.vue` and `src/common/backendEvents.ts`.
|
||||||
|
- The SSE endpoint is `/events`; per-user queues live in `backend/events/sse.py`.
|
||||||
|
|
||||||
|
### Background schedulers
|
||||||
|
Started in `backend/main.py` at boot:
|
||||||
|
- `start_deletion_scheduler` — runs hourly, deletes accounts that were marked-for-deletion at least `ACCOUNT_DELETION_THRESHOLD_HOURS` ago (default 720, min 24, max 720). Cleans pending rewards, children, tasks, rewards, images, then the user. Logs to `logs/account_deletion.log`.
|
||||||
|
- `start_digest_scheduler` — email digests.
|
||||||
|
- `start_state_expiry_scheduler` — expires stale state.
|
||||||
|
- `start_chore_expiry_notification_scheduler` — chore expiry notifications.
|
||||||
|
|
||||||
|
### Auth & security
|
||||||
|
- JWT in HttpOnly + Secure + SameSite=Strict cookies. Verification tokens expire in 4 hours; password-reset tokens in 10 minutes.
|
||||||
|
- Admin role is **never** assignable via signup — use `backend/scripts/create_admin.py`. Admin endpoints under `/admin/*` enforce role check.
|
||||||
|
|
||||||
|
### Frontend conventions
|
||||||
|
- Vue SFC file order: `<template>` → `<script>` → `<style scoped>`. TypeScript only inside `<script>`. **All styles must be `scoped`.**
|
||||||
|
- Use **only** `:root` CSS variables from `colors.css` for colors/spacing/tokens (e.g. `--btn-primary`, `--list-item-bg-good`). No hardcoded hex/px values for themed properties.
|
||||||
|
- Layout shells: `ParentLayout` for admin/management views, `ChildLayout` for child dashboard/focus views.
|
||||||
|
- Images: models carry `image_id`; frontend resolves to `image_url` for rendering.
|
||||||
|
|
||||||
|
### Specs
|
||||||
|
Feature specs live in `.github/specs/`. If a spec has a checklist, all items must be marked done before the feature is considered complete.
|
||||||
|
|
||||||
|
## Gotchas
|
||||||
|
|
||||||
|
- Backend Python imports assume `backend/` is on `sys.path` (added by `conftest.py` for tests, by `flask run` cwd in dev). Run pytest from `backend/`.
|
||||||
|
- Don't replace code with comments; mirror changes across backend + frontend so model/event parity holds.
|
||||||
|
- E2E tests share a single seeded user by default — buckets that mutate shared state (default tasks, delete-account, create-child) deliberately use isolated users; preserve that pattern when adding new buckets.
|
||||||
@@ -4,7 +4,7 @@ A family-friendly application for managing chores, tasks, and rewards for childr
|
|||||||
|
|
||||||
## 🏗️ Architecture
|
## 🏗️ Architecture
|
||||||
|
|
||||||
- **Backend**: Flask (Python) with TinyDB for data persistence
|
- **Backend**: Flask (Python) with TinyDB or MongoDB for data persistence
|
||||||
- **Frontend**: Vue 3 (TypeScript) with real-time SSE updates
|
- **Frontend**: Vue 3 (TypeScript) with real-time SSE updates
|
||||||
- **Deployment**: Docker with nginx reverse proxy
|
- **Deployment**: Docker with nginx reverse proxy
|
||||||
|
|
||||||
@@ -24,7 +24,7 @@ python -m flask run --host=0.0.0.0 --port=5000
|
|||||||
### Frontend
|
### Frontend
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend/vue-app
|
cd frontend
|
||||||
npm install
|
npm install
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
@@ -38,6 +38,37 @@ npm run dev
|
|||||||
| `ACCOUNT_DELETION_THRESHOLD_HOURS` | Hours to wait before deleting marked accounts | 720 (30 days) |
|
| `ACCOUNT_DELETION_THRESHOLD_HOURS` | Hours to wait before deleting marked accounts | 720 (30 days) |
|
||||||
| `DB_ENV` | Database environment (`prod` or `test`) | `prod` |
|
| `DB_ENV` | Database environment (`prod` or `test`) | `prod` |
|
||||||
| `DATA_ENV` | Data directory environment (`prod` or `test`) | `prod` |
|
| `DATA_ENV` | Data directory environment (`prod` or `test`) | `prod` |
|
||||||
|
| `USE_MONGODB` | Use MongoDB (`true`/`false`) | `true` |
|
||||||
|
| `MONGO_URI` | MongoDB connection URI (required when `USE_MONGODB=true`) | — |
|
||||||
|
| `MONGO_DB_NAME` | MongoDB database name (optional) | Parsed from `MONGO_URI`, or `chore_db`/`chore_db_test`/`chore_db_e2e` based on `DB_ENV` |
|
||||||
|
|
||||||
|
### Database Backend
|
||||||
|
|
||||||
|
The application supports two persistence backends:
|
||||||
|
|
||||||
|
- **MongoDB** (default): Set `MONGO_URI` (and optionally `MONGO_DB_NAME`). This is the recommended backend for production and managed hosting (e.g., MongoDB Atlas).
|
||||||
|
- **TinyDB**: JSON-file storage in `backend/data/db/` (or `backend/test_data/db/` for `test`/`e2e`). Opt in by setting `USE_MONGODB=false`.
|
||||||
|
|
||||||
|
#### Migrating from TinyDB to MongoDB
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
# Dry run to preview what will be migrated
|
||||||
|
python -m scripts.migrate_to_mongodb --dry-run
|
||||||
|
|
||||||
|
# Run the migration (backs up TinyDB files first)
|
||||||
|
python -m scripts.migrate_to_mongodb
|
||||||
|
```
|
||||||
|
|
||||||
|
The migration script reads the existing TinyDB JSON files and inserts each record into the matching MongoDB collection, skipping records that already exist. Original TinyDB files are backed up to `backend/data/db/backups/<timestamp>/`.
|
||||||
|
|
||||||
|
#### Rolling Back to TinyDB
|
||||||
|
|
||||||
|
Set `USE_MONGODB=false`. The original JSON files remain in place.
|
||||||
|
|
||||||
|
#### Gunicorn / Docker
|
||||||
|
|
||||||
|
When running multiple Gunicorn workers, each worker must create its own MongoDB client after forking. This is handled automatically by `backend/gunicorn.conf.py`, which is loaded by `backend/Dockerfile` via `-c gunicorn.conf.py`.
|
||||||
|
|
||||||
### Account Deletion Scheduler
|
### Account Deletion Scheduler
|
||||||
|
|
||||||
@@ -114,7 +145,7 @@ pytest tests/
|
|||||||
### Frontend Tests
|
### Frontend Tests
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd frontend/vue-app
|
cd frontend
|
||||||
npm run test
|
npm run test
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -145,17 +176,16 @@ npm run test
|
|||||||
├── backend/
|
├── backend/
|
||||||
│ ├── api/ # REST API endpoints
|
│ ├── api/ # REST API endpoints
|
||||||
│ ├── config/ # Configuration files
|
│ ├── config/ # Configuration files
|
||||||
│ ├── db/ # TinyDB setup
|
│ ├── db/ # TinyDB / MongoDB persistence layer
|
||||||
│ ├── events/ # SSE event system
|
│ ├── events/ # SSE event system
|
||||||
│ ├── models/ # Data models
|
│ ├── models/ # Data models
|
||||||
│ ├── tests/ # Backend tests
|
│ ├── tests/ # Backend tests
|
||||||
│ └── utils/ # Utilities (scheduler, etc)
|
│ └── utils/ # Utilities (scheduler, etc)
|
||||||
├── frontend/
|
├── frontend/
|
||||||
│ └── vue-app/
|
│ └── src/
|
||||||
│ └── src/
|
│ ├── common/ # Shared utilities
|
||||||
│ ├── common/ # Shared utilities
|
│ ├── components/ # Vue components
|
||||||
│ ├── components/ # Vue components
|
│ └── layout/ # Layout components
|
||||||
│ └── layout/ # Layout components
|
|
||||||
└── .github/
|
└── .github/
|
||||||
└── specs/ # Feature specifications
|
└── specs/ # Feature specifications
|
||||||
```
|
```
|
||||||
|
|||||||
+1
-1
@@ -15,4 +15,4 @@ ENV PYTHONIOENCODING=utf-8
|
|||||||
VOLUME ["/app/data"]
|
VOLUME ["/app/data"]
|
||||||
|
|
||||||
# Use Gunicorn instead of python main.py
|
# Use Gunicorn instead of python main.py
|
||||||
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "-k", "gevent", "--workers", "1", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "--log-level", "info", "main:app"]
|
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "-k", "gevent", "--workers", "1", "--timeout", "120", "--access-logfile", "-", "--error-logfile", "-", "--log-level", "info", "-c", "gunicorn.conf.py", "main:app"]
|
||||||
+133
-1
@@ -1,10 +1,12 @@
|
|||||||
|
import os
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
|
|
||||||
from db.db import users_db
|
from db.db import users_db
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from api.utils import admin_required
|
from api.utils import admin_required, get_validated_user_id
|
||||||
from config.deletion_config import (
|
from config.deletion_config import (
|
||||||
ACCOUNT_DELETION_THRESHOLD_HOURS,
|
ACCOUNT_DELETION_THRESHOLD_HOURS,
|
||||||
MIN_THRESHOLD_HOURS,
|
MIN_THRESHOLD_HOURS,
|
||||||
@@ -153,3 +155,133 @@ def trigger_deletion_queue():
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Test-only endpoint — active ONLY when DB_ENV=e2e
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@admin_api.route('/admin/test/digest-token', methods=['POST'])
|
||||||
|
def create_test_digest_token():
|
||||||
|
"""Create a valid DigestActionToken for E2E tests.
|
||||||
|
|
||||||
|
Only active when DB_ENV=e2e. Requires authentication.
|
||||||
|
"""
|
||||||
|
if os.environ.get('DB_ENV') != 'e2e':
|
||||||
|
return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404
|
||||||
|
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
child_id = data.get('child_id')
|
||||||
|
entity_id = data.get('entity_id')
|
||||||
|
entity_type = data.get('entity_type')
|
||||||
|
action = data.get('action')
|
||||||
|
expires_in_hours = data.get('expires_in_hours', 24)
|
||||||
|
|
||||||
|
if not all([child_id, entity_id, entity_type, action]):
|
||||||
|
return jsonify({'error': 'child_id, entity_id, entity_type, and action are required',
|
||||||
|
'code': 'MISSING_FIELDS'}), 400
|
||||||
|
|
||||||
|
if entity_type not in ('chore', 'reward'):
|
||||||
|
return jsonify({'error': 'entity_type must be "chore" or "reward"',
|
||||||
|
'code': 'INVALID_ENTITY_TYPE'}), 400
|
||||||
|
|
||||||
|
if action not in ('approve', 'deny'):
|
||||||
|
return jsonify({'error': 'action must be "approve" or "deny"',
|
||||||
|
'code': 'INVALID_ACTION'}), 400
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.digest_token import create_action_token
|
||||||
|
token = create_action_token(
|
||||||
|
user_id=user_id,
|
||||||
|
child_id=child_id,
|
||||||
|
entity_id=entity_id,
|
||||||
|
entity_type=entity_type,
|
||||||
|
action=action,
|
||||||
|
expiry_hours=int(expires_in_hours),
|
||||||
|
)
|
||||||
|
return jsonify({'token': token.id}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api.route('/admin/test/send-digest', methods=['POST'])
|
||||||
|
def send_test_digest():
|
||||||
|
"""Trigger a digest email for a specific user by email address.
|
||||||
|
|
||||||
|
Only active when DB_ENV is not 'production'. Requires admin authentication.
|
||||||
|
Note: actual email delivery is skipped in e2e mode by email_sender.
|
||||||
|
"""
|
||||||
|
if os.environ.get('DB_ENV') == 'production':
|
||||||
|
return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404
|
||||||
|
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
# Verify caller is admin
|
||||||
|
caller_dict = users_db.get(Query().id == user_id)
|
||||||
|
if not caller_dict or caller_dict.get('role') != 'admin':
|
||||||
|
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
email = data.get('email', '').strip().lower()
|
||||||
|
if not email:
|
||||||
|
return jsonify({'error': 'email is required', 'code': 'MISSING_FIELDS'}), 400
|
||||||
|
|
||||||
|
target = users_db.get(Query().email == email)
|
||||||
|
if not target:
|
||||||
|
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
|
||||||
|
|
||||||
|
target_id = target.get('id')
|
||||||
|
target_email = target.get('email')
|
||||||
|
|
||||||
|
try:
|
||||||
|
from flask import current_app
|
||||||
|
from utils.digest_scheduler import send_digest_for_user
|
||||||
|
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
|
||||||
|
items_sent = send_digest_for_user(target_id, target_email, frontend_url)
|
||||||
|
return jsonify({'items_sent': items_sent}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@admin_api.route('/admin/test/trigger-chore-expiry', methods=['POST'])
|
||||||
|
def trigger_test_chore_expiry():
|
||||||
|
"""Trigger the chore expiry notification check for a specific user by email address.
|
||||||
|
|
||||||
|
Only active when DB_ENV is not 'production'. Requires admin authentication.
|
||||||
|
Note: actual push delivery requires VAPID keys to be configured.
|
||||||
|
"""
|
||||||
|
if os.environ.get('DB_ENV') == 'production':
|
||||||
|
return jsonify({'error': 'Not found', 'code': 'NOT_FOUND'}), 404
|
||||||
|
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
caller_dict = users_db.get(Query().id == user_id)
|
||||||
|
if not caller_dict or caller_dict.get('role') != 'admin':
|
||||||
|
return jsonify({'error': 'Admin access required', 'code': 'ADMIN_REQUIRED'}), 403
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
email = data.get('email', '').strip().lower()
|
||||||
|
if not email:
|
||||||
|
return jsonify({'error': 'email is required', 'code': 'MISSING_FIELDS'}), 400
|
||||||
|
|
||||||
|
target = users_db.get(Query().email == email)
|
||||||
|
if not target:
|
||||||
|
return jsonify({'error': 'User not found', 'code': 'USER_NOT_FOUND'}), 404
|
||||||
|
|
||||||
|
target_id = target.get('id')
|
||||||
|
tz_str = target.get('timezone')
|
||||||
|
|
||||||
|
try:
|
||||||
|
from utils.chore_expiry_notification_scheduler import send_chore_expiry_notifications_for_user
|
||||||
|
chores_notified = send_chore_expiry_notifications_for_user(target_id, tz_str)
|
||||||
|
return jsonify({'chores_notified': chores_notified}), 200
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({'error': str(e), 'code': 'SERVER_ERROR'}), 500
|
||||||
|
|||||||
+111
-23
@@ -29,6 +29,8 @@ from db.db import (
|
|||||||
users_db, refresh_tokens_db, child_db, task_db, reward_db, image_db,
|
users_db, refresh_tokens_db, child_db, task_db, reward_db, image_db,
|
||||||
pending_reward_db, pending_confirmations_db, tracking_events_db,
|
pending_reward_db, pending_confirmations_db, tracking_events_db,
|
||||||
child_overrides_db, chore_schedules_db, task_extensions_db,
|
child_overrides_db, chore_schedules_db, task_extensions_db,
|
||||||
|
routine_db, routine_items_db, routine_schedules_db, routine_extensions_db,
|
||||||
|
push_subscriptions_db, digest_action_tokens_db,
|
||||||
)
|
)
|
||||||
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
|
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
|
||||||
from api.utils import normalize_email
|
from api.utils import normalize_email
|
||||||
@@ -39,7 +41,16 @@ UserQuery = Query()
|
|||||||
TokenQuery = Query()
|
TokenQuery = Query()
|
||||||
TOKEN_EXPIRY_MINUTES = 60 * 4
|
TOKEN_EXPIRY_MINUTES = 60 * 4
|
||||||
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
|
RESET_PASSWORD_TOKEN_EXPIRY_MINUTES = 10
|
||||||
ACCESS_TOKEN_EXPIRY_MINUTES = 15
|
try:
|
||||||
|
ACCESS_TOKEN_EXPIRY_MINUTES = int(os.environ.get('ACCESS_TOKEN_EXPIRY_MINUTES', '15'))
|
||||||
|
except ValueError:
|
||||||
|
ACCESS_TOKEN_EXPIRY_MINUTES = 15
|
||||||
|
try:
|
||||||
|
REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS = int(
|
||||||
|
os.environ.get('REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS', '30')
|
||||||
|
)
|
||||||
|
except ValueError:
|
||||||
|
REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS = 30
|
||||||
E2E_TEST_EMAIL = 'e2e@test.com'
|
E2E_TEST_EMAIL = 'e2e@test.com'
|
||||||
E2E_TEST_PASSWORD = 'E2eTestPass1!'
|
E2E_TEST_PASSWORD = 'E2eTestPass1!'
|
||||||
E2E_TEST_PIN = '1234'
|
E2E_TEST_PIN = '1234'
|
||||||
@@ -49,6 +60,9 @@ E2E_DELETE_PIN = '5678'
|
|||||||
E2E_CC_EMAIL = 'e2e-cc@test.com'
|
E2E_CC_EMAIL = 'e2e-cc@test.com'
|
||||||
E2E_CC_PASSWORD = 'E2eCCPass1!'
|
E2E_CC_PASSWORD = 'E2eCCPass1!'
|
||||||
E2E_CC_PIN = '3456'
|
E2E_CC_PIN = '3456'
|
||||||
|
E2E_TUTORIAL_EMAIL = 'e2e-tutorial@test.com'
|
||||||
|
E2E_TUTORIAL_PASSWORD = 'E2eTutorialPass1!'
|
||||||
|
E2E_TUTORIAL_PIN = '7890'
|
||||||
|
|
||||||
|
|
||||||
def send_verification_email(to_email, token):
|
def send_verification_email(to_email, token):
|
||||||
@@ -406,18 +420,21 @@ def refresh():
|
|||||||
|
|
||||||
token_record = RefreshToken.from_dict(token_dict)
|
token_record = RefreshToken.from_dict(token_dict)
|
||||||
|
|
||||||
# THEFT DETECTION: token was already used (rotated out) but replayed
|
# Look up the user early (needed for both legitimate rotation and grace-period handling)
|
||||||
if token_record.is_used:
|
user_dict = users_db.get(UserQuery.id == token_record.user_id)
|
||||||
logger.warning(
|
user = User.from_dict(user_dict) if user_dict else None
|
||||||
'Refresh token reuse detected! user_id=%s, family=%s, ip=%s — killing all sessions',
|
if not user:
|
||||||
token_record.user_id, token_record.token_family, request.remote_addr,
|
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
|
||||||
)
|
resp = jsonify({'error': 'User not found', 'code': USER_NOT_FOUND})
|
||||||
# Nuke ALL refresh tokens for this user
|
|
||||||
refresh_tokens_db.remove(TokenQuery.user_id == token_record.user_id)
|
|
||||||
resp = jsonify({'error': 'Token reuse detected, all sessions invalidated', 'code': REFRESH_TOKEN_REUSE})
|
|
||||||
_clear_auth_cookies(resp)
|
_clear_auth_cookies(resp)
|
||||||
return resp, 401
|
return resp, 401
|
||||||
|
|
||||||
|
if user.marked_for_deletion:
|
||||||
|
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
|
||||||
|
resp = jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION})
|
||||||
|
_clear_auth_cookies(resp)
|
||||||
|
return resp, 403
|
||||||
|
|
||||||
# Check expiry
|
# Check expiry
|
||||||
try:
|
try:
|
||||||
exp = datetime.fromisoformat(token_record.expires_at)
|
exp = datetime.fromisoformat(token_record.expires_at)
|
||||||
@@ -434,23 +451,59 @@ def refresh():
|
|||||||
_clear_auth_cookies(resp)
|
_clear_auth_cookies(resp)
|
||||||
return resp, 401
|
return resp, 401
|
||||||
|
|
||||||
# Look up the user
|
# THEFT DETECTION: token was already used (rotated out) but replayed
|
||||||
user_dict = users_db.get(UserQuery.id == token_record.user_id)
|
if token_record.is_used:
|
||||||
user = User.from_dict(user_dict) if user_dict else None
|
# Grace period: tolerate a very recent rotation to avoid false positives
|
||||||
if not user:
|
# from legitimate concurrent refresh requests (race conditions).
|
||||||
refresh_tokens_db.remove(TokenQuery.id == token_record.id)
|
grace_period = current_app.config.get(
|
||||||
resp = jsonify({'error': 'User not found', 'code': USER_NOT_FOUND})
|
'REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS', REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS
|
||||||
|
)
|
||||||
|
rotated_at = token_record.rotated_at
|
||||||
|
is_race_condition = False
|
||||||
|
if rotated_at:
|
||||||
|
try:
|
||||||
|
rotated_dt = datetime.fromisoformat(rotated_at)
|
||||||
|
if rotated_dt.tzinfo is None:
|
||||||
|
rotated_dt = rotated_dt.replace(tzinfo=timezone.utc)
|
||||||
|
if (datetime.now(timezone.utc) - rotated_dt).total_seconds() <= grace_period:
|
||||||
|
is_race_condition = True
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if is_race_condition:
|
||||||
|
logger.info(
|
||||||
|
'Refresh token replay within grace period treated as race condition. user_id=%s, family=%s, ip=%s',
|
||||||
|
token_record.user_id, token_record.token_family, request.remote_addr,
|
||||||
|
)
|
||||||
|
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
|
||||||
|
access_token = _create_access_token(user)
|
||||||
|
resp = jsonify({
|
||||||
|
'email': user.email,
|
||||||
|
'id': user.id,
|
||||||
|
'first_name': user.first_name,
|
||||||
|
'last_name': user.last_name,
|
||||||
|
'verified': user.verified,
|
||||||
|
})
|
||||||
|
_set_auth_cookies(resp, access_token, raw_new_refresh)
|
||||||
|
return resp, 200
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
'Refresh token reuse detected! user_id=%s, family=%s, ip=%s — killing family sessions',
|
||||||
|
token_record.user_id, token_record.token_family, request.remote_addr,
|
||||||
|
)
|
||||||
|
# Invalidate only the affected family, not every session for the user.
|
||||||
|
refresh_tokens_db.remove(
|
||||||
|
(TokenQuery.user_id == token_record.user_id) & (TokenQuery.token_family == token_record.token_family)
|
||||||
|
)
|
||||||
|
resp = jsonify({'error': 'Token reuse detected, family sessions invalidated', 'code': REFRESH_TOKEN_REUSE})
|
||||||
_clear_auth_cookies(resp)
|
_clear_auth_cookies(resp)
|
||||||
return resp, 401
|
return resp, 401
|
||||||
|
|
||||||
if user.marked_for_deletion:
|
|
||||||
refresh_tokens_db.remove(TokenQuery.user_id == user.id)
|
|
||||||
resp = jsonify({'error': 'Account marked for deletion', 'code': ACCOUNT_MARKED_FOR_DELETION})
|
|
||||||
_clear_auth_cookies(resp)
|
|
||||||
return resp, 403
|
|
||||||
|
|
||||||
# ROTATION: mark old token as used, create new one in same family
|
# ROTATION: mark old token as used, create new one in same family
|
||||||
refresh_tokens_db.update({'is_used': True}, TokenQuery.id == token_record.id)
|
refresh_tokens_db.update(
|
||||||
|
{'is_used': True, 'rotated_at': datetime.now(timezone.utc).isoformat()},
|
||||||
|
TokenQuery.id == token_record.id,
|
||||||
|
)
|
||||||
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
|
raw_new_refresh, _ = _create_refresh_token(user.id, token_family=token_record.token_family)
|
||||||
|
|
||||||
# Issue new access token
|
# Issue new access token
|
||||||
@@ -496,6 +549,7 @@ def e2e_create_delete_user():
|
|||||||
verified=True,
|
verified=True,
|
||||||
role='user',
|
role='user',
|
||||||
pin=E2E_DELETE_PIN,
|
pin=E2E_DELETE_PIN,
|
||||||
|
tutorial_enabled=False,
|
||||||
)
|
)
|
||||||
users_db.insert(user.to_dict())
|
users_db.insert(user.to_dict())
|
||||||
return jsonify({'email': norm_email}), 201
|
return jsonify({'email': norm_email}), 201
|
||||||
@@ -521,6 +575,33 @@ def e2e_create_cc_user():
|
|||||||
verified=True,
|
verified=True,
|
||||||
role='user',
|
role='user',
|
||||||
pin=E2E_CC_PIN,
|
pin=E2E_CC_PIN,
|
||||||
|
tutorial_enabled=False,
|
||||||
|
)
|
||||||
|
users_db.insert(user.to_dict())
|
||||||
|
return jsonify({'email': norm_email}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@auth_api.route('/e2e-create-tutorial-user', methods=['POST'])
|
||||||
|
def e2e_create_tutorial_user():
|
||||||
|
"""Create an isolated e2e test user for tutorial tests. Only available outside production."""
|
||||||
|
if os.environ.get('DB_ENV', 'prod') == 'prod':
|
||||||
|
return jsonify({'error': 'Not available in production'}), 403
|
||||||
|
|
||||||
|
norm_email = normalize_email(E2E_TUTORIAL_EMAIL)
|
||||||
|
# Clean up any children from previous tutorial runs.
|
||||||
|
existing = users_db.get(UserQuery.email == norm_email)
|
||||||
|
if existing:
|
||||||
|
child_db.remove(Query().user_id == existing.get('id'))
|
||||||
|
users_db.remove(UserQuery.email == norm_email)
|
||||||
|
user = User(
|
||||||
|
first_name='E2E',
|
||||||
|
last_name='Tutorial',
|
||||||
|
email=norm_email,
|
||||||
|
password=generate_password_hash(E2E_TUTORIAL_PASSWORD),
|
||||||
|
verified=True,
|
||||||
|
role='user',
|
||||||
|
pin=E2E_TUTORIAL_PIN,
|
||||||
|
tutorial_enabled=False,
|
||||||
)
|
)
|
||||||
users_db.insert(user.to_dict())
|
users_db.insert(user.to_dict())
|
||||||
return jsonify({'email': norm_email}), 201
|
return jsonify({'email': norm_email}), 201
|
||||||
@@ -544,6 +625,12 @@ def e2e_seed():
|
|||||||
chore_schedules_db.truncate()
|
chore_schedules_db.truncate()
|
||||||
task_extensions_db.truncate()
|
task_extensions_db.truncate()
|
||||||
refresh_tokens_db.truncate()
|
refresh_tokens_db.truncate()
|
||||||
|
routine_db.truncate()
|
||||||
|
routine_items_db.truncate()
|
||||||
|
routine_schedules_db.truncate()
|
||||||
|
routine_extensions_db.truncate()
|
||||||
|
push_subscriptions_db.truncate()
|
||||||
|
digest_action_tokens_db.truncate()
|
||||||
|
|
||||||
# Recreate only baseline defaults for e2e runs.
|
# Recreate only baseline defaults for e2e runs.
|
||||||
initializeImages()
|
initializeImages()
|
||||||
@@ -559,6 +646,7 @@ def e2e_seed():
|
|||||||
verified=True,
|
verified=True,
|
||||||
role='user',
|
role='user',
|
||||||
pin=E2E_TEST_PIN,
|
pin=E2E_TEST_PIN,
|
||||||
|
tutorial_enabled=False,
|
||||||
)
|
)
|
||||||
users_db.insert(user.to_dict())
|
users_db.insert(user.to_dict())
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,357 @@
|
|||||||
|
"""Shared business logic for chore confirmation and reward actions.
|
||||||
|
|
||||||
|
Called from both child_api.py (JWT-authenticated endpoints) and
|
||||||
|
digest_action_api.py (token-authenticated endpoints). All functions take
|
||||||
|
user_id explicitly rather than reading it from the Flask request context.
|
||||||
|
"""
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from db.db import child_db, task_db, reward_db, pending_confirmations_db, routine_db
|
||||||
|
from db.child_overrides import get_override
|
||||||
|
from db.chore_schedules import get_schedule
|
||||||
|
from db.routine_schedules import get_schedule as get_routine_schedule
|
||||||
|
from db.tracking import insert_tracking_event
|
||||||
|
from events.sse import send_event_to_user
|
||||||
|
from events.types.child_chore_confirmation import ChildChoreConfirmation
|
||||||
|
from events.types.child_routine_confirmation import ChildRoutineConfirmation
|
||||||
|
from events.types.child_reward_request import ChildRewardRequest
|
||||||
|
from events.types.child_reward_triggered import ChildRewardTriggered
|
||||||
|
from events.types.child_task_triggered import ChildTaskTriggered
|
||||||
|
from events.types.tracking_event_created import TrackingEventCreated
|
||||||
|
from events.types.event import Event
|
||||||
|
from events.types.event_types import EventType
|
||||||
|
from models.child import Child
|
||||||
|
from models.reward import Reward
|
||||||
|
from models.routine import Routine
|
||||||
|
from models.task import Task
|
||||||
|
from models.tracking_event import TrackingEvent
|
||||||
|
from utils.tracking_logger import log_tracking_event
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def approve_chore(user_id: str, child_id: str, task_id: str) -> dict | None:
|
||||||
|
"""Award points for a completed chore and mark the pending confirmation approved.
|
||||||
|
|
||||||
|
Returns a result dict on success, or None if the confirmation was already resolved.
|
||||||
|
Raises ValueError if the child or task cannot be found.
|
||||||
|
"""
|
||||||
|
ChildQ = Query()
|
||||||
|
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
||||||
|
if not child_result:
|
||||||
|
raise ValueError(f'Child {child_id} not found for user {user_id}')
|
||||||
|
child = Child.from_dict(child_result)
|
||||||
|
|
||||||
|
if task_id not in child.tasks:
|
||||||
|
logger.info(f'Task {task_id} no longer assigned to child {child_id}; skipping approve')
|
||||||
|
return None
|
||||||
|
|
||||||
|
PendingQ = Query()
|
||||||
|
existing = pending_confirmations_db.get(
|
||||||
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||||
|
(PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') &
|
||||||
|
(PendingQ.user_id == user_id)
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved')
|
||||||
|
return None
|
||||||
|
|
||||||
|
TaskQ = Query()
|
||||||
|
task_result = task_db.get(
|
||||||
|
(TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
|
||||||
|
)
|
||||||
|
if not task_result:
|
||||||
|
raise ValueError(f'Task {task_id} not found')
|
||||||
|
task = Task.from_dict(task_result)
|
||||||
|
|
||||||
|
override = get_override(child_id, task_id)
|
||||||
|
points_value = override.custom_value if override else task.points
|
||||||
|
points_before = child.points
|
||||||
|
child.points += points_value
|
||||||
|
child_db.update({'points': child.points}, ChildQ.id == child_id)
|
||||||
|
|
||||||
|
schedule = get_schedule(child_id, task_id)
|
||||||
|
if schedule:
|
||||||
|
now_str = datetime.now(timezone.utc).isoformat()
|
||||||
|
pending_confirmations_db.update(
|
||||||
|
{'status': 'approved', 'approved_at': now_str},
|
||||||
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||||
|
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||||
|
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
tracking_metadata = {
|
||||||
|
'task_name': task.name,
|
||||||
|
'task_type': task.type,
|
||||||
|
'default_points': task.points,
|
||||||
|
}
|
||||||
|
if override:
|
||||||
|
tracking_metadata['custom_points'] = override.custom_value
|
||||||
|
tracking_metadata['has_override'] = True
|
||||||
|
|
||||||
|
tracking_event = TrackingEvent.create_event(
|
||||||
|
user_id=user_id, child_id=child_id, entity_type='chore', entity_id=task_id,
|
||||||
|
action='approved', points_before=points_before, points_after=child.points,
|
||||||
|
metadata=tracking_metadata,
|
||||||
|
)
|
||||||
|
insert_tracking_event(tracking_event)
|
||||||
|
log_tracking_event(tracking_event)
|
||||||
|
|
||||||
|
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||||
|
TrackingEventCreated(tracking_event.id, child_id, 'chore', 'approved')))
|
||||||
|
send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
||||||
|
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_APPROVED)))
|
||||||
|
send_event_to_user(user_id, Event(EventType.CHILD_TASK_TRIGGERED.value,
|
||||||
|
ChildTaskTriggered(task_id, child_id, child.points)))
|
||||||
|
|
||||||
|
return {'task_name': task.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
|
||||||
|
|
||||||
|
|
||||||
|
def reject_chore(user_id: str, child_id: str, task_id: str) -> None:
|
||||||
|
"""Reject a pending chore confirmation. No-op if already resolved."""
|
||||||
|
PendingQ = Query()
|
||||||
|
existing = pending_confirmations_db.get(
|
||||||
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||||
|
(PendingQ.entity_type == 'chore') & (PendingQ.status == 'pending') &
|
||||||
|
(PendingQ.user_id == user_id)
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
logger.info(f'No pending chore for child {child_id}, task {task_id} — already resolved')
|
||||||
|
return
|
||||||
|
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == task_id) &
|
||||||
|
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
ChildQ = Query()
|
||||||
|
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
||||||
|
if child_result:
|
||||||
|
child = Child.from_dict(child_result)
|
||||||
|
TaskQ = Query()
|
||||||
|
task_result = task_db.get(
|
||||||
|
(TaskQ.id == task_id) & ((TaskQ.user_id == user_id) | (TaskQ.user_id == None))
|
||||||
|
)
|
||||||
|
task_name = task_result.get('name') if task_result else 'Unknown'
|
||||||
|
|
||||||
|
tracking_event = TrackingEvent.create_event(
|
||||||
|
user_id=user_id, child_id=child_id, entity_type='chore', entity_id=task_id,
|
||||||
|
action='rejected', points_before=child.points, points_after=child.points,
|
||||||
|
metadata={'task_name': task_name},
|
||||||
|
)
|
||||||
|
insert_tracking_event(tracking_event)
|
||||||
|
log_tracking_event(tracking_event)
|
||||||
|
|
||||||
|
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||||
|
TrackingEventCreated(tracking_event.id, child_id, 'chore', 'rejected')))
|
||||||
|
|
||||||
|
send_event_to_user(user_id, Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
||||||
|
ChildChoreConfirmation(child_id, task_id, ChildChoreConfirmation.OPERATION_REJECTED)))
|
||||||
|
|
||||||
|
|
||||||
|
def approve_reward_request(user_id: str, child_id: str, reward_id: str) -> dict:
|
||||||
|
"""Approve a child's pending reward request: deduct points and fire SSE events.
|
||||||
|
|
||||||
|
Returns a result dict on success.
|
||||||
|
Raises ValueError if child/reward not found or the child has insufficient points.
|
||||||
|
"""
|
||||||
|
ChildQ = Query()
|
||||||
|
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
||||||
|
if not child_result:
|
||||||
|
raise ValueError(f'Child {child_id} not found for user {user_id}')
|
||||||
|
child = Child.from_dict(child_result)
|
||||||
|
|
||||||
|
if reward_id not in child.rewards:
|
||||||
|
logger.info(f'Reward {reward_id} no longer assigned to child {child_id}; skipping approve')
|
||||||
|
return None
|
||||||
|
|
||||||
|
RewardQ = Query()
|
||||||
|
reward_result = reward_db.get(
|
||||||
|
(RewardQ.id == reward_id) & ((RewardQ.user_id == user_id) | (RewardQ.user_id == None))
|
||||||
|
)
|
||||||
|
if not reward_result:
|
||||||
|
raise ValueError(f'Reward {reward_id} not found')
|
||||||
|
reward = Reward.from_dict(reward_result)
|
||||||
|
|
||||||
|
override = get_override(child_id, reward_id)
|
||||||
|
cost_value = override.custom_value if override else reward.cost
|
||||||
|
|
||||||
|
if child.points < cost_value:
|
||||||
|
raise ValueError(f'Child {child_id} has insufficient points for reward {reward_id}')
|
||||||
|
|
||||||
|
PendingQ = Query()
|
||||||
|
existing = pending_confirmations_db.get(
|
||||||
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
|
||||||
|
(PendingQ.entity_type == 'reward') & (PendingQ.status == 'pending') &
|
||||||
|
(PendingQ.user_id == user_id)
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
logger.info(f'No pending reward for child {child_id}, reward {reward_id} — already resolved')
|
||||||
|
return None
|
||||||
|
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
|
||||||
|
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
|
||||||
|
)
|
||||||
|
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value,
|
||||||
|
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_GRANTED)))
|
||||||
|
|
||||||
|
points_before = child.points
|
||||||
|
child.points -= cost_value
|
||||||
|
child_db.update({'points': child.points}, ChildQ.id == child_id)
|
||||||
|
|
||||||
|
tracking_metadata = {
|
||||||
|
'reward_name': reward.name,
|
||||||
|
'reward_cost': reward.cost,
|
||||||
|
'default_cost': reward.cost,
|
||||||
|
}
|
||||||
|
if override:
|
||||||
|
tracking_metadata['custom_cost'] = override.custom_value
|
||||||
|
tracking_metadata['has_override'] = True
|
||||||
|
|
||||||
|
tracking_event = TrackingEvent.create_event(
|
||||||
|
user_id=user_id, child_id=child_id, entity_type='reward', entity_id=reward_id,
|
||||||
|
action='redeemed', points_before=points_before, points_after=child.points,
|
||||||
|
metadata=tracking_metadata,
|
||||||
|
)
|
||||||
|
insert_tracking_event(tracking_event)
|
||||||
|
log_tracking_event(tracking_event)
|
||||||
|
|
||||||
|
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||||
|
TrackingEventCreated(tracking_event.id, child_id, 'reward', 'redeemed')))
|
||||||
|
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_TRIGGERED.value,
|
||||||
|
ChildRewardTriggered(reward_id, child_id, child.points)))
|
||||||
|
|
||||||
|
return {'reward_name': reward.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
|
||||||
|
|
||||||
|
|
||||||
|
def deny_reward(user_id: str, child_id: str, reward_id: str) -> dict | None:
|
||||||
|
"""Deny a child's pending reward request. No-op if already resolved.
|
||||||
|
|
||||||
|
Returns a result dict on success, or None if already resolved.
|
||||||
|
"""
|
||||||
|
PendingQ = Query()
|
||||||
|
existing = pending_confirmations_db.get(
|
||||||
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
|
||||||
|
(PendingQ.entity_type == 'reward') & (PendingQ.status == 'pending') &
|
||||||
|
(PendingQ.user_id == user_id)
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
logger.info(f'Reward request for child {child_id}, reward {reward_id} already resolved')
|
||||||
|
return None
|
||||||
|
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == reward_id) &
|
||||||
|
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
ChildQ = Query()
|
||||||
|
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
||||||
|
child_name = 'Unknown'
|
||||||
|
if child_result:
|
||||||
|
child = Child.from_dict(child_result)
|
||||||
|
child_name = child.name
|
||||||
|
|
||||||
|
tracking_event = TrackingEvent.create_event(
|
||||||
|
user_id=user_id, child_id=child_id, entity_type='reward', entity_id=reward_id,
|
||||||
|
action='denied', points_before=child.points, points_after=child.points,
|
||||||
|
metadata={},
|
||||||
|
)
|
||||||
|
insert_tracking_event(tracking_event)
|
||||||
|
log_tracking_event(tracking_event)
|
||||||
|
|
||||||
|
send_event_to_user(user_id, Event(EventType.TRACKING_EVENT_CREATED.value,
|
||||||
|
TrackingEventCreated(tracking_event.id, child_id, 'reward', 'denied')))
|
||||||
|
|
||||||
|
send_event_to_user(user_id, Event(EventType.CHILD_REWARD_REQUEST.value,
|
||||||
|
ChildRewardRequest(child_id, reward_id, ChildRewardRequest.REQUEST_CANCELLED)))
|
||||||
|
|
||||||
|
return {'child_name': child_name}
|
||||||
|
|
||||||
|
|
||||||
|
def approve_routine(user_id: str, child_id: str, routine_id: str) -> dict | None:
|
||||||
|
"""Award points for a completed routine and mark the pending confirmation approved.
|
||||||
|
|
||||||
|
Returns a result dict on success, or None if already resolved.
|
||||||
|
Raises ValueError if the child or routine cannot be found.
|
||||||
|
"""
|
||||||
|
ChildQ = Query()
|
||||||
|
child_result = child_db.get((ChildQ.id == child_id) & (ChildQ.user_id == user_id))
|
||||||
|
if not child_result:
|
||||||
|
raise ValueError(f'Child {child_id} not found for user {user_id}')
|
||||||
|
child = Child.from_dict(child_result)
|
||||||
|
|
||||||
|
if routine_id not in child.routines:
|
||||||
|
logger.info(f'Routine {routine_id} no longer assigned to child {child_id}; skipping approve')
|
||||||
|
return None
|
||||||
|
|
||||||
|
PendingQ = Query()
|
||||||
|
existing = pending_confirmations_db.get(
|
||||||
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
|
||||||
|
(PendingQ.entity_type == 'routine') & (PendingQ.status == 'pending') &
|
||||||
|
(PendingQ.user_id == user_id)
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
logger.info(f'No pending routine for child {child_id}, routine {routine_id} — already resolved')
|
||||||
|
return None
|
||||||
|
|
||||||
|
RoutineQ = Query()
|
||||||
|
routine_result = routine_db.get(
|
||||||
|
(RoutineQ.id == routine_id) & ((RoutineQ.user_id == user_id) | (RoutineQ.user_id == None))
|
||||||
|
)
|
||||||
|
if not routine_result:
|
||||||
|
raise ValueError(f'Routine {routine_id} not found')
|
||||||
|
routine = Routine.from_dict(routine_result)
|
||||||
|
|
||||||
|
override = get_override(child_id, routine_id)
|
||||||
|
points_value = override.custom_value if override and override.entity_type == 'routine' else routine.points
|
||||||
|
points_before = child.points
|
||||||
|
child.points += points_value
|
||||||
|
child_db.update({'points': child.points}, ChildQ.id == child_id)
|
||||||
|
|
||||||
|
schedule = get_routine_schedule(child_id, routine_id)
|
||||||
|
now_str = datetime.now(timezone.utc).isoformat()
|
||||||
|
if schedule:
|
||||||
|
pending_confirmations_db.update(
|
||||||
|
{'status': 'approved', 'approved_at': now_str},
|
||||||
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
|
||||||
|
(PendingQ.entity_type == 'routine') & (PendingQ.user_id == user_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
|
||||||
|
(PendingQ.entity_type == 'routine') & (PendingQ.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
send_event_to_user(user_id, Event(EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||||
|
ChildRoutineConfirmation(child_id, routine_id, ChildRoutineConfirmation.OPERATION_APPROVED)))
|
||||||
|
|
||||||
|
return {'routine_name': routine.name, 'child_name': child.name, 'child_id': child_id, 'points': child.points}
|
||||||
|
|
||||||
|
|
||||||
|
def reject_routine(user_id: str, child_id: str, routine_id: str) -> None:
|
||||||
|
"""Reject a pending routine confirmation. No-op if already resolved."""
|
||||||
|
PendingQ = Query()
|
||||||
|
existing = pending_confirmations_db.get(
|
||||||
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
|
||||||
|
(PendingQ.entity_type == 'routine') & (PendingQ.status == 'pending') &
|
||||||
|
(PendingQ.user_id == user_id)
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
logger.info(f'No pending routine for child {child_id}, routine {routine_id} — already resolved')
|
||||||
|
return
|
||||||
|
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(PendingQ.child_id == child_id) & (PendingQ.entity_id == routine_id) &
|
||||||
|
(PendingQ.entity_type == 'routine') & (PendingQ.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
send_event_to_user(user_id, Event(EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||||
|
ChildRoutineConfirmation(child_id, routine_id, ChildRoutineConfirmation.OPERATION_REJECTED)))
|
||||||
+227
-102
@@ -1,5 +1,6 @@
|
|||||||
from time import sleep
|
from time import sleep
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
from flask import Blueprint, request, jsonify
|
from flask import Blueprint, request, jsonify
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
@@ -9,7 +10,8 @@ from api.child_tasks import ChildTask
|
|||||||
from api.pending_confirmation import PendingConfirmationResponse
|
from api.pending_confirmation import PendingConfirmationResponse
|
||||||
from api.reward_status import RewardStatus
|
from api.reward_status import RewardStatus
|
||||||
from api.utils import send_event_for_current_user, get_validated_user_id
|
from api.utils import send_event_for_current_user, get_validated_user_id
|
||||||
from db.db import child_db, task_db, reward_db, pending_reward_db, pending_confirmations_db
|
import api.child_action_helpers as chore_actions
|
||||||
|
from db.db import child_db, task_db, reward_db, routine_db, pending_reward_db, pending_confirmations_db, users_db
|
||||||
from db.tracking import insert_tracking_event
|
from db.tracking import insert_tracking_event
|
||||||
from db.child_overrides import get_override, delete_override, delete_overrides_for_child
|
from db.child_overrides import get_override, delete_override, delete_overrides_for_child
|
||||||
from events.types.child_chore_confirmation import ChildChoreConfirmation
|
from events.types.child_chore_confirmation import ChildChoreConfirmation
|
||||||
@@ -29,14 +31,65 @@ from models.reward import Reward
|
|||||||
from models.task import Task
|
from models.task import Task
|
||||||
from models.tracking_event import TrackingEvent
|
from models.tracking_event import TrackingEvent
|
||||||
from utils.tracking_logger import log_tracking_event
|
from utils.tracking_logger import log_tracking_event
|
||||||
|
from utils.push_sender import send_push_to_user
|
||||||
|
from utils.digest_token import create_action_token
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from db.chore_schedules import get_schedule
|
from db.chore_schedules import get_schedule
|
||||||
from db.task_extensions import get_extension_for_child_task
|
from db.task_extensions import get_extension_for_child_task
|
||||||
|
from db.routine_schedules import delete_schedules_for_child as delete_routine_schedules_for_child
|
||||||
|
from db.routine_extensions import delete_extensions_for_child as delete_routine_extensions_for_child
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
child_api = Blueprint('child_api', __name__)
|
child_api = Blueprint('child_api', __name__)
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_timezone(user_id: str) -> str | None:
|
||||||
|
user = users_db.get(Query().id == user_id)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
return user.get('timezone')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_today_local(user_id: str) -> tuple[str, str | None]:
|
||||||
|
tz_str = _get_user_timezone(user_id)
|
||||||
|
try:
|
||||||
|
now_local = datetime.now(ZoneInfo(tz_str)) if tz_str else datetime.now(timezone.utc)
|
||||||
|
except Exception:
|
||||||
|
tz_str = None
|
||||||
|
now_local = datetime.now(timezone.utc)
|
||||||
|
return now_local.strftime('%Y-%m-%d'), tz_str
|
||||||
|
|
||||||
|
|
||||||
|
def _is_iso_timestamp_on_local_day(timestamp: str | None, local_day: str, tz_str: str | None) -> bool:
|
||||||
|
if not timestamp:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
normalized = timestamp.replace('Z', '+00:00')
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(tz_str) if tz_str else timezone.utc
|
||||||
|
except Exception:
|
||||||
|
tz = timezone.utc
|
||||||
|
return parsed.astimezone(tz).strftime('%Y-%m-%d') == local_day
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_epoch_timestamp_on_local_day(epoch_ts, local_day: str, tz_str: str | None) -> bool:
|
||||||
|
if epoch_ts is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(tz_str) if tz_str else timezone.utc
|
||||||
|
except Exception:
|
||||||
|
tz = timezone.utc
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(float(epoch_ts), tz).strftime('%Y-%m-%d') == local_day
|
||||||
|
except (TypeError, ValueError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
@child_api.route('/child/<name>', methods=['GET'])
|
@child_api.route('/child/<name>', methods=['GET'])
|
||||||
@child_api.route('/child/<id>', methods=['GET'])
|
@child_api.route('/child/<id>', methods=['GET'])
|
||||||
def get_child(id):
|
def get_child(id):
|
||||||
@@ -147,6 +200,14 @@ def delete_child(id):
|
|||||||
deleted_count = delete_overrides_for_child(id)
|
deleted_count = delete_overrides_for_child(id)
|
||||||
if deleted_count > 0:
|
if deleted_count > 0:
|
||||||
logger.info(f"Cascade deleted {deleted_count} overrides for child {id}")
|
logger.info(f"Cascade deleted {deleted_count} overrides for child {id}")
|
||||||
|
|
||||||
|
# Cascade delete routine schedule/extension rows for this child.
|
||||||
|
delete_routine_schedules_for_child(id)
|
||||||
|
delete_routine_extensions_for_child(id)
|
||||||
|
|
||||||
|
# Remove pending routine confirmations for this child.
|
||||||
|
PendingQuery = Query()
|
||||||
|
pending_confirmations_db.remove((PendingQuery.child_id == id) & (PendingQuery.entity_type == 'routine'))
|
||||||
|
|
||||||
if child_db.remove((ChildQuery.id == id) & (ChildQuery.user_id == user_id)):
|
if child_db.remove((ChildQuery.id == id) & (ChildQuery.user_id == user_id)):
|
||||||
resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
|
resp = send_event_for_current_user(Event(EventType.CHILD_MODIFIED.value, ChildModified(id, ChildModified.OPERATION_DELETE)))
|
||||||
@@ -208,7 +269,7 @@ def set_child_tasks(id):
|
|||||||
# Convert back to list if needed
|
# Convert back to list if needed
|
||||||
new_tasks = list(new_task_ids)
|
new_tasks = list(new_task_ids)
|
||||||
|
|
||||||
# Identify unassigned tasks and delete their overrides
|
# Identify unassigned tasks and delete their overrides and pending confirmations
|
||||||
old_task_ids = set(child.tasks)
|
old_task_ids = set(child.tasks)
|
||||||
unassigned_task_ids = old_task_ids - new_task_ids
|
unassigned_task_ids = old_task_ids - new_task_ids
|
||||||
for task_id in unassigned_task_ids:
|
for task_id in unassigned_task_ids:
|
||||||
@@ -217,6 +278,12 @@ def set_child_tasks(id):
|
|||||||
if override and override.entity_type == 'task':
|
if override and override.entity_type == 'task':
|
||||||
delete_override(id, task_id)
|
delete_override(id, task_id)
|
||||||
logger.info(f"Deleted override for unassigned task: child={id}, task={task_id}")
|
logger.info(f"Deleted override for unassigned task: child={id}, task={task_id}")
|
||||||
|
# Clear any pending chore confirmation
|
||||||
|
PendingQ = Query()
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(PendingQ.child_id == id) & (PendingQ.entity_id == task_id) &
|
||||||
|
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
# Replace tasks with validated IDs
|
# Replace tasks with validated IDs
|
||||||
child_db.update({'tasks': new_tasks}, ChildQuery.id == id)
|
child_db.update({'tasks': new_tasks}, ChildQuery.id == id)
|
||||||
@@ -250,6 +317,12 @@ def remove_task_from_child(id):
|
|||||||
if task_id in child.get('tasks', []):
|
if task_id in child.get('tasks', []):
|
||||||
child['tasks'].remove(task_id)
|
child['tasks'].remove(task_id)
|
||||||
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
|
child_db.update({'tasks': child['tasks']}, ChildQuery.id == id)
|
||||||
|
# Clear any pending chore confirmation for this task
|
||||||
|
PendingQ = Query()
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(PendingQ.child_id == id) & (PendingQ.entity_id == task_id) &
|
||||||
|
(PendingQ.entity_type == 'chore') & (PendingQ.user_id == user_id)
|
||||||
|
)
|
||||||
return jsonify({'message': f'Task {task_id} removed from {child["name"]}.'}), 200
|
return jsonify({'message': f'Task {task_id} removed from {child["name"]}.'}), 200
|
||||||
return jsonify({'error': 'Task not assigned to child'}), 400
|
return jsonify({'error': 'Task not assigned to child'}), 400
|
||||||
|
|
||||||
@@ -267,6 +340,7 @@ def list_child_tasks(id):
|
|||||||
task_ids = child.get('tasks', [])
|
task_ids = child.get('tasks', [])
|
||||||
|
|
||||||
TaskQuery = Query()
|
TaskQuery = Query()
|
||||||
|
today_local, tz_str = _get_user_today_local(user_id)
|
||||||
child_tasks = []
|
child_tasks = []
|
||||||
for tid in task_ids:
|
for tid in task_ids:
|
||||||
task = task_db.get((TaskQuery.id == tid) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
|
task = task_db.get((TaskQuery.id == tid) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
|
||||||
@@ -298,8 +372,21 @@ def list_child_tasks(id):
|
|||||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
|
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
|
||||||
)
|
)
|
||||||
if pending:
|
if pending:
|
||||||
ct_dict['pending_status'] = pending.get('status')
|
status = pending.get('status')
|
||||||
ct_dict['approved_at'] = pending.get('approved_at')
|
approved_at = pending.get('approved_at')
|
||||||
|
created_at = pending.get('created_at')
|
||||||
|
if status == 'approved' and _is_iso_timestamp_on_local_day(approved_at, today_local, tz_str):
|
||||||
|
ct_dict['pending_status'] = 'approved'
|
||||||
|
ct_dict['approved_at'] = approved_at
|
||||||
|
elif status == 'pending' and _is_epoch_timestamp_on_local_day(created_at, today_local, tz_str):
|
||||||
|
ct_dict['pending_status'] = 'pending'
|
||||||
|
ct_dict['approved_at'] = None
|
||||||
|
else:
|
||||||
|
pending_id = pending.get('id')
|
||||||
|
if pending_id:
|
||||||
|
pending_confirmations_db.remove(PendingQuery.id == pending_id)
|
||||||
|
ct_dict['pending_status'] = None
|
||||||
|
ct_dict['approved_at'] = None
|
||||||
else:
|
else:
|
||||||
ct_dict['pending_status'] = None
|
ct_dict['pending_status'] = None
|
||||||
ct_dict['approved_at'] = None
|
ct_dict['approved_at'] = None
|
||||||
@@ -330,7 +417,6 @@ def list_assignable_tasks(id):
|
|||||||
all_tasks = [t for t in task_db.all() if t and t.get('id') and t.get('id') not in assigned_ids]
|
all_tasks = [t for t in task_db.all() if t and t.get('id') and t.get('id') not in assigned_ids]
|
||||||
|
|
||||||
# Group by name
|
# Group by name
|
||||||
from collections import defaultdict
|
|
||||||
name_to_tasks = defaultdict(list)
|
name_to_tasks = defaultdict(list)
|
||||||
for t in all_tasks:
|
for t in all_tasks:
|
||||||
name_to_tasks[t.get('name')].append(t)
|
name_to_tasks[t.get('name')].append(t)
|
||||||
@@ -539,7 +625,6 @@ def list_all_rewards(id):
|
|||||||
ChildRewardQuery = Query()
|
ChildRewardQuery = Query()
|
||||||
all_rewards = reward_db.search((ChildRewardQuery.user_id == user_id) | (ChildRewardQuery.user_id == None))
|
all_rewards = reward_db.search((ChildRewardQuery.user_id == user_id) | (ChildRewardQuery.user_id == None))
|
||||||
|
|
||||||
from collections import defaultdict
|
|
||||||
name_to_rewards = defaultdict(list)
|
name_to_rewards = defaultdict(list)
|
||||||
for r in all_rewards:
|
for r in all_rewards:
|
||||||
name_to_rewards[r.get('name')].append(r)
|
name_to_rewards[r.get('name')].append(r)
|
||||||
@@ -601,7 +686,7 @@ def set_child_rewards(id):
|
|||||||
if reward_db.get((RewardQuery.id == rid) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))):
|
if reward_db.get((RewardQuery.id == rid) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None))):
|
||||||
valid_reward_ids.append(rid)
|
valid_reward_ids.append(rid)
|
||||||
|
|
||||||
# Identify unassigned rewards and delete their overrides
|
# Identify unassigned rewards and delete their overrides and pending confirmations
|
||||||
new_reward_ids_set = set(valid_reward_ids)
|
new_reward_ids_set = set(valid_reward_ids)
|
||||||
unassigned_reward_ids = old_reward_ids - new_reward_ids_set
|
unassigned_reward_ids = old_reward_ids - new_reward_ids_set
|
||||||
for reward_id in unassigned_reward_ids:
|
for reward_id in unassigned_reward_ids:
|
||||||
@@ -609,6 +694,12 @@ def set_child_rewards(id):
|
|||||||
if override and override.entity_type == 'reward':
|
if override and override.entity_type == 'reward':
|
||||||
delete_override(id, reward_id)
|
delete_override(id, reward_id)
|
||||||
logger.info(f"Deleted override for unassigned reward: child={id}, reward={reward_id}")
|
logger.info(f"Deleted override for unassigned reward: child={id}, reward={reward_id}")
|
||||||
|
# Clear any pending reward confirmation
|
||||||
|
PendingQ = Query()
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(PendingQ.child_id == id) & (PendingQ.entity_id == reward_id) &
|
||||||
|
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
# Replace rewards with validated IDs
|
# Replace rewards with validated IDs
|
||||||
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
|
child_db.update({'rewards': valid_reward_ids}, ChildQuery.id == id)
|
||||||
@@ -639,6 +730,12 @@ def remove_reward_from_child(id):
|
|||||||
if reward_id in child.get('rewards', []):
|
if reward_id in child.get('rewards', []):
|
||||||
child['rewards'].remove(reward_id)
|
child['rewards'].remove(reward_id)
|
||||||
child_db.update({'rewards': child['rewards']}, ChildQuery.id == id)
|
child_db.update({'rewards': child['rewards']}, ChildQuery.id == id)
|
||||||
|
# Clear any pending reward confirmation for this reward
|
||||||
|
PendingQ = Query()
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(PendingQ.child_id == id) & (PendingQ.entity_id == reward_id) &
|
||||||
|
(PendingQ.entity_type == 'reward') & (PendingQ.user_id == user_id)
|
||||||
|
)
|
||||||
return jsonify({'message': f'Reward {reward_id} removed from {child["name"]}.'}), 200
|
return jsonify({'message': f'Reward {reward_id} removed from {child["name"]}.'}), 200
|
||||||
return jsonify({'error': 'Reward not assigned to child'}), 400
|
return jsonify({'error': 'Reward not assigned to child'}), 400
|
||||||
|
|
||||||
@@ -691,7 +788,6 @@ def list_assignable_rewards(id):
|
|||||||
all_rewards = [r for r in reward_db.all() if r and r.get('id') and r.get('id') not in assigned_ids]
|
all_rewards = [r for r in reward_db.all() if r and r.get('id') and r.get('id') not in assigned_ids]
|
||||||
|
|
||||||
# Group by name
|
# Group by name
|
||||||
from collections import defaultdict
|
|
||||||
name_to_rewards = defaultdict(list)
|
name_to_rewards = defaultdict(list)
|
||||||
for r in all_rewards:
|
for r in all_rewards:
|
||||||
name_to_rewards[r.get('name')].append(r)
|
name_to_rewards[r.get('name')].append(r)
|
||||||
@@ -830,6 +926,7 @@ def reward_status(id):
|
|||||||
reward_ids = child.rewards
|
reward_ids = child.rewards
|
||||||
|
|
||||||
RewardQuery = Query()
|
RewardQuery = Query()
|
||||||
|
today_local, tz_str = _get_user_today_local(user_id)
|
||||||
statuses = []
|
statuses = []
|
||||||
for reward_id in reward_ids:
|
for reward_id in reward_ids:
|
||||||
reward_dict = reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
reward_dict = reward_db.get((RewardQuery.id == reward_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
||||||
@@ -848,7 +945,16 @@ def reward_status(id):
|
|||||||
(pending_query.child_id == child.id) & (pending_query.entity_id == reward.id) &
|
(pending_query.child_id == child.id) & (pending_query.entity_id == reward.id) &
|
||||||
(pending_query.entity_type == 'reward') & (pending_query.user_id == user_id)
|
(pending_query.entity_type == 'reward') & (pending_query.user_id == user_id)
|
||||||
)
|
)
|
||||||
status = RewardStatus(reward.id, reward.name, points_needed, cost_value, pending is not None, reward.image_id)
|
redeeming = False
|
||||||
|
if pending and pending.get('status') == 'pending':
|
||||||
|
if _is_epoch_timestamp_on_local_day(pending.get('created_at'), today_local, tz_str):
|
||||||
|
redeeming = True
|
||||||
|
else:
|
||||||
|
pending_id = pending.get('id')
|
||||||
|
if pending_id:
|
||||||
|
pending_confirmations_db.remove(pending_query.id == pending_id)
|
||||||
|
|
||||||
|
status = RewardStatus(reward.id, reward.name, points_needed, cost_value, redeeming, reward.image_id)
|
||||||
status_dict = status.to_dict()
|
status_dict = status.to_dict()
|
||||||
if override:
|
if override:
|
||||||
status_dict['custom_value'] = override.custom_value
|
status_dict['custom_value'] = override.custom_value
|
||||||
@@ -895,6 +1001,21 @@ def request_reward(id):
|
|||||||
'reward_cost': reward.cost
|
'reward_cost': reward.cost
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
|
# Check for duplicate pending request
|
||||||
|
DupQuery = Query()
|
||||||
|
duplicate = pending_confirmations_db.get(
|
||||||
|
(DupQuery.child_id == child.id) & (DupQuery.entity_id == reward.id) &
|
||||||
|
(DupQuery.entity_type == 'reward') & (DupQuery.status == 'pending') &
|
||||||
|
(DupQuery.user_id == user_id)
|
||||||
|
)
|
||||||
|
if duplicate:
|
||||||
|
today_local, tz_str = _get_user_today_local(user_id)
|
||||||
|
if _is_epoch_timestamp_on_local_day(duplicate.get('created_at'), today_local, tz_str):
|
||||||
|
return jsonify({'error': 'Reward request already pending', 'code': 'DUPLICATE_REWARD_REQUEST'}), 409
|
||||||
|
pending_id = duplicate.get('id')
|
||||||
|
if pending_id:
|
||||||
|
pending_confirmations_db.remove(DupQuery.id == pending_id)
|
||||||
|
|
||||||
pending = PendingConfirmation(child_id=child.id, entity_id=reward.id, entity_type='reward', user_id=user_id)
|
pending = PendingConfirmation(child_id=child.id, entity_id=reward.id, entity_type='reward', user_id=user_id)
|
||||||
pending_confirmations_db.insert(pending.to_dict())
|
pending_confirmations_db.insert(pending.to_dict())
|
||||||
logger.info(f'Pending reward request created for child {child.name} for reward {reward.name}')
|
logger.info(f'Pending reward request created for child {child.name} for reward {reward.name}')
|
||||||
@@ -917,6 +1038,30 @@ def request_reward(id):
|
|||||||
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'requested')))
|
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value, TrackingEventCreated(tracking_event.id, child.id, 'reward', 'requested')))
|
||||||
|
|
||||||
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
|
send_event_for_current_user(Event(EventType.CHILD_REWARD_REQUEST.value, ChildRewardRequest(child.id, reward.id, ChildRewardRequest.REQUEST_CREATED)))
|
||||||
|
|
||||||
|
# Fire web push notification to all parent subscriptions
|
||||||
|
_push_user = users_db.get(Query().id == user_id)
|
||||||
|
if _push_user and _push_user.get('push_notifications_enabled', True):
|
||||||
|
try:
|
||||||
|
approve_token = create_action_token(user_id, child.id, reward.id, 'reward', 'approve')
|
||||||
|
deny_token = create_action_token(user_id, child.id, reward.id, 'reward', 'deny')
|
||||||
|
push_payload = {
|
||||||
|
'type': 'reward_requested',
|
||||||
|
'title': f'{child.name} wants a reward',
|
||||||
|
'body': f'{reward.name} costs {reward.cost} points.',
|
||||||
|
'user_id': user_id,
|
||||||
|
'child_id': child.id,
|
||||||
|
'child_name': child.name,
|
||||||
|
'entity_id': reward.id,
|
||||||
|
'entity_type': 'reward',
|
||||||
|
'entity_name': reward.name,
|
||||||
|
'approve_token': approve_token.id,
|
||||||
|
'deny_token': deny_token.id,
|
||||||
|
}
|
||||||
|
send_push_to_user(user_id, push_payload)
|
||||||
|
except Exception as _push_err:
|
||||||
|
logger.warning(f'Push notification failed for reward request: {_push_err}')
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'message': f'Reward request for {reward.name} submitted for {child.name}.',
|
'message': f'Reward request for {reward.name} submitted for {child.name}.',
|
||||||
'reward_id': reward.id,
|
'reward_id': reward.id,
|
||||||
@@ -995,6 +1140,7 @@ def list_pending_confirmations():
|
|||||||
if not user_id:
|
if not user_id:
|
||||||
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
PendingQuery = Query()
|
PendingQuery = Query()
|
||||||
|
today_local, tz_str = _get_user_today_local(user_id)
|
||||||
pending_items = pending_confirmations_db.search(
|
pending_items = pending_confirmations_db.search(
|
||||||
(PendingQuery.user_id == user_id) & (PendingQuery.status == 'pending')
|
(PendingQuery.user_id == user_id) & (PendingQuery.status == 'pending')
|
||||||
)
|
)
|
||||||
@@ -1002,11 +1148,16 @@ def list_pending_confirmations():
|
|||||||
|
|
||||||
RewardQuery = Query()
|
RewardQuery = Query()
|
||||||
TaskQuery = Query()
|
TaskQuery = Query()
|
||||||
|
RoutineQuery = Query()
|
||||||
ChildQuery = Query()
|
ChildQuery = Query()
|
||||||
|
|
||||||
for pr in pending_items:
|
for pr in pending_items:
|
||||||
pending = PendingConfirmation.from_dict(pr)
|
pending = PendingConfirmation.from_dict(pr)
|
||||||
|
|
||||||
|
if not _is_epoch_timestamp_on_local_day(pending.created_at, today_local, tz_str):
|
||||||
|
pending_confirmations_db.remove(PendingQuery.id == pending.id)
|
||||||
|
continue
|
||||||
|
|
||||||
# Look up child details
|
# Look up child details
|
||||||
child_result = child_db.get(ChildQuery.id == pending.child_id)
|
child_result = child_db.get(ChildQuery.id == pending.child_id)
|
||||||
if not child_result:
|
if not child_result:
|
||||||
@@ -1016,8 +1167,12 @@ def list_pending_confirmations():
|
|||||||
# Look up entity details based on type
|
# Look up entity details based on type
|
||||||
if pending.entity_type == 'reward':
|
if pending.entity_type == 'reward':
|
||||||
entity_result = reward_db.get((RewardQuery.id == pending.entity_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
entity_result = reward_db.get((RewardQuery.id == pending.entity_id) & ((RewardQuery.user_id == user_id) | (RewardQuery.user_id == None)))
|
||||||
else:
|
elif pending.entity_type == 'chore':
|
||||||
entity_result = task_db.get((TaskQuery.id == pending.entity_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
|
entity_result = task_db.get((TaskQuery.id == pending.entity_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
|
||||||
|
elif pending.entity_type == 'routine':
|
||||||
|
entity_result = routine_db.get((RoutineQuery.id == pending.entity_id) & ((RoutineQuery.user_id == user_id) | (RoutineQuery.user_id == None)))
|
||||||
|
else:
|
||||||
|
entity_result = None
|
||||||
|
|
||||||
if not entity_result:
|
if not entity_result:
|
||||||
continue
|
continue
|
||||||
@@ -1079,13 +1234,20 @@ def confirm_chore(id):
|
|||||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
|
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
|
||||||
)
|
)
|
||||||
if existing:
|
if existing:
|
||||||
|
today_local, tz_str = _get_user_today_local(user_id)
|
||||||
if existing.get('status') == 'pending':
|
if existing.get('status') == 'pending':
|
||||||
return jsonify({'error': 'Chore already pending confirmation', 'code': 'CHORE_ALREADY_PENDING'}), 400
|
if _is_epoch_timestamp_on_local_day(existing.get('created_at'), today_local, tz_str):
|
||||||
|
return jsonify({'error': 'Chore already pending confirmation', 'code': 'CHORE_ALREADY_PENDING'}), 400
|
||||||
|
pending_id = existing.get('id')
|
||||||
|
if pending_id:
|
||||||
|
pending_confirmations_db.remove(PendingQuery.id == pending_id)
|
||||||
if existing.get('status') == 'approved':
|
if existing.get('status') == 'approved':
|
||||||
approved_at = existing.get('approved_at', '')
|
approved_at = existing.get('approved_at', '')
|
||||||
today_utc = datetime.now(timezone.utc).strftime('%Y-%m-%d')
|
if _is_iso_timestamp_on_local_day(approved_at, today_local, tz_str):
|
||||||
if approved_at and approved_at[:10] == today_utc:
|
|
||||||
return jsonify({'error': 'Chore already completed today', 'code': 'CHORE_ALREADY_COMPLETED'}), 400
|
return jsonify({'error': 'Chore already completed today', 'code': 'CHORE_ALREADY_COMPLETED'}), 400
|
||||||
|
pending_id = existing.get('id')
|
||||||
|
if pending_id:
|
||||||
|
pending_confirmations_db.remove(PendingQuery.id == pending_id)
|
||||||
|
|
||||||
confirmation = PendingConfirmation(
|
confirmation = PendingConfirmation(
|
||||||
child_id=id, entity_id=task_id, entity_type='chore', user_id=user_id
|
child_id=id, entity_id=task_id, entity_type='chore', user_id=user_id
|
||||||
@@ -1105,6 +1267,30 @@ def confirm_chore(id):
|
|||||||
TrackingEventCreated(tracking_event.id, id, 'chore', 'confirmed')))
|
TrackingEventCreated(tracking_event.id, id, 'chore', 'confirmed')))
|
||||||
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
||||||
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_CONFIRMED)))
|
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_CONFIRMED)))
|
||||||
|
|
||||||
|
# Fire web push notification to all parent subscriptions
|
||||||
|
_push_user = users_db.get(Query().id == user_id)
|
||||||
|
if _push_user and _push_user.get('push_notifications_enabled', True):
|
||||||
|
try:
|
||||||
|
approve_token = create_action_token(user_id, id, task_id, 'chore', 'approve')
|
||||||
|
deny_token = create_action_token(user_id, id, task_id, 'chore', 'deny')
|
||||||
|
push_payload = {
|
||||||
|
'type': 'chore_confirmed',
|
||||||
|
'title': f'{child.name} completed a chore',
|
||||||
|
'body': f'{task.name} is waiting for your approval.',
|
||||||
|
'user_id': user_id,
|
||||||
|
'child_id': id,
|
||||||
|
'child_name': child.name,
|
||||||
|
'entity_id': task_id,
|
||||||
|
'entity_type': 'chore',
|
||||||
|
'entity_name': task.name,
|
||||||
|
'approve_token': approve_token.id,
|
||||||
|
'deny_token': deny_token.id,
|
||||||
|
}
|
||||||
|
send_push_to_user(user_id, push_payload)
|
||||||
|
except Exception as _push_err:
|
||||||
|
logger.warning(f'Push notification failed for chore confirmation: {_push_err}')
|
||||||
|
|
||||||
return jsonify({'message': f'Chore {task.name} confirmed by {child.name}.', 'confirmation_id': confirmation.id}), 200
|
return jsonify({'message': f'Chore {task.name} confirmed by {child.name}.', 'confirmation_id': confirmation.id}), 200
|
||||||
|
|
||||||
|
|
||||||
@@ -1171,77 +1357,18 @@ def approve_chore(id):
|
|||||||
if not task_id:
|
if not task_id:
|
||||||
return jsonify({'error': 'task_id is required'}), 400
|
return jsonify({'error': 'task_id is required'}), 400
|
||||||
|
|
||||||
ChildQuery = Query()
|
try:
|
||||||
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
|
result = chore_actions.approve_chore(user_id, id, task_id)
|
||||||
if not result:
|
except ValueError:
|
||||||
return jsonify({'error': 'Child not found'}), 404
|
return jsonify({'error': 'Child or task not found'}), 404
|
||||||
child = Child.from_dict(result[0])
|
|
||||||
|
|
||||||
PendingQuery = Query()
|
if result is None:
|
||||||
existing = pending_confirmations_db.get(
|
|
||||||
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
|
|
||||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.status == 'pending') &
|
|
||||||
(PendingQuery.user_id == user_id)
|
|
||||||
)
|
|
||||||
if not existing:
|
|
||||||
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
|
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
|
||||||
|
|
||||||
TaskQuery = Query()
|
|
||||||
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
|
|
||||||
if not task_result:
|
|
||||||
return jsonify({'error': 'Task not found'}), 404
|
|
||||||
task = Task.from_dict(task_result)
|
|
||||||
|
|
||||||
# Award points
|
|
||||||
override = get_override(id, task_id)
|
|
||||||
points_value = override.custom_value if override else task.points
|
|
||||||
points_before = child.points
|
|
||||||
child.points += points_value
|
|
||||||
child_db.update({'points': child.points}, ChildQuery.id == id)
|
|
||||||
|
|
||||||
# Update confirmation to approved
|
|
||||||
# For general (non-scheduled) chores, remove the confirmation so chore resets to normal
|
|
||||||
schedule = get_schedule(id, task_id)
|
|
||||||
if schedule:
|
|
||||||
now_str = datetime.now(timezone.utc).isoformat()
|
|
||||||
pending_confirmations_db.update(
|
|
||||||
{'status': 'approved', 'approved_at': now_str},
|
|
||||||
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
|
|
||||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
pending_confirmations_db.remove(
|
|
||||||
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
|
|
||||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
tracking_metadata = {
|
|
||||||
'task_name': task.name,
|
|
||||||
'task_type': task.type,
|
|
||||||
'default_points': task.points
|
|
||||||
}
|
|
||||||
if override:
|
|
||||||
tracking_metadata['custom_points'] = override.custom_value
|
|
||||||
tracking_metadata['has_override'] = True
|
|
||||||
|
|
||||||
tracking_event = TrackingEvent.create_event(
|
|
||||||
user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id,
|
|
||||||
action='approved', points_before=points_before, points_after=child.points,
|
|
||||||
metadata=tracking_metadata
|
|
||||||
)
|
|
||||||
insert_tracking_event(tracking_event)
|
|
||||||
log_tracking_event(tracking_event)
|
|
||||||
|
|
||||||
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
|
|
||||||
TrackingEventCreated(tracking_event.id, id, 'chore', 'approved')))
|
|
||||||
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
|
||||||
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_APPROVED)))
|
|
||||||
send_event_for_current_user(Event(EventType.CHILD_TASK_TRIGGERED.value,
|
|
||||||
ChildTaskTriggered(task_id, id, child.points)))
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'message': f'Chore {task.name} approved for {child.name}.',
|
'message': f'Chore {result["task_name"]} approved for {result["child_name"]}.',
|
||||||
'points': child.points,
|
'points': result['points'],
|
||||||
'id': child.id
|
'id': result['child_id']
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
@@ -1260,7 +1387,6 @@ def reject_chore(id):
|
|||||||
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
|
result = child_db.search((ChildQuery.id == id) & (ChildQuery.user_id == user_id))
|
||||||
if not result:
|
if not result:
|
||||||
return jsonify({'error': 'Child not found'}), 404
|
return jsonify({'error': 'Child not found'}), 404
|
||||||
child = Child.from_dict(result[0])
|
|
||||||
|
|
||||||
PendingQuery = Query()
|
PendingQuery = Query()
|
||||||
existing = pending_confirmations_db.get(
|
existing = pending_confirmations_db.get(
|
||||||
@@ -1271,27 +1397,7 @@ def reject_chore(id):
|
|||||||
if not existing:
|
if not existing:
|
||||||
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
|
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
|
||||||
|
|
||||||
pending_confirmations_db.remove(
|
chore_actions.reject_chore(user_id, id, task_id)
|
||||||
(PendingQuery.child_id == id) & (PendingQuery.entity_id == task_id) &
|
|
||||||
(PendingQuery.entity_type == 'chore') & (PendingQuery.user_id == user_id)
|
|
||||||
)
|
|
||||||
|
|
||||||
TaskQuery = Query()
|
|
||||||
task_result = task_db.get((TaskQuery.id == task_id) & ((TaskQuery.user_id == user_id) | (TaskQuery.user_id == None)))
|
|
||||||
task_name = task_result.get('name') if task_result else 'Unknown'
|
|
||||||
|
|
||||||
tracking_event = TrackingEvent.create_event(
|
|
||||||
user_id=user_id, child_id=id, entity_type='chore', entity_id=task_id,
|
|
||||||
action='rejected', points_before=child.points, points_after=child.points,
|
|
||||||
metadata={'task_name': task_name}
|
|
||||||
)
|
|
||||||
insert_tracking_event(tracking_event)
|
|
||||||
log_tracking_event(tracking_event)
|
|
||||||
|
|
||||||
send_event_for_current_user(Event(EventType.TRACKING_EVENT_CREATED.value,
|
|
||||||
TrackingEventCreated(tracking_event.id, id, 'chore', 'rejected')))
|
|
||||||
send_event_for_current_user(Event(EventType.CHILD_CHORE_CONFIRMATION.value,
|
|
||||||
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_REJECTED)))
|
|
||||||
return jsonify({'message': 'Chore confirmation rejected.'}), 200
|
return jsonify({'message': 'Chore confirmation rejected.'}), 200
|
||||||
|
|
||||||
|
|
||||||
@@ -1344,3 +1450,22 @@ def reset_chore(id):
|
|||||||
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_RESET)))
|
ChildChoreConfirmation(id, task_id, ChildChoreConfirmation.OPERATION_RESET)))
|
||||||
return jsonify({'message': 'Chore reset to available.'}), 200
|
return jsonify({'message': 'Chore reset to available.'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_api.route('/child/<id>/deny-reward-request', methods=['POST'])
|
||||||
|
def deny_reward_request(id):
|
||||||
|
"""Parent denies a child's pending reward request."""
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
data = request.get_json()
|
||||||
|
reward_id = data.get('reward_id')
|
||||||
|
if not reward_id:
|
||||||
|
return jsonify({'error': 'reward_id is required'}), 400
|
||||||
|
|
||||||
|
result = chore_actions.deny_reward(user_id, id, reward_id)
|
||||||
|
if result is None:
|
||||||
|
return jsonify({'message': 'This reward request has already been resolved.'}), 200
|
||||||
|
|
||||||
|
return jsonify({'message': f'Reward request denied for {result["child_name"]}.'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ from flask import Blueprint, request, jsonify
|
|||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
from api.utils import get_validated_user_id, send_event_for_current_user
|
from api.utils import get_validated_user_id, send_event_for_current_user
|
||||||
from api.error_codes import ErrorCodes
|
from api.error_codes import ErrorCodes
|
||||||
from db.db import child_db, task_db, reward_db
|
from db.db import child_db, task_db, reward_db, routine_db
|
||||||
from db.child_overrides import (
|
from db.child_overrides import (
|
||||||
insert_override,
|
insert_override,
|
||||||
get_override,
|
get_override,
|
||||||
@@ -52,8 +52,8 @@ def set_child_override(child_id):
|
|||||||
return jsonify({'error': 'custom_value is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'custom_value'}), 400
|
return jsonify({'error': 'custom_value is required', 'code': ErrorCodes.MISSING_FIELD, 'field': 'custom_value'}), 400
|
||||||
|
|
||||||
# Validate entity_type
|
# Validate entity_type
|
||||||
if entity_type not in ['task', 'reward']:
|
if entity_type not in ['task', 'reward', 'routine']:
|
||||||
return jsonify({'error': 'entity_type must be "task" or "reward"', 'code': ErrorCodes.INVALID_VALUE, 'field': 'entity_type'}), 400
|
return jsonify({'error': 'entity_type must be "task", "reward", or "routine"', 'code': ErrorCodes.INVALID_VALUE, 'field': 'entity_type'}), 400
|
||||||
|
|
||||||
# Validate custom_value range
|
# Validate custom_value range
|
||||||
if not isinstance(custom_value, int) or custom_value < 0 or custom_value > 10000:
|
if not isinstance(custom_value, int) or custom_value < 0 or custom_value > 10000:
|
||||||
@@ -74,7 +74,7 @@ def set_child_override(child_id):
|
|||||||
if entity_id not in assigned_tasks:
|
if entity_id not in assigned_tasks:
|
||||||
return jsonify({'error': 'Task not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
|
return jsonify({'error': 'Task not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
|
||||||
|
|
||||||
else: # reward
|
elif entity_type == 'reward':
|
||||||
EntityQuery = Query()
|
EntityQuery = Query()
|
||||||
entity_result = reward_db.search(
|
entity_result = reward_db.search(
|
||||||
(EntityQuery.id == entity_id) &
|
(EntityQuery.id == entity_id) &
|
||||||
@@ -87,6 +87,19 @@ def set_child_override(child_id):
|
|||||||
assigned_rewards = child_dict.get('rewards', [])
|
assigned_rewards = child_dict.get('rewards', [])
|
||||||
if entity_id not in assigned_rewards:
|
if entity_id not in assigned_rewards:
|
||||||
return jsonify({'error': 'Reward not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
|
return jsonify({'error': 'Reward not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
|
||||||
|
|
||||||
|
else: # routine
|
||||||
|
EntityQuery = Query()
|
||||||
|
entity_result = routine_db.search(
|
||||||
|
(EntityQuery.id == entity_id) &
|
||||||
|
((EntityQuery.user_id == user_id) | (EntityQuery.user_id == None))
|
||||||
|
)
|
||||||
|
if not entity_result:
|
||||||
|
return jsonify({'error': 'Routine not found', 'code': 'ROUTINE_NOT_FOUND'}), 404
|
||||||
|
|
||||||
|
assigned_routines = child_dict.get('routines', [])
|
||||||
|
if entity_id not in assigned_routines:
|
||||||
|
return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 404
|
||||||
|
|
||||||
# Create and insert override
|
# Create and insert override
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -0,0 +1,638 @@
|
|||||||
|
from collections import defaultdict
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from api.error_codes import ErrorCodes
|
||||||
|
from api.utils import get_validated_user_id, send_event_for_current_user
|
||||||
|
from db.child_overrides import delete_override, get_override
|
||||||
|
from db.db import child_db, pending_confirmations_db, routine_db, users_db
|
||||||
|
from db.routine_extensions import delete_extension_for_child_routine, get_extension_for_child_routine
|
||||||
|
from db.routine_items import get_items_for_routine
|
||||||
|
from db.routine_schedules import delete_schedule, get_schedule
|
||||||
|
from events.types.child_routine_confirmation import ChildRoutineConfirmation
|
||||||
|
from events.types.child_routines_set import ChildRoutinesSet
|
||||||
|
from events.types.event import Event
|
||||||
|
from events.types.event_types import EventType
|
||||||
|
from models.child import Child
|
||||||
|
from models.pending_confirmation import PendingConfirmation
|
||||||
|
from models.routine import Routine
|
||||||
|
from utils.digest_token import create_action_token
|
||||||
|
from utils.push_sender import send_push_to_user
|
||||||
|
|
||||||
|
child_routine_api = Blueprint('child_routine_api', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_timezone(user_id: str) -> str | None:
|
||||||
|
user = users_db.get(Query().id == user_id)
|
||||||
|
if not user:
|
||||||
|
return None
|
||||||
|
return user.get('timezone')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_today_local(user_id: str) -> tuple[str, str | None]:
|
||||||
|
tz_str = _get_user_timezone(user_id)
|
||||||
|
try:
|
||||||
|
now_local = datetime.now(ZoneInfo(tz_str)) if tz_str else datetime.now(timezone.utc)
|
||||||
|
except Exception:
|
||||||
|
tz_str = None
|
||||||
|
now_local = datetime.now(timezone.utc)
|
||||||
|
return now_local.strftime('%Y-%m-%d'), tz_str
|
||||||
|
|
||||||
|
|
||||||
|
def _is_iso_timestamp_on_local_day(timestamp: str | None, local_day: str, tz_str: str | None) -> bool:
|
||||||
|
if not timestamp:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
normalized = timestamp.replace('Z', '+00:00')
|
||||||
|
parsed = datetime.fromisoformat(normalized)
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
parsed = parsed.replace(tzinfo=timezone.utc)
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(tz_str) if tz_str else timezone.utc
|
||||||
|
except Exception:
|
||||||
|
tz = timezone.utc
|
||||||
|
return parsed.astimezone(tz).strftime('%Y-%m-%d') == local_day
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_epoch_timestamp_on_local_day(epoch_ts, local_day: str, tz_str: str | None) -> bool:
|
||||||
|
if epoch_ts is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
tz = ZoneInfo(tz_str) if tz_str else timezone.utc
|
||||||
|
except Exception:
|
||||||
|
tz = timezone.utc
|
||||||
|
try:
|
||||||
|
return datetime.fromtimestamp(float(epoch_ts), tz).strftime('%Y-%m-%d') == local_day
|
||||||
|
except (TypeError, ValueError, OSError):
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
class ChildRoutine:
|
||||||
|
def __init__(self, name, points, image_id, _id):
|
||||||
|
self.id = _id
|
||||||
|
self.name = name
|
||||||
|
self.points = points
|
||||||
|
self.image_id = image_id
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'name': self.name,
|
||||||
|
'points': self.points,
|
||||||
|
'image_id': self.image_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_child_for_user(child_id: str, user_id: str):
|
||||||
|
child_q = Query()
|
||||||
|
result = child_db.search((child_q.id == child_id) & (child_q.user_id == user_id))
|
||||||
|
return Child.from_dict(result[0]) if result else None
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_routine_for_user(routine_id: str, user_id: str):
|
||||||
|
routine_q = Query()
|
||||||
|
routine_result = routine_db.get(
|
||||||
|
(routine_q.id == routine_id) & ((routine_q.user_id == user_id) | (routine_q.user_id == None))
|
||||||
|
)
|
||||||
|
return Routine.from_dict(routine_result) if routine_result else None
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/assign-routine', methods=['POST'])
|
||||||
|
def assign_routine_to_child(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
routine_id = data.get('routine_id')
|
||||||
|
if not routine_id:
|
||||||
|
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
routine = _resolve_routine_for_user(routine_id, user_id)
|
||||||
|
if not routine:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
routine_ids = list(child.routines)
|
||||||
|
if routine_id not in routine_ids:
|
||||||
|
routine_ids.append(routine_id)
|
||||||
|
child_db.update({'routines': routine_ids}, Query().id == id)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(id, routine_ids))
|
||||||
|
)
|
||||||
|
return jsonify({'message': f'Routine {routine_id} assigned to {child.name}.'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/remove-routine', methods=['POST'])
|
||||||
|
def remove_routine_from_child(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
routine_id = data.get('routine_id')
|
||||||
|
if not routine_id:
|
||||||
|
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
routine_ids = list(child.routines)
|
||||||
|
if routine_id not in routine_ids:
|
||||||
|
return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 400
|
||||||
|
|
||||||
|
routine_ids.remove(routine_id)
|
||||||
|
child_db.update({'routines': routine_ids}, Query().id == id)
|
||||||
|
|
||||||
|
override = get_override(id, routine_id)
|
||||||
|
if override and override.entity_type == 'routine':
|
||||||
|
delete_override(id, routine_id)
|
||||||
|
|
||||||
|
delete_schedule(id, routine_id)
|
||||||
|
delete_extension_for_child_routine(id, routine_id)
|
||||||
|
|
||||||
|
pending_q = Query()
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(id, routine_ids))
|
||||||
|
)
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||||
|
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return jsonify({'message': f'Routine {routine_id} removed from {child.name}.'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/set-routines', methods=['PUT'])
|
||||||
|
def set_child_routines(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
routine_ids = data.get('routine_ids')
|
||||||
|
if not isinstance(routine_ids, list):
|
||||||
|
return jsonify({'error': 'routine_ids must be a list'}), 400
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
routine_q = Query()
|
||||||
|
valid_ids = []
|
||||||
|
for rid in dict.fromkeys(routine_ids):
|
||||||
|
if routine_db.get((routine_q.id == rid) & ((routine_q.user_id == user_id) | (routine_q.user_id == None))):
|
||||||
|
valid_ids.append(rid)
|
||||||
|
|
||||||
|
old_ids = set(child.routines)
|
||||||
|
new_ids = set(valid_ids)
|
||||||
|
unassigned_ids = old_ids - new_ids
|
||||||
|
|
||||||
|
pending_q = Query()
|
||||||
|
for rid in unassigned_ids:
|
||||||
|
override = get_override(id, rid)
|
||||||
|
if override and override.entity_type == 'routine':
|
||||||
|
delete_override(id, rid)
|
||||||
|
|
||||||
|
delete_schedule(id, rid)
|
||||||
|
delete_extension_for_child_routine(id, rid)
|
||||||
|
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(pending_q.child_id == id) & (pending_q.entity_id == rid) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
child_db.update({'routines': valid_ids}, Query().id == id)
|
||||||
|
send_event_for_current_user(Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(id, valid_ids)))
|
||||||
|
|
||||||
|
return jsonify({'message': f'Routines set for child {id}.', 'routine_ids': valid_ids, 'count': len(valid_ids)}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/list-routines', methods=['GET'])
|
||||||
|
def list_child_routines(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
routine_q = Query()
|
||||||
|
pending_q = Query()
|
||||||
|
today_local, tz_str = _get_user_today_local(user_id)
|
||||||
|
|
||||||
|
child_routines = []
|
||||||
|
for rid in child.routines:
|
||||||
|
routine_record = routine_db.get((routine_q.id == rid) & ((routine_q.user_id == user_id) | (routine_q.user_id == None)))
|
||||||
|
if not routine_record:
|
||||||
|
continue
|
||||||
|
|
||||||
|
routine = Routine.from_dict(routine_record)
|
||||||
|
override = get_override(id, rid)
|
||||||
|
custom_value = override.custom_value if override and override.entity_type == 'routine' else None
|
||||||
|
|
||||||
|
cr = ChildRoutine(routine.name, routine.points, routine.image_id, routine.id)
|
||||||
|
cr_dict = cr.to_dict()
|
||||||
|
if custom_value is not None:
|
||||||
|
cr_dict['custom_value'] = custom_value
|
||||||
|
|
||||||
|
schedule = get_schedule(id, rid)
|
||||||
|
cr_dict['schedule'] = schedule.to_dict() if schedule else None
|
||||||
|
|
||||||
|
extension = get_extension_for_child_routine(id, rid)
|
||||||
|
cr_dict['extension_date'] = extension.date if extension else None
|
||||||
|
|
||||||
|
items = get_items_for_routine(rid)
|
||||||
|
cr_dict['items'] = [item.to_dict() for item in items]
|
||||||
|
|
||||||
|
pending = pending_confirmations_db.get(
|
||||||
|
(pending_q.child_id == id) & (pending_q.entity_id == rid) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
if pending:
|
||||||
|
status = pending.get('status')
|
||||||
|
approved_at = pending.get('approved_at')
|
||||||
|
created_at = pending.get('created_at')
|
||||||
|
confirmation_id = pending.get('id')
|
||||||
|
|
||||||
|
if status == 'approved' and _is_iso_timestamp_on_local_day(approved_at, today_local, tz_str):
|
||||||
|
cr_dict['pending_status'] = 'approved'
|
||||||
|
cr_dict['approved_at'] = approved_at
|
||||||
|
cr_dict['pending_confirmation_id'] = confirmation_id
|
||||||
|
elif status == 'pending' and _is_epoch_timestamp_on_local_day(created_at, today_local, tz_str):
|
||||||
|
cr_dict['pending_status'] = 'pending'
|
||||||
|
cr_dict['approved_at'] = None
|
||||||
|
cr_dict['pending_confirmation_id'] = confirmation_id
|
||||||
|
else:
|
||||||
|
pending_id = pending.get('id')
|
||||||
|
if pending_id:
|
||||||
|
pending_confirmations_db.remove(pending_q.id == pending_id)
|
||||||
|
cr_dict['pending_status'] = None
|
||||||
|
cr_dict['approved_at'] = None
|
||||||
|
cr_dict['pending_confirmation_id'] = None
|
||||||
|
else:
|
||||||
|
cr_dict['pending_status'] = None
|
||||||
|
cr_dict['approved_at'] = None
|
||||||
|
cr_dict['pending_confirmation_id'] = None
|
||||||
|
|
||||||
|
child_routines.append(cr_dict)
|
||||||
|
|
||||||
|
return jsonify({'routines': child_routines}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/list-assignable-routines', methods=['GET'])
|
||||||
|
def list_assignable_routines(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
assigned_ids = set(child.routines)
|
||||||
|
all_routines = [r for r in routine_db.all() if r and r.get('id') and r.get('id') not in assigned_ids]
|
||||||
|
|
||||||
|
name_to_routines = defaultdict(list)
|
||||||
|
for routine in all_routines:
|
||||||
|
name_to_routines[routine.get('name')].append(routine)
|
||||||
|
|
||||||
|
filtered_routines = []
|
||||||
|
for _, routines in name_to_routines.items():
|
||||||
|
user_routines = [r for r in routines if r.get('user_id') is not None]
|
||||||
|
if len(user_routines) == 0:
|
||||||
|
filtered_routines.append(routines[0])
|
||||||
|
elif len(user_routines) == 1:
|
||||||
|
filtered_routines.append(user_routines[0])
|
||||||
|
else:
|
||||||
|
filtered_routines.extend(user_routines)
|
||||||
|
|
||||||
|
assignable = [
|
||||||
|
ChildRoutine(r.get('name'), r.get('points'), r.get('image_id'), r.get('id')).to_dict()
|
||||||
|
for r in filtered_routines
|
||||||
|
]
|
||||||
|
return jsonify({'routines': assignable, 'count': len(assignable)}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/confirm-routine', methods=['POST'])
|
||||||
|
def confirm_routine(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
routine_id = data.get('routine_id')
|
||||||
|
if not routine_id:
|
||||||
|
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
if routine_id not in child.routines:
|
||||||
|
return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 400
|
||||||
|
|
||||||
|
routine = _resolve_routine_for_user(routine_id, user_id)
|
||||||
|
if not routine:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
pending_q = Query()
|
||||||
|
existing = pending_confirmations_db.get(
|
||||||
|
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
today_local, tz_str = _get_user_today_local(user_id)
|
||||||
|
if existing.get('status') == 'pending':
|
||||||
|
if _is_epoch_timestamp_on_local_day(existing.get('created_at'), today_local, tz_str):
|
||||||
|
return jsonify({'error': 'Routine already pending confirmation', 'code': 'ROUTINE_ALREADY_PENDING'}), 400
|
||||||
|
pending_id = existing.get('id')
|
||||||
|
if pending_id:
|
||||||
|
pending_confirmations_db.remove(pending_q.id == pending_id)
|
||||||
|
if existing.get('status') == 'approved':
|
||||||
|
approved_at = existing.get('approved_at', '')
|
||||||
|
if _is_iso_timestamp_on_local_day(approved_at, today_local, tz_str):
|
||||||
|
return jsonify({'error': 'Routine already completed today', 'code': 'ROUTINE_ALREADY_COMPLETED'}), 400
|
||||||
|
pending_id = existing.get('id')
|
||||||
|
if pending_id:
|
||||||
|
pending_confirmations_db.remove(pending_q.id == pending_id)
|
||||||
|
|
||||||
|
confirmation = PendingConfirmation(
|
||||||
|
child_id=id,
|
||||||
|
entity_id=routine_id,
|
||||||
|
entity_type='routine',
|
||||||
|
user_id=user_id,
|
||||||
|
)
|
||||||
|
pending_confirmations_db.insert(confirmation.to_dict())
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||||
|
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_PENDING)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
push_user = users_db.get(Query().id == user_id)
|
||||||
|
if push_user and push_user.get('push_notifications_enabled', True):
|
||||||
|
try:
|
||||||
|
approve_token = create_action_token(user_id, id, routine_id, 'routine', 'approve')
|
||||||
|
deny_token = create_action_token(user_id, id, routine_id, 'routine', 'deny')
|
||||||
|
push_payload = {
|
||||||
|
'type': 'routine_confirmed',
|
||||||
|
'title': 'Routine Pending',
|
||||||
|
'body': f'{child.name} completed {routine.name}',
|
||||||
|
'user_id': user_id,
|
||||||
|
'child_id': id,
|
||||||
|
'child_name': child.name,
|
||||||
|
'entity_id': routine_id,
|
||||||
|
'entity_type': 'routine',
|
||||||
|
'entity_name': routine.name,
|
||||||
|
'approve_token': approve_token.id,
|
||||||
|
'deny_token': deny_token.id,
|
||||||
|
}
|
||||||
|
send_push_to_user(user_id, push_payload)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({'message': f'Routine {routine.name} confirmed by {child.name}.', 'confirmation_id': confirmation.id}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/cancel-routine-confirmation', methods=['POST'])
|
||||||
|
def cancel_routine_confirmation(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
routine_id = data.get('routine_id')
|
||||||
|
if not routine_id:
|
||||||
|
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
pending_q = Query()
|
||||||
|
existing = pending_confirmations_db.get(
|
||||||
|
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending') &
|
||||||
|
(pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
if not existing:
|
||||||
|
return jsonify({'error': 'No pending confirmation found', 'code': 'PENDING_NOT_FOUND'}), 400
|
||||||
|
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending') &
|
||||||
|
(pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||||
|
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return jsonify({'message': 'Routine confirmation cancelled.'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/approve-routine/<confirmation_id>', methods=['POST'])
|
||||||
|
def approve_routine(id, confirmation_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
pending_q = Query()
|
||||||
|
confirmation = pending_confirmations_db.get(
|
||||||
|
(pending_q.id == confirmation_id) & (pending_q.child_id == id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
if not confirmation:
|
||||||
|
return jsonify({'error': 'Pending confirmation not found', 'code': 'PENDING_NOT_FOUND'}), 404
|
||||||
|
|
||||||
|
if confirmation.get('status') != 'pending':
|
||||||
|
return jsonify({'error': 'Confirmation is already resolved', 'code': 'ALREADY_RESOLVED'}), 400
|
||||||
|
|
||||||
|
routine_id = confirmation.get('entity_id')
|
||||||
|
routine = _resolve_routine_for_user(routine_id, user_id)
|
||||||
|
if not routine:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
override = get_override(id, routine_id)
|
||||||
|
points_value = override.custom_value if override and override.entity_type == 'routine' else routine.points
|
||||||
|
|
||||||
|
new_points = max(0, child.points + points_value)
|
||||||
|
child_db.update({'points': new_points}, Query().id == id)
|
||||||
|
|
||||||
|
approved_at = datetime.now(timezone.utc).isoformat()
|
||||||
|
pending_confirmations_db.update(
|
||||||
|
{'status': 'approved', 'approved_at': approved_at},
|
||||||
|
pending_q.id == confirmation_id
|
||||||
|
)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||||
|
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_APPROVED)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'message': f'Routine {routine.name} approved for {child.name}.',
|
||||||
|
'points': new_points,
|
||||||
|
'id': child.id,
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/reject-routine/<confirmation_id>', methods=['POST'])
|
||||||
|
def reject_routine(id, confirmation_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
pending_q = Query()
|
||||||
|
confirmation = pending_confirmations_db.get(
|
||||||
|
(pending_q.id == confirmation_id) & (pending_q.child_id == id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
if not confirmation:
|
||||||
|
return jsonify({'error': 'Pending confirmation not found', 'code': 'PENDING_NOT_FOUND'}), 404
|
||||||
|
|
||||||
|
if confirmation.get('status') != 'pending':
|
||||||
|
return jsonify({'error': 'Confirmation is already resolved', 'code': 'ALREADY_RESOLVED'}), 400
|
||||||
|
|
||||||
|
pending_confirmations_db.update({'status': 'rejected', 'approved_at': None}, pending_q.id == confirmation_id)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||||
|
ChildRoutineConfirmation(id, confirmation.get('entity_id'), ChildRoutineConfirmation.OPERATION_REJECTED)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({'message': 'Routine confirmation rejected.'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/reset-routine/<confirmation_id>', methods=['POST'])
|
||||||
|
def reset_routine(id, confirmation_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
pending_q = Query()
|
||||||
|
confirmation = pending_confirmations_db.get(
|
||||||
|
(pending_q.id == confirmation_id) & (pending_q.child_id == id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
if not confirmation:
|
||||||
|
return jsonify({'error': 'Pending confirmation not found', 'code': 'PENDING_NOT_FOUND'}), 404
|
||||||
|
|
||||||
|
routine_id = confirmation.get('entity_id')
|
||||||
|
pending_confirmations_db.remove(pending_q.id == confirmation_id)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||||
|
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return jsonify({'message': 'Routine reset to available.'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@child_routine_api.route('/child/<id>/trigger-routine', methods=['POST'])
|
||||||
|
def trigger_child_routine(id):
|
||||||
|
"""Parent-triggered routine confirmation — directly awards points."""
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
routine_id = data.get('routine_id')
|
||||||
|
if not routine_id:
|
||||||
|
return jsonify({'error': 'routine_id is required', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||||
|
|
||||||
|
child = _validate_child_for_user(id, user_id)
|
||||||
|
if not child:
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
if routine_id not in child.routines:
|
||||||
|
return jsonify({'error': 'Routine not assigned to child', 'code': ErrorCodes.ENTITY_NOT_ASSIGNED}), 400
|
||||||
|
|
||||||
|
routine = _resolve_routine_for_user(routine_id, user_id)
|
||||||
|
if not routine:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
# Check for override
|
||||||
|
override = get_override(id, routine_id)
|
||||||
|
points_value = override.custom_value if override and override.entity_type == 'routine' else routine.points
|
||||||
|
|
||||||
|
# Award points
|
||||||
|
new_points = max(0, child.points + points_value)
|
||||||
|
child_db.update({'points': new_points}, Query().id == id)
|
||||||
|
|
||||||
|
# Create an approved pending confirmation so it shows as completed in the routine list
|
||||||
|
pending_q = Query()
|
||||||
|
existing = pending_confirmations_db.get(
|
||||||
|
(pending_q.child_id == id) & (pending_q.entity_id == routine_id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
if existing:
|
||||||
|
today_local, tz_str = _get_user_today_local(user_id)
|
||||||
|
# Remove old confirmation if it exists and is not from today
|
||||||
|
if existing.get('status') == 'approved' and _is_iso_timestamp_on_local_day(existing.get('approved_at'), today_local, tz_str):
|
||||||
|
return jsonify({'error': 'Routine already completed today', 'code': 'ROUTINE_ALREADY_COMPLETED'}), 400
|
||||||
|
pending_id = existing.get('id')
|
||||||
|
if pending_id:
|
||||||
|
pending_confirmations_db.remove(pending_q.id == pending_id)
|
||||||
|
|
||||||
|
confirmation = PendingConfirmation(
|
||||||
|
child_id=id,
|
||||||
|
entity_id=routine_id,
|
||||||
|
entity_type='routine',
|
||||||
|
user_id=user_id,
|
||||||
|
status='approved',
|
||||||
|
approved_at=datetime.now(timezone.utc).isoformat()
|
||||||
|
)
|
||||||
|
pending_confirmations_db.insert(confirmation.to_dict())
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||||
|
ChildRoutineConfirmation(id, routine_id, ChildRoutineConfirmation.OPERATION_APPROVED)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'message': f'Routine {routine.name} awarded to {child.name}.',
|
||||||
|
'points': new_points,
|
||||||
|
'id': child.id,
|
||||||
|
}), 200
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import logging
|
||||||
|
|
||||||
|
from flask import Blueprint, redirect, make_response, jsonify, request
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from utils.digest_token import validate_and_consume_token, validate_unsubscribe_token, peek_token
|
||||||
|
from db.db import users_db
|
||||||
|
from api.child_action_helpers import approve_chore, reject_chore, approve_reward_request, deny_reward, approve_routine, reject_routine
|
||||||
|
from api.utils import get_validated_user_id
|
||||||
|
|
||||||
|
digest_action_api = Blueprint('digest_action_api', __name__)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_ERROR_HTML = """<!DOCTYPE html>
|
||||||
|
<html><head><title>Link Error</title></head>
|
||||||
|
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
|
||||||
|
<h2>This link is invalid or has expired.</h2>
|
||||||
|
<p>Action links expire after 24 hours and can only be used once.</p>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
_UNSUB_HTML = """<!DOCTYPE html>
|
||||||
|
<html><head><title>Unsubscribed</title></head>
|
||||||
|
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
|
||||||
|
<h2>You have been unsubscribed from daily digest emails.</h2>
|
||||||
|
<p>To re-enable, visit your profile in the app.</p>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
_UNSUB_ERROR_HTML = """<!DOCTYPE html>
|
||||||
|
<html><head><title>Link Error</title></head>
|
||||||
|
<body style="font-family:sans-serif;text-align:center;margin-top:60px;">
|
||||||
|
<h2>This unsubscribe link is invalid or has expired.</h2>
|
||||||
|
</body></html>"""
|
||||||
|
|
||||||
|
|
||||||
|
@digest_action_api.route('/digest-action/<token_id>', methods=['GET'])
|
||||||
|
def handle_digest_action(token_id: str):
|
||||||
|
"""
|
||||||
|
Validate a digest action token (without consuming it) and redirect to the
|
||||||
|
frontend ParentView with the token embedded so the action executes only
|
||||||
|
after the user authenticates as a parent.
|
||||||
|
"""
|
||||||
|
from flask import current_app
|
||||||
|
frontend_url = current_app.config.get('FRONTEND_URL', 'https://localhost:5173')
|
||||||
|
|
||||||
|
token = peek_token(token_id)
|
||||||
|
if not token:
|
||||||
|
return make_response(_ERROR_HTML, 400)
|
||||||
|
|
||||||
|
deep_link = (
|
||||||
|
f"{frontend_url}/parent/{token.child_id}"
|
||||||
|
f"?digestToken={token_id}&scrollTo={token.entity_id}&entityType={token.entity_type}"
|
||||||
|
)
|
||||||
|
return redirect(deep_link, 302)
|
||||||
|
|
||||||
|
|
||||||
|
@digest_action_api.route('/digest-action/<token_id>', methods=['POST'])
|
||||||
|
def execute_digest_action(token_id: str):
|
||||||
|
"""
|
||||||
|
Execute a digest action. Requires the user to be authenticated (JWT cookie).
|
||||||
|
Validates and consumes the token, then performs the approve/deny action.
|
||||||
|
"""
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
token = validate_and_consume_token(token_id)
|
||||||
|
if not token:
|
||||||
|
return jsonify({'error': 'This link is invalid or has expired.', 'code': 'INVALID_TOKEN'}), 400
|
||||||
|
|
||||||
|
if token.user_id != user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 403
|
||||||
|
|
||||||
|
try:
|
||||||
|
if token.entity_type == 'chore' and token.action == 'approve':
|
||||||
|
approve_chore(user_id, token.child_id, token.entity_id)
|
||||||
|
elif token.entity_type == 'chore' and token.action == 'deny':
|
||||||
|
reject_chore(user_id, token.child_id, token.entity_id)
|
||||||
|
elif token.entity_type == 'reward' and token.action == 'approve':
|
||||||
|
approve_reward_request(user_id, token.child_id, token.entity_id)
|
||||||
|
elif token.entity_type == 'reward' and token.action == 'deny':
|
||||||
|
deny_reward(user_id, token.child_id, token.entity_id)
|
||||||
|
elif token.entity_type == 'routine' and token.action == 'approve':
|
||||||
|
approve_routine(user_id, token.child_id, token.entity_id)
|
||||||
|
elif token.entity_type == 'routine' and token.action == 'deny':
|
||||||
|
reject_routine(user_id, token.child_id, token.entity_id)
|
||||||
|
else:
|
||||||
|
return jsonify({'error': 'Unknown action', 'code': 'INVALID_ACTION'}), 400
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f'Error executing digest action {token.action}/{token.entity_type}: {e}')
|
||||||
|
return jsonify({'error': 'Failed to execute action', 'code': 'ACTION_FAILED'}), 400
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'success': True,
|
||||||
|
'child_id': token.child_id,
|
||||||
|
'entity_id': token.entity_id,
|
||||||
|
'entity_type': token.entity_type,
|
||||||
|
'action': token.action,
|
||||||
|
}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@digest_action_api.route('/digest-unsubscribe/<token>', methods=['GET'])
|
||||||
|
def handle_digest_unsubscribe(token: str):
|
||||||
|
user_id = validate_unsubscribe_token(token)
|
||||||
|
if not user_id:
|
||||||
|
return make_response(_UNSUB_ERROR_HTML, 400)
|
||||||
|
|
||||||
|
UserQ = Query()
|
||||||
|
users_db.update({'email_digest_enabled': False}, UserQ.id == user_id)
|
||||||
|
logger.info(f'User {user_id} unsubscribed from digest via email link')
|
||||||
|
return make_response(_UNSUB_HTML, 200)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -35,3 +35,4 @@ class ErrorCodes:
|
|||||||
PENDING_NOT_FOUND = "PENDING_NOT_FOUND"
|
PENDING_NOT_FOUND = "PENDING_NOT_FOUND"
|
||||||
INSUFFICIENT_POINTS = "INSUFFICIENT_POINTS"
|
INSUFFICIENT_POINTS = "INSUFFICIENT_POINTS"
|
||||||
INVALID_TASK_TYPE = "INVALID_TASK_TYPE"
|
INVALID_TASK_TYPE = "INVALID_TASK_TYPE"
|
||||||
|
DUPLICATE_REWARD_REQUEST = "DUPLICATE_REWARD_REQUEST"
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from api.utils import get_validated_user_id
|
||||||
|
from db.push_subscriptions import upsert_subscription, delete_by_endpoint
|
||||||
|
from db.db import users_db
|
||||||
|
|
||||||
|
push_subscription_api = Blueprint('push_subscription_api', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@push_subscription_api.route('/push-vapid-key', methods=['GET'])
|
||||||
|
def get_vapid_public_key():
|
||||||
|
"""Return the VAPID public key for the frontend to use when subscribing."""
|
||||||
|
public_key = current_app.config.get('VAPID_PUBLIC_KEY', '')
|
||||||
|
return jsonify({'public_key': public_key}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@push_subscription_api.route('/push-subscription', methods=['POST'])
|
||||||
|
def subscribe():
|
||||||
|
"""Upsert a push subscription for the current user."""
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
endpoint = data.get('endpoint')
|
||||||
|
keys = data.get('keys')
|
||||||
|
timezone_str = data.get('timezone')
|
||||||
|
|
||||||
|
if not endpoint or not isinstance(keys, dict):
|
||||||
|
return jsonify({'error': 'endpoint and keys are required', 'code': 'MISSING_FIELDS'}), 400
|
||||||
|
if 'p256dh' not in keys or 'auth' not in keys:
|
||||||
|
return jsonify({'error': 'keys must contain p256dh and auth', 'code': 'MISSING_FIELDS'}), 400
|
||||||
|
|
||||||
|
sub = upsert_subscription(user_id=user_id, endpoint=endpoint, keys=keys)
|
||||||
|
|
||||||
|
# Update user timezone if provided
|
||||||
|
if timezone_str:
|
||||||
|
UserQ = Query()
|
||||||
|
users_db.update({'timezone': timezone_str}, UserQ.id == user_id)
|
||||||
|
|
||||||
|
return jsonify({'message': 'Subscription saved', 'id': sub.id}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@push_subscription_api.route('/push-subscription', methods=['DELETE'])
|
||||||
|
def unsubscribe():
|
||||||
|
"""Remove a push subscription for the current user by endpoint."""
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
endpoint = data.get('endpoint')
|
||||||
|
|
||||||
|
if not endpoint:
|
||||||
|
return jsonify({'error': 'endpoint is required', 'code': 'MISSING_FIELDS'}), 400
|
||||||
|
|
||||||
|
removed = delete_by_endpoint(user_id=user_id, endpoint=endpoint)
|
||||||
|
return jsonify({'message': 'Subscription removed', 'removed': removed}), 200
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from api.utils import send_event_for_current_user, get_validated_user_id
|
||||||
|
from db.db import routine_db, child_db, pending_confirmations_db
|
||||||
|
from db.child_overrides import delete_overrides_for_entity
|
||||||
|
from db.routine_items import delete_for_routine
|
||||||
|
from db.routine_schedules import delete_schedules_for_routine
|
||||||
|
from db.routine_extensions import delete_extensions_for_routine
|
||||||
|
from events.types.event import Event
|
||||||
|
from events.types.event_types import EventType
|
||||||
|
from events.types.routine_modified import RoutineModified
|
||||||
|
from events.types.child_routines_set import ChildRoutinesSet
|
||||||
|
from models.routine import Routine
|
||||||
|
|
||||||
|
routine_api = Blueprint('routine_api', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
@routine_api.route('/routine/add', methods=['PUT'])
|
||||||
|
def add_routine():
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
name = data.get('name')
|
||||||
|
points = data.get('points')
|
||||||
|
image = data.get('image_id', '')
|
||||||
|
|
||||||
|
if not name or points is None:
|
||||||
|
return jsonify({'error': 'Name and points are required'}), 400
|
||||||
|
|
||||||
|
routine = Routine(name=name, points=points, image_id=image, user_id=user_id)
|
||||||
|
routine_db.insert(routine.to_dict())
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine.id, RoutineModified.OPERATION_ADD))
|
||||||
|
)
|
||||||
|
return jsonify({'message': f'Routine {name} added.', 'routine': routine.to_dict()}), 201
|
||||||
|
|
||||||
|
|
||||||
|
@routine_api.route('/routine/<id>', methods=['GET'])
|
||||||
|
def get_routine(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
q = Query()
|
||||||
|
result = routine_db.search((q.id == id) & ((q.user_id == user_id) | (q.user_id == None)))
|
||||||
|
if not result:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
return jsonify(result[0]), 200
|
||||||
|
|
||||||
|
|
||||||
|
@routine_api.route('/routine/list', methods=['GET'])
|
||||||
|
def list_routines():
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
ids_param = request.args.get('ids')
|
||||||
|
q = Query()
|
||||||
|
routines = routine_db.search((q.user_id == user_id) | (q.user_id == None))
|
||||||
|
|
||||||
|
if ids_param is not None:
|
||||||
|
if ids_param.strip() == '':
|
||||||
|
routines = []
|
||||||
|
else:
|
||||||
|
ids = set(ids_param.split(','))
|
||||||
|
routines = [routine for routine in routines if routine.get('id') in ids]
|
||||||
|
|
||||||
|
user_routines = {r['name'].strip().lower(): r for r in routines if r.get('user_id') == user_id}
|
||||||
|
filtered_routines = []
|
||||||
|
for routine in routines:
|
||||||
|
if routine.get('user_id') is None and routine['name'].strip().lower() in user_routines:
|
||||||
|
continue
|
||||||
|
filtered_routines.append(routine)
|
||||||
|
|
||||||
|
user_created = sorted(
|
||||||
|
[r for r in filtered_routines if r.get('user_id') == user_id],
|
||||||
|
key=lambda x: x['name'].lower(),
|
||||||
|
)
|
||||||
|
default_items = sorted(
|
||||||
|
[r for r in filtered_routines if r.get('user_id') is None],
|
||||||
|
key=lambda x: x['name'].lower(),
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify({'routines': user_created + default_items}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@routine_api.route('/routine/<id>/edit', methods=['PUT'])
|
||||||
|
def edit_routine(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
q = Query()
|
||||||
|
existing = routine_db.get((q.id == id) & ((q.user_id == user_id) | (q.user_id == None)))
|
||||||
|
if not existing:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
routine = Routine.from_dict(existing)
|
||||||
|
data = request.get_json(force=True) or {}
|
||||||
|
is_dirty = False
|
||||||
|
|
||||||
|
if 'name' in data:
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({'error': 'Name cannot be empty'}), 400
|
||||||
|
routine.name = name
|
||||||
|
is_dirty = True
|
||||||
|
|
||||||
|
if 'points' in data:
|
||||||
|
points = data.get('points')
|
||||||
|
if not isinstance(points, int) or points <= 0:
|
||||||
|
return jsonify({'error': 'Points must be a positive integer'}), 400
|
||||||
|
routine.points = points
|
||||||
|
is_dirty = True
|
||||||
|
|
||||||
|
if 'image_id' in data:
|
||||||
|
routine.image_id = data.get('image_id', '')
|
||||||
|
is_dirty = True
|
||||||
|
|
||||||
|
if not is_dirty:
|
||||||
|
return jsonify({'error': 'No valid fields to update'}), 400
|
||||||
|
|
||||||
|
if routine.user_id is None:
|
||||||
|
new_routine = Routine(name=routine.name, points=routine.points, image_id=routine.image_id, user_id=user_id)
|
||||||
|
routine_db.insert(new_routine.to_dict())
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(new_routine.id, RoutineModified.OPERATION_ADD))
|
||||||
|
)
|
||||||
|
return jsonify(new_routine.to_dict()), 200
|
||||||
|
|
||||||
|
routine_db.update(routine.to_dict(), q.id == id)
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(id, RoutineModified.OPERATION_EDIT))
|
||||||
|
)
|
||||||
|
return jsonify(routine.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
@routine_api.route('/routine/<id>', methods=['DELETE'])
|
||||||
|
def delete_routine(id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
q = Query()
|
||||||
|
routine = routine_db.get(q.id == id)
|
||||||
|
if not routine:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
if routine.get('user_id') is None:
|
||||||
|
return jsonify({'error': 'System routines cannot be deleted.'}), 403
|
||||||
|
|
||||||
|
removed = routine_db.remove((q.id == id) & (q.user_id == user_id))
|
||||||
|
if not removed:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
delete_overrides_for_entity(id)
|
||||||
|
delete_for_routine(id)
|
||||||
|
delete_schedules_for_routine(id)
|
||||||
|
delete_extensions_for_routine(id)
|
||||||
|
|
||||||
|
pending_q = Query()
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(pending_q.entity_id == id) & (pending_q.entity_type == 'routine') & (pending_q.user_id == user_id)
|
||||||
|
)
|
||||||
|
|
||||||
|
child_q = Query()
|
||||||
|
children = child_db.search(child_q.user_id == user_id)
|
||||||
|
for child in children:
|
||||||
|
routine_ids = child.get('routines', [])
|
||||||
|
if id in routine_ids:
|
||||||
|
routine_ids = [rid for rid in routine_ids if rid != id]
|
||||||
|
child_db.update({'routines': routine_ids}, child_q.id == child.get('id'))
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.CHILD_ROUTINES_SET.value, ChildRoutinesSet(child.get('id'), routine_ids))
|
||||||
|
)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(id, RoutineModified.OPERATION_DELETE))
|
||||||
|
)
|
||||||
|
return jsonify({'message': f'Routine {id} deleted.'}), 200
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from api.utils import get_validated_user_id, send_event_for_current_user
|
||||||
|
from db.db import routine_db
|
||||||
|
from db.routine_items import add_item, delete_item, get_item, get_items_for_routine, update_item
|
||||||
|
from events.types.event import Event
|
||||||
|
from events.types.event_types import EventType
|
||||||
|
from events.types.routine_modified import RoutineModified
|
||||||
|
from models.routine_item import RoutineItem
|
||||||
|
|
||||||
|
routine_item_api = Blueprint('routine_item_api', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_routine_owned_by_user(routine_id: str, user_id: str):
|
||||||
|
q = Query()
|
||||||
|
return routine_db.get((q.id == routine_id) & ((q.user_id == user_id) | (q.user_id == None)))
|
||||||
|
|
||||||
|
|
||||||
|
@routine_item_api.route('/routine/<routine_id>/item/add', methods=['PUT'])
|
||||||
|
def add_routine_item(routine_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
routine = _validate_routine_owned_by_user(routine_id, user_id)
|
||||||
|
if not routine:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
image_id = data.get('image_id')
|
||||||
|
|
||||||
|
if not name:
|
||||||
|
return jsonify({'error': 'name is required'}), 400
|
||||||
|
|
||||||
|
existing_items = get_items_for_routine(routine_id)
|
||||||
|
order = data.get('order', len(existing_items))
|
||||||
|
|
||||||
|
item = RoutineItem(routine_id=routine_id, name=name, image_id=image_id, order=order)
|
||||||
|
add_item(item)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine_id, RoutineModified.OPERATION_EDIT))
|
||||||
|
)
|
||||||
|
return jsonify(item.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
@routine_item_api.route('/routine/<routine_id>/item/<item_id>/edit', methods=['PUT'])
|
||||||
|
def edit_routine_item(routine_id, item_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
routine = _validate_routine_owned_by_user(routine_id, user_id)
|
||||||
|
if not routine:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
existing = get_item(item_id)
|
||||||
|
if not existing or existing.routine_id != routine_id:
|
||||||
|
return jsonify({'error': 'Item not found'}), 404
|
||||||
|
|
||||||
|
data = request.get_json(force=True) or {}
|
||||||
|
is_dirty = False
|
||||||
|
|
||||||
|
if 'name' in data:
|
||||||
|
name = data.get('name', '').strip()
|
||||||
|
if not name:
|
||||||
|
return jsonify({'error': 'name cannot be empty'}), 400
|
||||||
|
existing.name = name
|
||||||
|
is_dirty = True
|
||||||
|
|
||||||
|
if 'image_id' in data:
|
||||||
|
existing.image_id = data.get('image_id')
|
||||||
|
is_dirty = True
|
||||||
|
|
||||||
|
if 'order' in data:
|
||||||
|
order = data.get('order')
|
||||||
|
if not isinstance(order, int) or order < 0:
|
||||||
|
return jsonify({'error': 'order must be a non-negative integer'}), 400
|
||||||
|
existing.order = order
|
||||||
|
is_dirty = True
|
||||||
|
|
||||||
|
if not is_dirty:
|
||||||
|
return jsonify({'error': 'No valid fields to update'}), 400
|
||||||
|
|
||||||
|
update_item(existing)
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine_id, RoutineModified.OPERATION_EDIT))
|
||||||
|
)
|
||||||
|
return jsonify(existing.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
@routine_item_api.route('/routine/<routine_id>/item/<item_id>', methods=['DELETE'])
|
||||||
|
def delete_routine_item(routine_id, item_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
routine = _validate_routine_owned_by_user(routine_id, user_id)
|
||||||
|
if not routine:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
item = get_item(item_id)
|
||||||
|
if not item or item.routine_id != routine_id:
|
||||||
|
return jsonify({'error': 'Item not found'}), 404
|
||||||
|
|
||||||
|
delete_item(item_id)
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(EventType.ROUTINE_MODIFIED.value, RoutineModified(routine_id, RoutineModified.OPERATION_EDIT))
|
||||||
|
)
|
||||||
|
return jsonify({'message': 'Item deleted'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@routine_item_api.route('/routine/<routine_id>/items', methods=['GET'])
|
||||||
|
def list_routine_items(routine_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
|
||||||
|
routine = _validate_routine_owned_by_user(routine_id, user_id)
|
||||||
|
if not routine:
|
||||||
|
return jsonify({'error': 'Routine not found'}), 404
|
||||||
|
|
||||||
|
items = [item.to_dict() for item in get_items_for_routine(routine_id)]
|
||||||
|
return jsonify({'items': items, 'count': len(items)}), 200
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
from flask import Blueprint, request, jsonify
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from api.error_codes import ErrorCodes
|
||||||
|
from api.utils import get_validated_user_id, send_event_for_current_user
|
||||||
|
from db.db import child_db, pending_confirmations_db
|
||||||
|
from db.routine_extensions import add_extension, delete_extension_for_child_routine, get_extension
|
||||||
|
from db.routine_schedules import delete_schedule, get_schedule, upsert_schedule
|
||||||
|
from events.types.child_routine_confirmation import ChildRoutineConfirmation
|
||||||
|
from events.types.event import Event
|
||||||
|
from events.types.event_types import EventType
|
||||||
|
from events.types.routine_schedule_modified import RoutineScheduleModified
|
||||||
|
from events.types.routine_time_extended import RoutineTimeExtended
|
||||||
|
from models.routine_extension import RoutineExtension
|
||||||
|
from models.routine_schedule import RoutineSchedule
|
||||||
|
|
||||||
|
routine_schedule_api = Blueprint('routine_schedule_api', __name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_child(child_id: str, user_id: str):
|
||||||
|
q = Query()
|
||||||
|
result = child_db.search((q.id == child_id) & (q.user_id == user_id))
|
||||||
|
return result[0] if result else None
|
||||||
|
|
||||||
|
|
||||||
|
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/schedule', methods=['GET'])
|
||||||
|
def get_routine_schedule(child_id, routine_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
if not _validate_child(child_id, user_id):
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
schedule = get_schedule(child_id, routine_id)
|
||||||
|
if not schedule:
|
||||||
|
return jsonify({'error': 'Schedule not found'}), 404
|
||||||
|
|
||||||
|
return jsonify(schedule.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/schedule', methods=['PUT'])
|
||||||
|
def set_routine_schedule(child_id, routine_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
if not _validate_child(child_id, user_id):
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
mode = data.get('mode')
|
||||||
|
if mode not in ('days', 'interval'):
|
||||||
|
return jsonify({'error': 'mode must be "days" or "interval"', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||||
|
|
||||||
|
enabled = data.get('enabled', True)
|
||||||
|
if not isinstance(enabled, bool):
|
||||||
|
return jsonify({'error': 'enabled must be a boolean', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||||
|
|
||||||
|
if mode == 'days':
|
||||||
|
day_configs = data.get('day_configs', [])
|
||||||
|
if not isinstance(day_configs, list):
|
||||||
|
return jsonify({'error': 'day_configs must be a list', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||||
|
|
||||||
|
schedule = RoutineSchedule(
|
||||||
|
child_id=child_id,
|
||||||
|
routine_id=routine_id,
|
||||||
|
mode='days',
|
||||||
|
day_configs=day_configs,
|
||||||
|
default_hour=data.get('default_hour', 8),
|
||||||
|
default_minute=data.get('default_minute', 0),
|
||||||
|
default_has_deadline=data.get('default_has_deadline', True),
|
||||||
|
enabled=enabled,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
interval_days = data.get('interval_days', 2)
|
||||||
|
anchor_date = data.get('anchor_date', '')
|
||||||
|
interval_has_deadline = data.get('interval_has_deadline', True)
|
||||||
|
interval_hour = data.get('interval_hour', 0)
|
||||||
|
interval_minute = data.get('interval_minute', 0)
|
||||||
|
|
||||||
|
if not isinstance(interval_days, int) or not (1 <= interval_days <= 7):
|
||||||
|
return jsonify({'error': 'interval_days must be an integer between 1 and 7', 'code': ErrorCodes.INVALID_VALUE}), 400
|
||||||
|
|
||||||
|
schedule = RoutineSchedule(
|
||||||
|
child_id=child_id,
|
||||||
|
routine_id=routine_id,
|
||||||
|
mode='interval',
|
||||||
|
interval_days=interval_days,
|
||||||
|
anchor_date=anchor_date,
|
||||||
|
interval_has_deadline=interval_has_deadline,
|
||||||
|
interval_hour=interval_hour,
|
||||||
|
interval_minute=interval_minute,
|
||||||
|
enabled=enabled,
|
||||||
|
)
|
||||||
|
|
||||||
|
delete_extension_for_child_routine(child_id, routine_id)
|
||||||
|
upsert_schedule(schedule)
|
||||||
|
|
||||||
|
pending_q = Query()
|
||||||
|
pending_routines = pending_confirmations_db.search(
|
||||||
|
(pending_q.child_id == child_id) & (pending_q.entity_id == routine_id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending')
|
||||||
|
)
|
||||||
|
for _ in pending_routines:
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(pending_q.child_id == child_id) & (pending_q.entity_id == routine_id) &
|
||||||
|
(pending_q.entity_type == 'routine') & (pending_q.status == 'pending')
|
||||||
|
)
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.CHILD_ROUTINE_CONFIRMATION.value,
|
||||||
|
ChildRoutineConfirmation(child_id, routine_id, ChildRoutineConfirmation.OPERATION_RESET)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.ROUTINE_SCHEDULE_MODIFIED.value,
|
||||||
|
RoutineScheduleModified(child_id, routine_id, RoutineScheduleModified.OPERATION_SET)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(schedule.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/schedule', methods=['DELETE'])
|
||||||
|
def delete_routine_schedule(child_id, routine_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
if not _validate_child(child_id, user_id):
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
removed = delete_schedule(child_id, routine_id)
|
||||||
|
if not removed:
|
||||||
|
return jsonify({'error': 'Schedule not found'}), 404
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.ROUTINE_SCHEDULE_MODIFIED.value,
|
||||||
|
RoutineScheduleModified(child_id, routine_id, RoutineScheduleModified.OPERATION_DELETED)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return jsonify({'message': 'Schedule deleted'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@routine_schedule_api.route('/child/<child_id>/routine/<routine_id>/extend', methods=['POST'])
|
||||||
|
def extend_routine_time(child_id, routine_id):
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': ErrorCodes.UNAUTHORIZED}), 401
|
||||||
|
|
||||||
|
if not _validate_child(child_id, user_id):
|
||||||
|
return jsonify({'error': 'Child not found', 'code': ErrorCodes.CHILD_NOT_FOUND}), 404
|
||||||
|
|
||||||
|
data = request.get_json() or {}
|
||||||
|
date = data.get('date')
|
||||||
|
if not date or not isinstance(date, str):
|
||||||
|
return jsonify({'error': 'date is required (ISO date string)', 'code': ErrorCodes.MISSING_FIELD}), 400
|
||||||
|
|
||||||
|
existing = get_extension(child_id, routine_id, date)
|
||||||
|
if existing:
|
||||||
|
return jsonify({'error': 'Routine already extended for this date', 'code': 'ALREADY_EXTENDED'}), 409
|
||||||
|
|
||||||
|
delete_extension_for_child_routine(child_id, routine_id)
|
||||||
|
extension = RoutineExtension(child_id=child_id, routine_id=routine_id, date=date)
|
||||||
|
add_extension(extension)
|
||||||
|
|
||||||
|
send_event_for_current_user(
|
||||||
|
Event(
|
||||||
|
EventType.ROUTINE_TIME_EXTENDED.value,
|
||||||
|
RoutineTimeExtended(child_id, routine_id)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(extension.to_dict()), 200
|
||||||
+47
-2
@@ -46,7 +46,11 @@ def get_profile():
|
|||||||
'first_name': user.first_name,
|
'first_name': user.first_name,
|
||||||
'last_name': user.last_name,
|
'last_name': user.last_name,
|
||||||
'email': user.email,
|
'email': user.email,
|
||||||
'image_id': user.image_id
|
'image_id': user.image_id,
|
||||||
|
'email_digest_enabled': user.email_digest_enabled,
|
||||||
|
'push_notifications_enabled': user.push_notifications_enabled,
|
||||||
|
'tutorial_enabled': user.tutorial_enabled,
|
||||||
|
'tutorial_progress': user.tutorial_progress or {},
|
||||||
}), 200
|
}), 200
|
||||||
|
|
||||||
@user_api.route('/user/profile', methods=['PUT'])
|
@user_api.route('/user/profile', methods=['PUT'])
|
||||||
@@ -58,16 +62,22 @@ def update_profile():
|
|||||||
if not user:
|
if not user:
|
||||||
return jsonify({'error': 'Unauthorized'}), 401
|
return jsonify({'error': 'Unauthorized'}), 401
|
||||||
data = request.get_json()
|
data = request.get_json()
|
||||||
# Only allow first_name, last_name, image_id to be updated
|
# Only allow first_name, last_name, image_id, email_digest_enabled, push_notifications_enabled to be updated
|
||||||
first_name = data.get('first_name')
|
first_name = data.get('first_name')
|
||||||
last_name = data.get('last_name')
|
last_name = data.get('last_name')
|
||||||
image_id = data.get('image_id')
|
image_id = data.get('image_id')
|
||||||
|
email_digest_enabled = data.get('email_digest_enabled')
|
||||||
|
push_notifications_enabled = data.get('push_notifications_enabled')
|
||||||
if first_name is not None:
|
if first_name is not None:
|
||||||
user.first_name = first_name
|
user.first_name = first_name
|
||||||
if last_name is not None:
|
if last_name is not None:
|
||||||
user.last_name = last_name
|
user.last_name = last_name
|
||||||
if image_id is not None:
|
if image_id is not None:
|
||||||
user.image_id = image_id
|
user.image_id = image_id
|
||||||
|
if email_digest_enabled is not None:
|
||||||
|
user.email_digest_enabled = bool(email_digest_enabled)
|
||||||
|
if push_notifications_enabled is not None:
|
||||||
|
user.push_notifications_enabled = bool(push_notifications_enabled)
|
||||||
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||||
|
|
||||||
# Create tracking event
|
# Create tracking event
|
||||||
@@ -78,6 +88,10 @@ def update_profile():
|
|||||||
metadata['last_name_updated'] = True
|
metadata['last_name_updated'] = True
|
||||||
if image_id is not None:
|
if image_id is not None:
|
||||||
metadata['image_updated'] = True
|
metadata['image_updated'] = True
|
||||||
|
if email_digest_enabled is not None:
|
||||||
|
metadata['email_digest_enabled_updated'] = True
|
||||||
|
if push_notifications_enabled is not None:
|
||||||
|
metadata['push_notifications_enabled_updated'] = True
|
||||||
|
|
||||||
tracking_event = TrackingEvent.create_event(
|
tracking_event = TrackingEvent.create_event(
|
||||||
user_id=user_id,
|
user_id=user_id,
|
||||||
@@ -97,6 +111,37 @@ def update_profile():
|
|||||||
|
|
||||||
return jsonify({'message': 'Profile updated'}), 200
|
return jsonify({'message': 'Profile updated'}), 200
|
||||||
|
|
||||||
|
@user_api.route('/user/tutorial-progress', methods=['PATCH'])
|
||||||
|
def update_tutorial_progress():
|
||||||
|
user_id = get_validated_user_id()
|
||||||
|
if not user_id:
|
||||||
|
return jsonify({'error': 'Unauthorized', 'code': 'UNAUTHORIZED'}), 401
|
||||||
|
user = get_current_user()
|
||||||
|
if not user:
|
||||||
|
return jsonify({'error': 'Unauthorized'}), 401
|
||||||
|
data = request.get_json() or {}
|
||||||
|
|
||||||
|
if data.get('reset') is True:
|
||||||
|
user.tutorial_progress = {}
|
||||||
|
elif 'enabled' in data:
|
||||||
|
user.tutorial_enabled = bool(data.get('enabled'))
|
||||||
|
elif 'step_id' in data:
|
||||||
|
step_id = str(data.get('step_id') or '').strip()
|
||||||
|
if not step_id:
|
||||||
|
return jsonify({'error': 'Missing step_id'}), 400
|
||||||
|
progress = dict(user.tutorial_progress or {})
|
||||||
|
progress[step_id] = bool(data.get('seen', True))
|
||||||
|
user.tutorial_progress = progress
|
||||||
|
else:
|
||||||
|
return jsonify({'error': 'No-op'}), 400
|
||||||
|
|
||||||
|
users_db.update(user.to_dict(), UserQuery.email == user.email)
|
||||||
|
send_event_for_current_user(Event(EventType.PROFILE_UPDATED.value, ProfileUpdated(user.id)))
|
||||||
|
return jsonify({
|
||||||
|
'tutorial_enabled': user.tutorial_enabled,
|
||||||
|
'tutorial_progress': user.tutorial_progress,
|
||||||
|
}), 200
|
||||||
|
|
||||||
@user_api.route('/user/image', methods=['PUT'])
|
@user_api.route('/user/image', methods=['PUT'])
|
||||||
def update_image():
|
def update_image():
|
||||||
user_id = get_validated_user_id()
|
user_id = get_validated_user_id()
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# file: config/version.py
|
# file: config/version.py
|
||||||
import os
|
import os
|
||||||
|
|
||||||
BASE_VERSION = "1.0.9" # update manually when releasing features
|
BASE_VERSION = "1.0.16" # update manually when releasing features
|
||||||
|
|
||||||
def get_full_version() -> str:
|
def get_full_version() -> str:
|
||||||
"""
|
"""
|
||||||
|
|||||||
+505
-28
@@ -1,13 +1,42 @@
|
|||||||
# python
|
# python
|
||||||
import os
|
import os
|
||||||
from config.paths import get_database_dir
|
|
||||||
import threading
|
import threading
|
||||||
|
from config.paths import get_database_dir
|
||||||
from tinydb import TinyDB
|
from tinydb import TinyDB
|
||||||
|
from tinydb.queries import QueryInstance
|
||||||
|
|
||||||
|
from db.mongo_client import get_mongo_client, get_mongo_db_name
|
||||||
|
|
||||||
|
try:
|
||||||
|
from tinydb.table import Document
|
||||||
|
except ImportError: # pragma: no cover - tinydb version compatibility
|
||||||
|
from tinydb.database import Document
|
||||||
|
|
||||||
|
|
||||||
|
def _stable_clause_key(clause: dict) -> str:
|
||||||
|
"""Return a stable string key for sorting MongoDB filter clauses."""
|
||||||
|
import json
|
||||||
|
return json.dumps(clause, sort_keys=True, default=str)
|
||||||
|
|
||||||
|
|
||||||
|
try:
|
||||||
|
from pymongo import ASCENDING
|
||||||
|
except ImportError: # pragma: no cover - pymongo is a required dependency
|
||||||
|
ASCENDING = 1
|
||||||
|
|
||||||
|
|
||||||
|
USE_MONGODB = os.environ.get('USE_MONGODB', 'true').lower() == 'true'
|
||||||
|
# Resolve the MongoDB database name once at module load so runtime changes to
|
||||||
|
# DB_ENV/DATA_ENV in tests do not switch databases mid-process.
|
||||||
|
_mongo_db_name = get_mongo_db_name() if USE_MONGODB else None
|
||||||
base_dir = get_database_dir()
|
base_dir = get_database_dir()
|
||||||
os.makedirs(base_dir, exist_ok=True)
|
os.makedirs(base_dir, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TinyDB-backed table wrapper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
class LockedTable:
|
class LockedTable:
|
||||||
"""
|
"""
|
||||||
Thread-safe wrapper around a TinyDB table. All callable attribute access
|
Thread-safe wrapper around a TinyDB table. All callable attribute access
|
||||||
@@ -65,10 +94,369 @@ class LockedTable:
|
|||||||
with self._lock:
|
with self._lock:
|
||||||
return self._table.truncate()
|
return self._table.truncate()
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
with self._lock:
|
||||||
|
return self._table.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TinyDB Query -> MongoDB filter translator
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_MONGO_OP_MAP = {
|
||||||
|
'==': '$eq',
|
||||||
|
'!=': '$ne',
|
||||||
|
'<': '$lt',
|
||||||
|
'<=': '$lte',
|
||||||
|
'>': '$gt',
|
||||||
|
'>=': '$gte',
|
||||||
|
}
|
||||||
|
|
||||||
|
_NEGATED_OPS = {
|
||||||
|
'$eq': '$ne',
|
||||||
|
'$ne': '$eq',
|
||||||
|
'$lt': '$gte',
|
||||||
|
'$lte': '$gt',
|
||||||
|
'$gt': '$lte',
|
||||||
|
'$gte': '$lt',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _field_to_mongo(field_path: tuple) -> str:
|
||||||
|
"""Map a TinyDB field path to a MongoDB field name.
|
||||||
|
|
||||||
|
The model ``id`` field is stored as the MongoDB ``_id`` field, so queries
|
||||||
|
on ``id`` are translated to queries on ``_id``.
|
||||||
|
"""
|
||||||
|
if len(field_path) == 1:
|
||||||
|
return '_id' if field_path[0] == 'id' else field_path[0]
|
||||||
|
return '.'.join('_id' if p == 'id' else p for p in field_path)
|
||||||
|
|
||||||
|
|
||||||
|
def _negate_condition(cond: dict) -> dict | None:
|
||||||
|
"""Return a MongoDB condition that negates a single-field condition."""
|
||||||
|
if len(cond) != 1:
|
||||||
|
return None
|
||||||
|
field, inner = next(iter(cond.items()))
|
||||||
|
if not isinstance(inner, dict) or len(inner) != 1:
|
||||||
|
return None
|
||||||
|
op, value = next(iter(inner.items()))
|
||||||
|
if op in _NEGATED_OPS:
|
||||||
|
return {field: {_NEGATED_OPS[op]: value}}
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_to_mongo_filter(query_hash) -> dict | None:
|
||||||
|
"""Translate a TinyDB query hash tuple to a MongoDB filter document.
|
||||||
|
|
||||||
|
Returns ``None`` when the query construct cannot be expressed as a native
|
||||||
|
MongoDB filter, signalling that the caller should fall back to in-memory
|
||||||
|
TinyDB evaluation.
|
||||||
|
"""
|
||||||
|
if not isinstance(query_hash, tuple) or len(query_hash) == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
op = query_hash[0]
|
||||||
|
|
||||||
|
if op in _MONGO_OP_MAP:
|
||||||
|
field = _field_to_mongo(query_hash[1])
|
||||||
|
value = query_hash[2]
|
||||||
|
return {field: {_MONGO_OP_MAP[op]: value}}
|
||||||
|
|
||||||
|
if op == 'exists':
|
||||||
|
field = _field_to_mongo(query_hash[1])
|
||||||
|
return {field: {'$exists': True}}
|
||||||
|
|
||||||
|
if op == 'one_of':
|
||||||
|
field = _field_to_mongo(query_hash[1])
|
||||||
|
return {field: {'$in': list(query_hash[2])}}
|
||||||
|
|
||||||
|
if op == 'any':
|
||||||
|
field = _field_to_mongo(query_hash[1])
|
||||||
|
return {field: {'$in': list(query_hash[2])}}
|
||||||
|
|
||||||
|
if op == 'all':
|
||||||
|
field = _field_to_mongo(query_hash[1])
|
||||||
|
return {field: {'$all': list(query_hash[2])}}
|
||||||
|
|
||||||
|
if op == 'matches':
|
||||||
|
field = _field_to_mongo(query_hash[1])
|
||||||
|
# TinyDB matches() anchors the regex at the start of the string.
|
||||||
|
return {field: {'$regex': f'^{query_hash[2]}'}}
|
||||||
|
|
||||||
|
if op == 'search':
|
||||||
|
field = _field_to_mongo(query_hash[1])
|
||||||
|
return {field: {'$regex': query_hash[2]}}
|
||||||
|
|
||||||
|
if op == 'and':
|
||||||
|
merged: dict = {}
|
||||||
|
for sub_hash in query_hash[1]:
|
||||||
|
sub = _hash_to_mongo_filter(sub_hash)
|
||||||
|
if sub is None:
|
||||||
|
return None
|
||||||
|
for field, inner in sub.items():
|
||||||
|
if field in merged:
|
||||||
|
if isinstance(merged[field], dict) and isinstance(inner, dict):
|
||||||
|
merged[field].update(inner)
|
||||||
|
elif isinstance(merged[field], list) and isinstance(inner, list):
|
||||||
|
merged[field].extend(inner)
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
else:
|
||||||
|
merged[field] = (
|
||||||
|
dict(inner) if isinstance(inner, dict)
|
||||||
|
else list(inner) if isinstance(inner, list)
|
||||||
|
else inner
|
||||||
|
)
|
||||||
|
return merged
|
||||||
|
|
||||||
|
if op == 'or':
|
||||||
|
clauses = [_hash_to_mongo_filter(sub_hash) for sub_hash in query_hash[1]]
|
||||||
|
if any(c is None for c in clauses):
|
||||||
|
return None
|
||||||
|
return {'$or': sorted(clauses, key=_stable_clause_key)}
|
||||||
|
|
||||||
|
if op == 'not':
|
||||||
|
inner = _hash_to_mongo_filter(query_hash[1])
|
||||||
|
if inner is None:
|
||||||
|
return None
|
||||||
|
negated = _negate_condition(inner)
|
||||||
|
if negated is not None:
|
||||||
|
return negated
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Unsupported operation (test, fragment, noop, etc.) -> fall back.
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _query_to_mongo_filter(query) -> dict | None:
|
||||||
|
"""Translate a TinyDB QueryInstance to a MongoDB filter, if possible."""
|
||||||
|
if isinstance(query, dict):
|
||||||
|
return query
|
||||||
|
if isinstance(query, QueryInstance):
|
||||||
|
return _hash_to_mongo_filter(query._hash)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_in_memory(docs, query) -> list:
|
||||||
|
"""Evaluate a TinyDB query against an in-memory list of documents."""
|
||||||
|
if callable(query):
|
||||||
|
return [doc for doc in docs if query(doc)]
|
||||||
|
return docs
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# MongoDB-backed table wrapper
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class MongoLockedTable:
|
||||||
|
"""Drop-in replacement for ``LockedTable`` that delegates to MongoDB.
|
||||||
|
|
||||||
|
The adapter preserves the existing table API while mapping the model
|
||||||
|
``id`` field to MongoDB's ``_id`` field on reads and writes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, collection_name: str):
|
||||||
|
self.collection_name = collection_name
|
||||||
|
|
||||||
|
def _collection(self):
|
||||||
|
client = get_mongo_client()
|
||||||
|
return client[_mongo_db_name][self.collection_name]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _doc_to_mongo(doc: dict) -> dict:
|
||||||
|
"""Store a copy of ``doc`` with ``id`` promoted to MongoDB ``_id``.
|
||||||
|
|
||||||
|
This avoids storing both ``_id`` and ``id`` with identical values.
|
||||||
|
The original ``id`` field is removed from the stored document.
|
||||||
|
"""
|
||||||
|
if doc is None:
|
||||||
|
return None
|
||||||
|
d = dict(doc)
|
||||||
|
if 'id' in d:
|
||||||
|
d['_id'] = d.pop('id')
|
||||||
|
return d
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _doc_from_mongo(doc: dict):
|
||||||
|
"""Return a TinyDB-compatible Document with ``doc_id`` set to ``_id``.
|
||||||
|
|
||||||
|
Restores the model ``id`` field from MongoDB's ``_id`` and exposes
|
||||||
|
TinyDB's ``doc_id`` attribute so callers that rely on it continue to
|
||||||
|
work.
|
||||||
|
"""
|
||||||
|
if doc is None:
|
||||||
|
return None
|
||||||
|
d = dict(doc)
|
||||||
|
doc_id = d.pop('_id', None)
|
||||||
|
if doc_id is not None:
|
||||||
|
d['id'] = doc_id
|
||||||
|
return Document(d, doc_id=doc_id)
|
||||||
|
|
||||||
|
def _mongo_filter(self, cond):
|
||||||
|
"""Translate a TinyDB query or dict to a MongoDB filter."""
|
||||||
|
return _query_to_mongo_filter(cond)
|
||||||
|
|
||||||
|
def all(self):
|
||||||
|
return [self._doc_from_mongo(doc) for doc in self._collection().find({})]
|
||||||
|
|
||||||
|
def search(self, cond):
|
||||||
|
mongo_filter = self._mongo_filter(cond)
|
||||||
|
if mongo_filter is not None:
|
||||||
|
cursor = self._collection().find(mongo_filter)
|
||||||
|
return [self._doc_from_mongo(doc) for doc in cursor]
|
||||||
|
|
||||||
|
# Fallback: fetch all and evaluate the TinyDB query in Python.
|
||||||
|
docs = list(self._collection().find({}))
|
||||||
|
matched = _evaluate_in_memory(
|
||||||
|
[self._doc_from_mongo(doc) for doc in docs], cond
|
||||||
|
)
|
||||||
|
return matched
|
||||||
|
|
||||||
|
def get(self, cond):
|
||||||
|
mongo_filter = self._mongo_filter(cond)
|
||||||
|
if mongo_filter is not None:
|
||||||
|
doc = self._collection().find_one(mongo_filter)
|
||||||
|
return self._doc_from_mongo(doc)
|
||||||
|
|
||||||
|
docs = list(self._collection().find({}))
|
||||||
|
for doc in docs:
|
||||||
|
d = self._doc_from_mongo(doc)
|
||||||
|
if callable(cond) and cond(d):
|
||||||
|
return d
|
||||||
|
return None
|
||||||
|
|
||||||
|
def insert(self, document: dict):
|
||||||
|
doc = self._doc_to_mongo(document)
|
||||||
|
result = self._collection().insert_one(doc)
|
||||||
|
return str(result.inserted_id)
|
||||||
|
|
||||||
|
def insert_multiple(self, documents: list):
|
||||||
|
if not documents:
|
||||||
|
return []
|
||||||
|
docs = [self._doc_to_mongo(d) for d in documents]
|
||||||
|
result = self._collection().insert_many(docs)
|
||||||
|
return [str(iid) for iid in result.inserted_ids]
|
||||||
|
|
||||||
|
def update(self, fields, cond=None, doc_ids=None):
|
||||||
|
is_callable = callable(fields)
|
||||||
|
|
||||||
|
if doc_ids is not None:
|
||||||
|
mongo_filter = {'_id': {'$in': list(doc_ids)}}
|
||||||
|
target_ids = [str(did) for did in doc_ids]
|
||||||
|
if not target_ids:
|
||||||
|
return []
|
||||||
|
|
||||||
|
if is_callable:
|
||||||
|
# Fetch, apply callable in-memory, and replace each document.
|
||||||
|
updated_ids = []
|
||||||
|
for doc in self._collection().find(mongo_filter):
|
||||||
|
d = self._doc_from_mongo(doc)
|
||||||
|
fields(d)
|
||||||
|
new_doc = self._doc_to_mongo(d)
|
||||||
|
new_doc.pop('_id', None)
|
||||||
|
self._collection().update_one(
|
||||||
|
{'_id': doc['_id']}, {'$set': new_doc}
|
||||||
|
)
|
||||||
|
updated_ids.append(str(doc['_id']))
|
||||||
|
return updated_ids
|
||||||
|
|
||||||
|
update_doc = self._doc_to_mongo(fields) or {}
|
||||||
|
update_doc.pop('_id', None)
|
||||||
|
update_doc.pop('id', None)
|
||||||
|
if update_doc:
|
||||||
|
self._collection().update_many(mongo_filter, {'$set': update_doc})
|
||||||
|
return target_ids
|
||||||
|
|
||||||
|
mongo_filter = self._mongo_filter(cond)
|
||||||
|
if mongo_filter is not None and not is_callable:
|
||||||
|
update_doc = self._doc_to_mongo(fields) or {}
|
||||||
|
update_doc.pop('_id', None)
|
||||||
|
update_doc.pop('id', None)
|
||||||
|
target_ids = [
|
||||||
|
str(doc['_id'])
|
||||||
|
for doc in self._collection().find(mongo_filter, {'_id': 1})
|
||||||
|
]
|
||||||
|
if target_ids and update_doc:
|
||||||
|
self._collection().update_many(
|
||||||
|
mongo_filter, {'$set': update_doc}
|
||||||
|
)
|
||||||
|
return target_ids
|
||||||
|
|
||||||
|
# Fallback: evaluate the query in-memory and update one at a time.
|
||||||
|
docs = list(self._collection().find({}))
|
||||||
|
updated_ids = []
|
||||||
|
for doc in docs:
|
||||||
|
d = self._doc_from_mongo(doc)
|
||||||
|
match = cond(d) if callable(cond) else (mongo_filter is not None)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
if is_callable:
|
||||||
|
fields(d)
|
||||||
|
new_doc = self._doc_to_mongo(d)
|
||||||
|
new_doc.pop('_id', None)
|
||||||
|
self._collection().update_one(
|
||||||
|
{'_id': doc['_id']}, {'$set': new_doc}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
update_doc = self._doc_to_mongo(fields) or {}
|
||||||
|
update_doc.pop('_id', None)
|
||||||
|
update_doc.pop('id', None)
|
||||||
|
if update_doc:
|
||||||
|
self._collection().update_one(
|
||||||
|
{'_id': doc['_id']}, {'$set': update_doc}
|
||||||
|
)
|
||||||
|
updated_ids.append(str(doc['_id']))
|
||||||
|
return updated_ids
|
||||||
|
|
||||||
|
def remove(self, cond):
|
||||||
|
mongo_filter = self._mongo_filter(cond)
|
||||||
|
if mongo_filter is not None:
|
||||||
|
target_ids = [
|
||||||
|
str(doc['_id'])
|
||||||
|
for doc in self._collection().find(mongo_filter, {'_id': 1})
|
||||||
|
]
|
||||||
|
if target_ids:
|
||||||
|
self._collection().delete_many(mongo_filter)
|
||||||
|
return target_ids
|
||||||
|
|
||||||
|
# Fallback: evaluate the query in-memory and delete one at a time.
|
||||||
|
docs = list(self._collection().find({}))
|
||||||
|
removed_ids = []
|
||||||
|
for doc in docs:
|
||||||
|
d = self._doc_from_mongo(doc)
|
||||||
|
if callable(cond) and cond(d):
|
||||||
|
self._collection().delete_one({'_id': doc['_id']})
|
||||||
|
removed_ids.append(str(doc['_id']))
|
||||||
|
return removed_ids
|
||||||
|
|
||||||
|
def truncate(self):
|
||||||
|
self._collection().delete_many({})
|
||||||
|
|
||||||
|
def close(self):
|
||||||
|
# MongoDB clients are shared and long-lived; nothing to close here.
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Collection factory
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_table(json_path: str, collection_name: str):
|
||||||
|
if USE_MONGODB:
|
||||||
|
return MongoLockedTable(collection_name)
|
||||||
|
db = TinyDB(json_path, indent=2)
|
||||||
|
return LockedTable(db)
|
||||||
|
|
||||||
|
|
||||||
# Setup DB files next to this module
|
# Setup DB files next to this module
|
||||||
|
|
||||||
child_path = os.path.join(base_dir, 'children.json')
|
child_path = os.path.join(base_dir, 'children.json')
|
||||||
task_path = os.path.join(base_dir, 'tasks.json')
|
task_path = os.path.join(base_dir, 'tasks.json')
|
||||||
|
routine_path = os.path.join(base_dir, 'routines.json')
|
||||||
|
routine_items_path = os.path.join(base_dir, 'routine_items.json')
|
||||||
|
routine_schedules_path = os.path.join(base_dir, 'routine_schedules.json')
|
||||||
|
routine_extensions_path = os.path.join(base_dir, 'routine_extensions.json')
|
||||||
reward_path = os.path.join(base_dir, 'rewards.json')
|
reward_path = os.path.join(base_dir, 'rewards.json')
|
||||||
image_path = os.path.join(base_dir, 'images.json')
|
image_path = os.path.join(base_dir, 'images.json')
|
||||||
pending_reward_path = os.path.join(base_dir, 'pending_rewards.json')
|
pending_reward_path = os.path.join(base_dir, 'pending_rewards.json')
|
||||||
@@ -79,38 +467,126 @@ child_overrides_path = os.path.join(base_dir, 'child_overrides.json')
|
|||||||
chore_schedules_path = os.path.join(base_dir, 'chore_schedules.json')
|
chore_schedules_path = os.path.join(base_dir, 'chore_schedules.json')
|
||||||
task_extensions_path = os.path.join(base_dir, 'task_extensions.json')
|
task_extensions_path = os.path.join(base_dir, 'task_extensions.json')
|
||||||
refresh_tokens_path = os.path.join(base_dir, 'refresh_tokens.json')
|
refresh_tokens_path = os.path.join(base_dir, 'refresh_tokens.json')
|
||||||
|
push_subscriptions_path = os.path.join(base_dir, 'push_subscriptions.json')
|
||||||
|
digest_action_tokens_path = os.path.join(base_dir, 'digest_action_tokens.json')
|
||||||
|
|
||||||
# Use separate TinyDB instances/files for each collection
|
# Expose table objects backed by TinyDB or MongoDB based on USE_MONGODB
|
||||||
_child_db = TinyDB(child_path, indent=2)
|
child_db = _make_table(child_path, 'children')
|
||||||
_task_db = TinyDB(task_path, indent=2)
|
task_db = _make_table(task_path, 'tasks')
|
||||||
_reward_db = TinyDB(reward_path, indent=2)
|
routine_db = _make_table(routine_path, 'routines')
|
||||||
_image_db = TinyDB(image_path, indent=2)
|
routine_items_db = _make_table(routine_items_path, 'routine_items')
|
||||||
_pending_rewards_db = TinyDB(pending_reward_path, indent=2)
|
routine_schedules_db = _make_table(routine_schedules_path, 'routine_schedules')
|
||||||
_pending_confirmations_db = TinyDB(pending_confirmations_path, indent=2)
|
routine_extensions_db = _make_table(routine_extensions_path, 'routine_extensions')
|
||||||
_users_db = TinyDB(users_path, indent=2)
|
reward_db = _make_table(reward_path, 'rewards')
|
||||||
_tracking_events_db = TinyDB(tracking_events_path, indent=2)
|
image_db = _make_table(image_path, 'images')
|
||||||
_child_overrides_db = TinyDB(child_overrides_path, indent=2)
|
pending_reward_db = _make_table(pending_reward_path, 'pending_rewards')
|
||||||
_chore_schedules_db = TinyDB(chore_schedules_path, indent=2)
|
pending_confirmations_db = _make_table(pending_confirmations_path, 'pending_confirmations')
|
||||||
_task_extensions_db = TinyDB(task_extensions_path, indent=2)
|
users_db = _make_table(users_path, 'users')
|
||||||
_refresh_tokens_db = TinyDB(refresh_tokens_path, indent=2)
|
tracking_events_db = _make_table(tracking_events_path, 'tracking_events')
|
||||||
|
child_overrides_db = _make_table(child_overrides_path, 'child_overrides')
|
||||||
|
chore_schedules_db = _make_table(chore_schedules_path, 'chore_schedules')
|
||||||
|
task_extensions_db = _make_table(task_extensions_path, 'task_extensions')
|
||||||
|
refresh_tokens_db = _make_table(refresh_tokens_path, 'refresh_tokens')
|
||||||
|
push_subscriptions_db = _make_table(push_subscriptions_path, 'push_subscriptions')
|
||||||
|
digest_action_tokens_db = _make_table(digest_action_tokens_path, 'digest_action_tokens')
|
||||||
|
|
||||||
# Expose table objects wrapped with locking
|
|
||||||
child_db = LockedTable(_child_db)
|
|
||||||
task_db = LockedTable(_task_db)
|
|
||||||
reward_db = LockedTable(_reward_db)
|
|
||||||
image_db = LockedTable(_image_db)
|
|
||||||
pending_reward_db = LockedTable(_pending_rewards_db)
|
|
||||||
pending_confirmations_db = LockedTable(_pending_confirmations_db)
|
|
||||||
users_db = LockedTable(_users_db)
|
|
||||||
tracking_events_db = LockedTable(_tracking_events_db)
|
|
||||||
child_overrides_db = LockedTable(_child_overrides_db)
|
|
||||||
chore_schedules_db = LockedTable(_chore_schedules_db)
|
|
||||||
task_extensions_db = LockedTable(_task_extensions_db)
|
|
||||||
refresh_tokens_db = LockedTable(_refresh_tokens_db)
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Index management
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
COLLECTION_INDEXES = {
|
||||||
|
# NOTE: The model ``id`` field is stored as MongoDB's primary key ``_id``,
|
||||||
|
# so no separate unique index on ``id`` is needed. Only secondary indexes
|
||||||
|
# for frequently queried fields are defined here.
|
||||||
|
'children': [
|
||||||
|
{'keys': [('user_id', ASCENDING)]},
|
||||||
|
],
|
||||||
|
'tasks': [
|
||||||
|
{'keys': [('user_id', ASCENDING)]},
|
||||||
|
],
|
||||||
|
'routines': [
|
||||||
|
{'keys': [('user_id', ASCENDING)]},
|
||||||
|
],
|
||||||
|
'routine_items': [
|
||||||
|
{'keys': [('user_id', ASCENDING)]},
|
||||||
|
],
|
||||||
|
'routine_schedules': [],
|
||||||
|
'routine_extensions': [],
|
||||||
|
'rewards': [
|
||||||
|
{'keys': [('user_id', ASCENDING)]},
|
||||||
|
],
|
||||||
|
'images': [
|
||||||
|
{'keys': [('user_id', ASCENDING)]},
|
||||||
|
],
|
||||||
|
'pending_rewards': [
|
||||||
|
{'keys': [('child_id', ASCENDING)]},
|
||||||
|
],
|
||||||
|
'pending_confirmations': [
|
||||||
|
{'keys': [('user_id', ASCENDING)]},
|
||||||
|
{'keys': [('child_id', ASCENDING)]},
|
||||||
|
{'keys': [('entity_id', ASCENDING), ('entity_type', ASCENDING)]},
|
||||||
|
],
|
||||||
|
'users': [],
|
||||||
|
'tracking_events': [
|
||||||
|
{'keys': [('user_id', ASCENDING)]},
|
||||||
|
{'keys': [('child_id', ASCENDING)]},
|
||||||
|
{'keys': [('entity_id', ASCENDING), ('entity_type', ASCENDING)]},
|
||||||
|
],
|
||||||
|
'child_overrides': [
|
||||||
|
{'keys': [('child_id', ASCENDING)]},
|
||||||
|
{'keys': [('entity_id', ASCENDING), ('entity_type', ASCENDING)]},
|
||||||
|
],
|
||||||
|
'chore_schedules': [
|
||||||
|
{'keys': [('user_id', ASCENDING)]},
|
||||||
|
{'keys': [('child_id', ASCENDING)]},
|
||||||
|
],
|
||||||
|
'task_extensions': [
|
||||||
|
{'keys': [('user_id', ASCENDING)]},
|
||||||
|
{'keys': [('child_id', ASCENDING)]},
|
||||||
|
],
|
||||||
|
'refresh_tokens': [
|
||||||
|
{'keys': [('user_id', ASCENDING)]},
|
||||||
|
{'keys': [('token', ASCENDING)], 'unique': True, 'sparse': True},
|
||||||
|
],
|
||||||
|
'push_subscriptions': [
|
||||||
|
{'keys': [('user_id', ASCENDING)]},
|
||||||
|
],
|
||||||
|
'digest_action_tokens': [
|
||||||
|
{'keys': [('user_id', ASCENDING)]},
|
||||||
|
{'keys': [('token', ASCENDING)], 'unique': True, 'sparse': True},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_mongodb_indexes(client=None, db_name=None):
|
||||||
|
"""Create required indexes on all MongoDB collections.
|
||||||
|
|
||||||
|
Safe to call repeatedly: MongoDB treats index creation as idempotent.
|
||||||
|
"""
|
||||||
|
if not USE_MONGODB:
|
||||||
|
return
|
||||||
|
|
||||||
|
client = client or get_mongo_client()
|
||||||
|
db_name = db_name or _mongo_db_name
|
||||||
|
db = client[db_name]
|
||||||
|
|
||||||
|
for collection_name, indexes in COLLECTION_INDEXES.items():
|
||||||
|
coll = db[collection_name]
|
||||||
|
for spec in indexes:
|
||||||
|
keys = spec['keys']
|
||||||
|
kwargs = {k: v for k, v in spec.items() if k != 'keys'}
|
||||||
|
coll.create_index(keys, **kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
# Clear test collections at import time so tests start with a clean slate.
|
||||||
if os.environ.get('DB_ENV', 'prod') == 'test':
|
if os.environ.get('DB_ENV', 'prod') == 'test':
|
||||||
child_db.truncate()
|
child_db.truncate()
|
||||||
task_db.truncate()
|
task_db.truncate()
|
||||||
|
routine_db.truncate()
|
||||||
|
routine_items_db.truncate()
|
||||||
|
routine_schedules_db.truncate()
|
||||||
|
routine_extensions_db.truncate()
|
||||||
reward_db.truncate()
|
reward_db.truncate()
|
||||||
image_db.truncate()
|
image_db.truncate()
|
||||||
pending_reward_db.truncate()
|
pending_reward_db.truncate()
|
||||||
@@ -121,4 +597,5 @@ if os.environ.get('DB_ENV', 'prod') == 'test':
|
|||||||
chore_schedules_db.truncate()
|
chore_schedules_db.truncate()
|
||||||
task_extensions_db.truncate()
|
task_extensions_db.truncate()
|
||||||
refresh_tokens_db.truncate()
|
refresh_tokens_db.truncate()
|
||||||
|
push_subscriptions_db.truncate()
|
||||||
|
digest_action_tokens_db.truncate()
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
from tinydb import Query
|
||||||
|
from db.db import digest_action_tokens_db
|
||||||
|
from models.digest_action_token import DigestActionToken
|
||||||
|
|
||||||
|
|
||||||
|
def insert_token(token: DigestActionToken) -> None:
|
||||||
|
digest_action_tokens_db.insert(token.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def get_token_by_id(token_id: str) -> DigestActionToken | None:
|
||||||
|
Q = Query()
|
||||||
|
result = digest_action_tokens_db.get(Q.id == token_id)
|
||||||
|
return DigestActionToken.from_dict(result) if result else None
|
||||||
|
|
||||||
|
|
||||||
|
def mark_token_used(token_id: str) -> None:
|
||||||
|
Q = Query()
|
||||||
|
digest_action_tokens_db.update({'used': True}, Q.id == token_id)
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
# python
|
||||||
|
"""Lazy MongoDB client factory and database-name helpers.
|
||||||
|
|
||||||
|
The client is intentionally **not** created at module import. Use
|
||||||
|
``get_mongo_client()`` to obtain a cached singleton. For Gunicorn multi-worker
|
||||||
|
deployments call ``init_mongo_client()`` from a ``post_fork`` hook so each
|
||||||
|
worker process owns its own connection pool rather than inheriting the parent
|
||||||
|
process's client.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from pymongo import MongoClient
|
||||||
|
from pymongo.uri_parser import parse_uri
|
||||||
|
|
||||||
|
|
||||||
|
_mongo_client_lock = threading.Lock()
|
||||||
|
_mongo_client = None
|
||||||
|
|
||||||
|
|
||||||
|
def _create_mongo_client():
|
||||||
|
"""Build a fail-fast MongoClient from environment variables."""
|
||||||
|
uri = os.environ.get('MONGO_URI')
|
||||||
|
if not uri:
|
||||||
|
raise RuntimeError(
|
||||||
|
'MONGO_URI environment variable is required when USE_MONGODB=true.'
|
||||||
|
)
|
||||||
|
|
||||||
|
# mongomock is used for unit/integration tests without a real server.
|
||||||
|
if uri.lower().startswith('mongomock') or uri.lower() == 'mongomock':
|
||||||
|
try:
|
||||||
|
import mongomock
|
||||||
|
except ImportError as exc: # pragma: no cover - test dependency
|
||||||
|
raise RuntimeError(
|
||||||
|
'mongomock is required for test MongoDB mode. '
|
||||||
|
'Install it with: pip install mongomock'
|
||||||
|
) from exc
|
||||||
|
return mongomock.MongoClient()
|
||||||
|
|
||||||
|
return MongoClient(
|
||||||
|
uri,
|
||||||
|
serverSelectionTimeoutMS=5000,
|
||||||
|
connectTimeoutMS=5000,
|
||||||
|
maxPoolSize=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def init_mongo_client():
|
||||||
|
"""Create a fresh MongoClient and store it as the process singleton.
|
||||||
|
|
||||||
|
Call this from a Gunicorn ``post_fork`` hook so each worker process gets
|
||||||
|
its own client after forking. It can also be called in tests to reset the
|
||||||
|
shared client to a known state.
|
||||||
|
"""
|
||||||
|
global _mongo_client
|
||||||
|
with _mongo_client_lock:
|
||||||
|
_mongo_client = _create_mongo_client()
|
||||||
|
return _mongo_client
|
||||||
|
|
||||||
|
|
||||||
|
def get_mongo_client():
|
||||||
|
"""Return the cached process-level MongoClient, creating it lazily once."""
|
||||||
|
global _mongo_client
|
||||||
|
if _mongo_client is None:
|
||||||
|
with _mongo_client_lock:
|
||||||
|
if _mongo_client is None:
|
||||||
|
_mongo_client = _create_mongo_client()
|
||||||
|
return _mongo_client
|
||||||
|
|
||||||
|
|
||||||
|
def _db_name_from_uri(uri: str) -> str | None:
|
||||||
|
"""Extract the database name from a MongoDB connection URI, if present."""
|
||||||
|
if not uri or uri.lower().startswith('mongomock'):
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
parsed = parse_uri(uri)
|
||||||
|
return parsed.get('database') or None
|
||||||
|
except Exception:
|
||||||
|
# Fallback to a simple path-based parse for non-standard URIs.
|
||||||
|
try:
|
||||||
|
path = urlparse(uri).path
|
||||||
|
return path.lstrip('/') or None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_mongo_db_name() -> str:
|
||||||
|
"""Resolve the MongoDB database name from env vars or the connection URI.
|
||||||
|
|
||||||
|
Precedence:
|
||||||
|
1. ``MONGO_DB_NAME`` environment variable.
|
||||||
|
2. Database name parsed from ``MONGO_URI``.
|
||||||
|
3. Default based on ``DATA_ENV`` / ``DB_ENV``:
|
||||||
|
* prod -> ``chore_db``
|
||||||
|
* e2e -> ``chore_db_e2e``
|
||||||
|
* test -> ``chore_db_test``
|
||||||
|
"""
|
||||||
|
env_name = os.environ.get('MONGO_DB_NAME')
|
||||||
|
if env_name:
|
||||||
|
return env_name
|
||||||
|
|
||||||
|
uri = os.environ.get('MONGO_URI', '')
|
||||||
|
db_name = _db_name_from_uri(uri)
|
||||||
|
if db_name:
|
||||||
|
return db_name
|
||||||
|
|
||||||
|
env = (os.environ.get('DATA_ENV') or os.environ.get('DB_ENV', 'prod')).lower()
|
||||||
|
if env == 'prod':
|
||||||
|
return 'chore_db'
|
||||||
|
if env == 'e2e':
|
||||||
|
return 'chore_db_e2e'
|
||||||
|
return 'chore_db_test'
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
from tinydb import Query
|
||||||
|
from db.db import push_subscriptions_db
|
||||||
|
from models.push_subscription import PushSubscription
|
||||||
|
|
||||||
|
|
||||||
|
def get_subscriptions_by_user(user_id: str) -> list[PushSubscription]:
|
||||||
|
"""Return all push subscriptions for a user."""
|
||||||
|
Q = Query()
|
||||||
|
results = push_subscriptions_db.search(Q.user_id == user_id)
|
||||||
|
return [PushSubscription.from_dict(r) for r in results]
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_subscription(user_id: str, endpoint: str, keys: dict) -> PushSubscription:
|
||||||
|
"""Insert or update a subscription for the given user+endpoint pair."""
|
||||||
|
Q = Query()
|
||||||
|
existing = push_subscriptions_db.get((Q.user_id == user_id) & (Q.endpoint == endpoint))
|
||||||
|
if existing:
|
||||||
|
sub = PushSubscription.from_dict(existing)
|
||||||
|
sub.keys = keys
|
||||||
|
sub.touch()
|
||||||
|
push_subscriptions_db.update(sub.to_dict(), (Q.user_id == user_id) & (Q.endpoint == endpoint))
|
||||||
|
return sub
|
||||||
|
sub = PushSubscription(user_id=user_id, endpoint=endpoint, keys=keys)
|
||||||
|
push_subscriptions_db.insert(sub.to_dict())
|
||||||
|
return sub
|
||||||
|
|
||||||
|
|
||||||
|
def delete_by_endpoint(user_id: str, endpoint: str) -> int:
|
||||||
|
"""Remove the subscription with the given endpoint for this user. Returns count removed."""
|
||||||
|
Q = Query()
|
||||||
|
removed = push_subscriptions_db.remove((Q.user_id == user_id) & (Q.endpoint == endpoint))
|
||||||
|
return len(removed)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_subscription_by_id(subscription_id: str) -> None:
|
||||||
|
"""Remove a subscription by its ID (used when push delivery fails)."""
|
||||||
|
Q = Query()
|
||||||
|
push_subscriptions_db.remove(Q.id == subscription_id)
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from tinydb import Query
|
||||||
|
from db.db import routine_extensions_db
|
||||||
|
from models.routine_extension import RoutineExtension
|
||||||
|
|
||||||
|
|
||||||
|
def get_extension(child_id: str, routine_id: str, date: str) -> RoutineExtension | None:
|
||||||
|
q = Query()
|
||||||
|
result = routine_extensions_db.search(
|
||||||
|
(q.child_id == child_id) & (q.routine_id == routine_id) & (q.date == date)
|
||||||
|
)
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
return RoutineExtension.from_dict(result[0])
|
||||||
|
|
||||||
|
|
||||||
|
def add_extension(extension: RoutineExtension) -> None:
|
||||||
|
routine_extensions_db.insert(extension.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def delete_extensions_for_child(child_id: str) -> None:
|
||||||
|
q = Query()
|
||||||
|
routine_extensions_db.remove(q.child_id == child_id)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_extensions_for_routine(routine_id: str) -> None:
|
||||||
|
q = Query()
|
||||||
|
routine_extensions_db.remove(q.routine_id == routine_id)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_extension_for_child_routine(child_id: str, routine_id: str) -> None:
|
||||||
|
q = Query()
|
||||||
|
routine_extensions_db.remove((q.child_id == child_id) & (q.routine_id == routine_id))
|
||||||
|
|
||||||
|
|
||||||
|
def get_extension_for_child_routine(child_id: str, routine_id: str) -> RoutineExtension | None:
|
||||||
|
q = Query()
|
||||||
|
results = routine_extensions_db.search((q.child_id == child_id) & (q.routine_id == routine_id))
|
||||||
|
if not results:
|
||||||
|
return None
|
||||||
|
latest = max(results, key=lambda r: r.get('date', ''))
|
||||||
|
return RoutineExtension.from_dict(latest)
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from tinydb import Query
|
||||||
|
from db.db import routine_items_db
|
||||||
|
from models.routine_item import RoutineItem
|
||||||
|
|
||||||
|
|
||||||
|
def add_item(item: RoutineItem) -> None:
|
||||||
|
routine_items_db.insert(item.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def get_item(item_id: str) -> RoutineItem | None:
|
||||||
|
q = Query()
|
||||||
|
result = routine_items_db.search(q.id == item_id)
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
return RoutineItem.from_dict(result[0])
|
||||||
|
|
||||||
|
|
||||||
|
def get_items_for_routine(routine_id: str) -> list[RoutineItem]:
|
||||||
|
q = Query()
|
||||||
|
results = routine_items_db.search(q.routine_id == routine_id)
|
||||||
|
items = [RoutineItem.from_dict(r) for r in results]
|
||||||
|
return sorted(items, key=lambda i: (i.order, i.created_at))
|
||||||
|
|
||||||
|
|
||||||
|
def update_item(item: RoutineItem) -> bool:
|
||||||
|
q = Query()
|
||||||
|
existing = routine_items_db.get(q.id == item.id)
|
||||||
|
if not existing:
|
||||||
|
return False
|
||||||
|
routine_items_db.update(item.to_dict(), q.id == item.id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def delete_item(item_id: str) -> bool:
|
||||||
|
q = Query()
|
||||||
|
existing = routine_items_db.get(q.id == item_id)
|
||||||
|
if not existing:
|
||||||
|
return False
|
||||||
|
routine_items_db.remove(q.id == item_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def delete_for_routine(routine_id: str) -> None:
|
||||||
|
q = Query()
|
||||||
|
routine_items_db.remove(q.routine_id == routine_id)
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
from tinydb import Query
|
||||||
|
from db.db import routine_schedules_db
|
||||||
|
from models.routine_schedule import RoutineSchedule
|
||||||
|
|
||||||
|
|
||||||
|
def get_schedule(child_id: str, routine_id: str) -> RoutineSchedule | None:
|
||||||
|
q = Query()
|
||||||
|
result = routine_schedules_db.search((q.child_id == child_id) & (q.routine_id == routine_id))
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
return RoutineSchedule.from_dict(result[0])
|
||||||
|
|
||||||
|
|
||||||
|
def upsert_schedule(schedule: RoutineSchedule) -> None:
|
||||||
|
q = Query()
|
||||||
|
existing = routine_schedules_db.get((q.child_id == schedule.child_id) & (q.routine_id == schedule.routine_id))
|
||||||
|
if existing:
|
||||||
|
routine_schedules_db.update(
|
||||||
|
schedule.to_dict(),
|
||||||
|
(q.child_id == schedule.child_id) & (q.routine_id == schedule.routine_id)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
routine_schedules_db.insert(schedule.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def delete_schedule(child_id: str, routine_id: str) -> bool:
|
||||||
|
q = Query()
|
||||||
|
existing = routine_schedules_db.get((q.child_id == child_id) & (q.routine_id == routine_id))
|
||||||
|
if not existing:
|
||||||
|
return False
|
||||||
|
routine_schedules_db.remove((q.child_id == child_id) & (q.routine_id == routine_id))
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def delete_schedules_for_child(child_id: str) -> None:
|
||||||
|
q = Query()
|
||||||
|
routine_schedules_db.remove(q.child_id == child_id)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_schedules_for_routine(routine_id: str) -> None:
|
||||||
|
q = Query()
|
||||||
|
routine_schedules_db.remove(q.routine_id == routine_id)
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
from tinydb import Query
|
||||||
|
from db.db import routine_db
|
||||||
|
from models.routine import Routine
|
||||||
|
|
||||||
|
|
||||||
|
def add_routine(routine: Routine) -> None:
|
||||||
|
routine_db.insert(routine.to_dict())
|
||||||
|
|
||||||
|
|
||||||
|
def get_routine(routine_id: str) -> Routine | None:
|
||||||
|
q = Query()
|
||||||
|
result = routine_db.search(q.id == routine_id)
|
||||||
|
if not result:
|
||||||
|
return None
|
||||||
|
return Routine.from_dict(result[0])
|
||||||
|
|
||||||
|
|
||||||
|
def update_routine(routine: Routine) -> bool:
|
||||||
|
q = Query()
|
||||||
|
existing = routine_db.get(q.id == routine.id)
|
||||||
|
if not existing:
|
||||||
|
return False
|
||||||
|
routine_db.update(routine.to_dict(), q.id == routine.id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def delete_routine(routine_id: str) -> bool:
|
||||||
|
q = Query()
|
||||||
|
existing = routine_db.get(q.id == routine_id)
|
||||||
|
if not existing:
|
||||||
|
return False
|
||||||
|
routine_db.remove(q.id == routine_id)
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def list_routines_for_user(user_id: str) -> list[Routine]:
|
||||||
|
q = Query()
|
||||||
|
results = routine_db.search((q.user_id == user_id) | (q.user_id == None))
|
||||||
|
return [Routine.from_dict(r) for r in results]
|
||||||
+22
-8
@@ -1,4 +1,5 @@
|
|||||||
"""Helper functions for tracking events database operations."""
|
"""Helper functions for tracking events database operations."""
|
||||||
|
import itertools
|
||||||
import logging
|
import logging
|
||||||
from typing import Optional, List
|
from typing import Optional, List
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
@@ -8,6 +9,10 @@ from models.tracking_event import TrackingEvent, EntityType, ActionType
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Monotonic sequence used as a deterministic tiebreaker when tracking events
|
||||||
|
# share the same ``occurred_at``/``created_at`` timestamps (common in tests).
|
||||||
|
_tracking_event_seq = itertools.count()
|
||||||
|
|
||||||
|
|
||||||
def insert_tracking_event(event: TrackingEvent) -> str:
|
def insert_tracking_event(event: TrackingEvent) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -20,7 +25,9 @@ def insert_tracking_event(event: TrackingEvent) -> str:
|
|||||||
The event ID
|
The event ID
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
tracking_events_db.insert(event.to_dict())
|
event_dict = event.to_dict()
|
||||||
|
event_dict['_seq'] = next(_tracking_event_seq)
|
||||||
|
tracking_events_db.insert(event_dict)
|
||||||
logger.info(f"Tracking event created: {event.action} {event.entity_type} {event.entity_id} for child {event.child_id}")
|
logger.info(f"Tracking event created: {event.action} {event.entity_type} {event.entity_id} for child {event.child_id}")
|
||||||
return event.id
|
return event.id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -61,12 +68,16 @@ def get_tracking_events_by_child(
|
|||||||
all_results = tracking_events_db.search(query_condition)
|
all_results = tracking_events_db.search(query_condition)
|
||||||
total = len(all_results)
|
total = len(all_results)
|
||||||
|
|
||||||
# Sort by occurred_at desc, then created_at desc
|
# Sort by occurred_at desc, then created_at desc, then _seq desc for
|
||||||
all_results.sort(key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0)), reverse=True)
|
# deterministic ordering when timestamps collide (common in fast tests).
|
||||||
|
all_results.sort(
|
||||||
|
key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0), x.get('_seq', 0)),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
paginated = all_results[offset:offset + limit]
|
paginated = all_results[offset:offset + limit]
|
||||||
events = [TrackingEvent.from_dict(r) for r in paginated]
|
events = [TrackingEvent.from_dict(r) for r in paginated]
|
||||||
|
|
||||||
return events, total
|
return events, total
|
||||||
|
|
||||||
|
|
||||||
@@ -99,11 +110,14 @@ def get_tracking_events_by_user(
|
|||||||
all_results = tracking_events_db.search(query_condition)
|
all_results = tracking_events_db.search(query_condition)
|
||||||
total = len(all_results)
|
total = len(all_results)
|
||||||
|
|
||||||
all_results.sort(key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0)), reverse=True)
|
all_results.sort(
|
||||||
|
key=lambda x: (x.get('occurred_at', ''), x.get('created_at', 0), x.get('_seq', 0)),
|
||||||
|
reverse=True,
|
||||||
|
)
|
||||||
|
|
||||||
paginated = all_results[offset:offset + limit]
|
paginated = all_results[offset:offset + limit]
|
||||||
events = [TrackingEvent.from_dict(r) for r in paginated]
|
events = [TrackingEvent.from_dict(r) for r in paginated]
|
||||||
|
|
||||||
return events, total
|
return events, total
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
|
class ChildRoutineConfirmation(Payload):
|
||||||
|
OPERATION_PENDING = "PENDING"
|
||||||
|
OPERATION_APPROVED = "APPROVED"
|
||||||
|
OPERATION_REJECTED = "REJECTED"
|
||||||
|
OPERATION_RESET = "RESET"
|
||||||
|
|
||||||
|
def __init__(self, child_id: str, routine_id: str, operation: str):
|
||||||
|
super().__init__({
|
||||||
|
'child_id': child_id,
|
||||||
|
'routine_id': routine_id,
|
||||||
|
'operation': operation
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
|
class ChildRoutinesSet(Payload):
|
||||||
|
def __init__(self, child_id: str, routine_ids: list[str]):
|
||||||
|
super().__init__({
|
||||||
|
'child_id': child_id,
|
||||||
|
'routine_ids': routine_ids
|
||||||
|
})
|
||||||
@@ -28,4 +28,10 @@ class EventType(Enum):
|
|||||||
CHORE_TIME_EXTENDED = "chore_time_extended"
|
CHORE_TIME_EXTENDED = "chore_time_extended"
|
||||||
CHILD_CHORE_CONFIRMATION = "child_chore_confirmation"
|
CHILD_CHORE_CONFIRMATION = "child_chore_confirmation"
|
||||||
|
|
||||||
|
ROUTINE_MODIFIED = "routine_modified"
|
||||||
|
CHILD_ROUTINES_SET = "child_routines_set"
|
||||||
|
ROUTINE_SCHEDULE_MODIFIED = "routine_schedule_modified"
|
||||||
|
ROUTINE_TIME_EXTENDED = "routine_time_extended"
|
||||||
|
CHILD_ROUTINE_CONFIRMATION = "child_routine_confirmation"
|
||||||
|
|
||||||
FORCE_LOGOUT = "force_logout"
|
FORCE_LOGOUT = "force_logout"
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
|
class RoutineModified(Payload):
|
||||||
|
OPERATION_ADD = "ADD"
|
||||||
|
OPERATION_EDIT = "EDIT"
|
||||||
|
OPERATION_DELETE = "DELETE"
|
||||||
|
|
||||||
|
def __init__(self, routine_id: str, operation: str):
|
||||||
|
super().__init__({
|
||||||
|
'routine_id': routine_id,
|
||||||
|
'operation': operation
|
||||||
|
})
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
|
class RoutineScheduleModified(Payload):
|
||||||
|
OPERATION_SET = 'SET'
|
||||||
|
OPERATION_DELETED = 'DELETED'
|
||||||
|
|
||||||
|
def __init__(self, child_id: str, routine_id: str, operation: str):
|
||||||
|
super().__init__({
|
||||||
|
'child_id': child_id,
|
||||||
|
'routine_id': routine_id,
|
||||||
|
'operation': operation,
|
||||||
|
})
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
from events.types.payload import Payload
|
||||||
|
|
||||||
|
|
||||||
|
class RoutineTimeExtended(Payload):
|
||||||
|
def __init__(self, child_id: str, routine_id: str):
|
||||||
|
super().__init__({
|
||||||
|
'child_id': child_id,
|
||||||
|
'routine_id': routine_id,
|
||||||
|
})
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
"""Gunicorn configuration for the chore/reward Flask backend.
|
||||||
|
|
||||||
|
This file is automatically loaded by Gunicorn when it is started from the
|
||||||
|
backend directory. It ensures each worker process creates its own MongoDB
|
||||||
|
client after forking, avoiding shared socket/file-descriptor issues.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def post_fork(server, worker):
|
||||||
|
"""Reinitialize the MongoDB client in each worker process after forking."""
|
||||||
|
try:
|
||||||
|
from db.mongo_client import init_mongo_client
|
||||||
|
init_mongo_client()
|
||||||
|
except Exception:
|
||||||
|
# If MongoDB is not configured (USE_MONGODB=false), there is no client
|
||||||
|
# to initialize; ignore the error silently.
|
||||||
|
pass
|
||||||
+46
-1
@@ -2,6 +2,7 @@ import logging
|
|||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
|
||||||
|
from dotenv import load_dotenv
|
||||||
from flask import Flask, request, jsonify
|
from flask import Flask, request, jsonify
|
||||||
|
|
||||||
from api.admin_api import admin_api
|
from api.admin_api import admin_api
|
||||||
@@ -14,16 +15,33 @@ from api.image_api import image_api
|
|||||||
from api.kindness_api import kindness_api
|
from api.kindness_api import kindness_api
|
||||||
from api.penalty_api import penalty_api
|
from api.penalty_api import penalty_api
|
||||||
from api.reward_api import reward_api
|
from api.reward_api import reward_api
|
||||||
|
from api.routine_api import routine_api
|
||||||
|
from api.routine_item_api import routine_item_api
|
||||||
|
from api.child_routine_api import child_routine_api
|
||||||
|
from api.routine_schedule_api import routine_schedule_api
|
||||||
from api.task_api import task_api
|
from api.task_api import task_api
|
||||||
from api.tracking_api import tracking_api
|
from api.tracking_api import tracking_api
|
||||||
from api.user_api import user_api
|
from api.user_api import user_api
|
||||||
|
from api.push_subscription_api import push_subscription_api
|
||||||
|
from api.digest_action_api import digest_action_api
|
||||||
from config.version import get_full_version
|
from config.version import get_full_version
|
||||||
|
|
||||||
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
|
from db.default import initializeImages, createDefaultTasks, createDefaultRewards
|
||||||
|
from db.db import ensure_mongodb_indexes
|
||||||
from events.broadcaster import Broadcaster
|
from events.broadcaster import Broadcaster
|
||||||
from events.sse import sse_response_for_user, send_to_user
|
from events.sse import sse_response_for_user, send_to_user
|
||||||
from api.utils import get_current_user_id
|
from api.utils import get_current_user_id
|
||||||
from utils.account_deletion_scheduler import start_deletion_scheduler
|
from utils.account_deletion_scheduler import start_deletion_scheduler
|
||||||
|
from utils.chore_expiry_notification_scheduler import start_chore_expiry_notification_scheduler
|
||||||
|
from utils.digest_scheduler import start_digest_scheduler
|
||||||
|
from utils.state_expiry_scheduler import start_state_expiry_scheduler
|
||||||
|
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
# Ensure MongoDB indexes exist when running against MongoDB.
|
||||||
|
ensure_mongodb_indexes()
|
||||||
|
|
||||||
# Configure logging once at application startup
|
# Configure logging once at application startup
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
@@ -49,11 +67,17 @@ app.register_blueprint(chore_schedule_api)
|
|||||||
app.register_blueprint(kindness_api)
|
app.register_blueprint(kindness_api)
|
||||||
app.register_blueprint(penalty_api)
|
app.register_blueprint(penalty_api)
|
||||||
app.register_blueprint(reward_api)
|
app.register_blueprint(reward_api)
|
||||||
|
app.register_blueprint(routine_api)
|
||||||
|
app.register_blueprint(routine_item_api)
|
||||||
|
app.register_blueprint(child_routine_api)
|
||||||
|
app.register_blueprint(routine_schedule_api)
|
||||||
app.register_blueprint(task_api)
|
app.register_blueprint(task_api)
|
||||||
app.register_blueprint(image_api)
|
app.register_blueprint(image_api)
|
||||||
app.register_blueprint(auth_api, url_prefix='/auth')
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
app.register_blueprint(user_api)
|
app.register_blueprint(user_api)
|
||||||
app.register_blueprint(tracking_api)
|
app.register_blueprint(tracking_api)
|
||||||
|
app.register_blueprint(push_subscription_api)
|
||||||
|
app.register_blueprint(digest_action_api)
|
||||||
|
|
||||||
app.config.update(
|
app.config.update(
|
||||||
MAIL_SERVER='smtp.gmail.com',
|
MAIL_SERVER='smtp.gmail.com',
|
||||||
@@ -63,6 +87,7 @@ app.config.update(
|
|||||||
MAIL_PASSWORD='ruyj hxjf nmrz buar',
|
MAIL_PASSWORD='ruyj hxjf nmrz buar',
|
||||||
MAIL_DEFAULT_SENDER='ryan.kegel@gmail.com',
|
MAIL_DEFAULT_SENDER='ryan.kegel@gmail.com',
|
||||||
FRONTEND_URL=os.environ.get('FRONTEND_URL', 'https://localhost:5173'), # Dynamic via env var, defaults to localhost
|
FRONTEND_URL=os.environ.get('FRONTEND_URL', 'https://localhost:5173'), # Dynamic via env var, defaults to localhost
|
||||||
|
VAPID_CLAIMS_EMAIL=os.environ.get('VAPID_CLAIMS_EMAIL', 'admin@reward-app.local'),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Security: require SECRET_KEY and REFRESH_TOKEN_EXPIRY_DAYS from environment
|
# Security: require SECRET_KEY and REFRESH_TOKEN_EXPIRY_DAYS from environment
|
||||||
@@ -81,6 +106,24 @@ try:
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
raise RuntimeError('REFRESH_TOKEN_EXPIRY_DAYS must be an integer.')
|
raise RuntimeError('REFRESH_TOKEN_EXPIRY_DAYS must be an integer.')
|
||||||
|
|
||||||
|
_digest_token_secret = os.environ.get('DIGEST_TOKEN_SECRET')
|
||||||
|
if not _digest_token_secret:
|
||||||
|
raise RuntimeError(
|
||||||
|
'DIGEST_TOKEN_SECRET environment variable is required. '
|
||||||
|
'Set it to a random string (e.g. python -c "import secrets; print(secrets.token_urlsafe(64))")')
|
||||||
|
app.config['DIGEST_TOKEN_SECRET'] = _digest_token_secret
|
||||||
|
|
||||||
|
_vapid_public_key = os.environ.get('VAPID_PUBLIC_KEY')
|
||||||
|
_vapid_private_key = os.environ.get('VAPID_PRIVATE_KEY')
|
||||||
|
if not _vapid_public_key or not _vapid_private_key:
|
||||||
|
raise RuntimeError(
|
||||||
|
'VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY environment variables are required. '
|
||||||
|
'Generate a key pair with: python -c "from py_vapid import Vapid; v=Vapid(); '
|
||||||
|
'v.generate_keys(); print(v.public_key.public_bytes_raw().hex(), v.private_key.private_bytes_raw().hex())"'
|
||||||
|
)
|
||||||
|
app.config['VAPID_PUBLIC_KEY'] = _vapid_public_key
|
||||||
|
app.config['VAPID_PRIVATE_KEY'] = _vapid_private_key
|
||||||
|
|
||||||
@app.route("/version")
|
@app.route("/version")
|
||||||
def api_version():
|
def api_version():
|
||||||
return jsonify({"version": get_full_version()})
|
return jsonify({"version": get_full_version()})
|
||||||
@@ -109,12 +152,14 @@ def start_background_threads():
|
|||||||
broadcaster.daemon = True
|
broadcaster.daemon = True
|
||||||
broadcaster.start()
|
broadcaster.start()
|
||||||
|
|
||||||
# TODO: implement users
|
|
||||||
initializeImages()
|
initializeImages()
|
||||||
createDefaultTasks()
|
createDefaultTasks()
|
||||||
createDefaultRewards()
|
createDefaultRewards()
|
||||||
start_background_threads()
|
start_background_threads()
|
||||||
start_deletion_scheduler()
|
start_deletion_scheduler()
|
||||||
|
start_digest_scheduler(app)
|
||||||
|
start_state_expiry_scheduler(app)
|
||||||
|
start_chore_expiry_notification_scheduler(app)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(debug=False, host='0.0.0.0', port=5000, threaded=True)
|
app.run(debug=False, host='0.0.0.0', port=5000, threaded=True)
|
||||||
@@ -6,6 +6,7 @@ class Child(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
age: int | None = None
|
age: int | None = None
|
||||||
tasks: list[str] = field(default_factory=list)
|
tasks: list[str] = field(default_factory=list)
|
||||||
|
routines: list[str] = field(default_factory=list)
|
||||||
rewards: list[str] = field(default_factory=list)
|
rewards: list[str] = field(default_factory=list)
|
||||||
points: int = 0
|
points: int = 0
|
||||||
image_id: str | None = None
|
image_id: str | None = None
|
||||||
@@ -17,6 +18,7 @@ class Child(BaseModel):
|
|||||||
name=d.get('name'),
|
name=d.get('name'),
|
||||||
age=d.get('age'),
|
age=d.get('age'),
|
||||||
tasks=d.get('tasks', []),
|
tasks=d.get('tasks', []),
|
||||||
|
routines=d.get('routines', []),
|
||||||
rewards=d.get('rewards', []),
|
rewards=d.get('rewards', []),
|
||||||
points=d.get('points', 0),
|
points=d.get('points', 0),
|
||||||
image_id=d.get('image_id'),
|
image_id=d.get('image_id'),
|
||||||
@@ -32,6 +34,7 @@ class Child(BaseModel):
|
|||||||
'name': self.name,
|
'name': self.name,
|
||||||
'age': self.age,
|
'age': self.age,
|
||||||
'tasks': self.tasks,
|
'tasks': self.tasks,
|
||||||
|
'routines': self.routines,
|
||||||
'rewards': self.rewards,
|
'rewards': self.rewards,
|
||||||
'points': self.points,
|
'points': self.points,
|
||||||
'image_id': self.image_id,
|
'image_id': self.image_id,
|
||||||
|
|||||||
@@ -16,15 +16,15 @@ class ChildOverride(BaseModel):
|
|||||||
"""
|
"""
|
||||||
child_id: str
|
child_id: str
|
||||||
entity_id: str
|
entity_id: str
|
||||||
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty']
|
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty', 'routine']
|
||||||
custom_value: int
|
custom_value: int
|
||||||
|
|
||||||
def __post_init__(self):
|
def __post_init__(self):
|
||||||
"""Validate custom_value range and entity_type."""
|
"""Validate custom_value range and entity_type."""
|
||||||
if self.custom_value < 0 or self.custom_value > 10000:
|
if self.custom_value < 0 or self.custom_value > 10000:
|
||||||
raise ValueError("custom_value must be between 0 and 10000")
|
raise ValueError("custom_value must be between 0 and 10000")
|
||||||
if self.entity_type not in ['task', 'reward', 'chore', 'kindness', 'penalty']:
|
if self.entity_type not in ['task', 'reward', 'chore', 'kindness', 'penalty', 'routine']:
|
||||||
raise ValueError("entity_type must be 'task', 'reward', 'chore', 'kindness', or 'penalty'")
|
raise ValueError("entity_type must be 'task', 'reward', 'chore', 'kindness', 'penalty', or 'routine'")
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, d: dict):
|
def from_dict(cls, d: dict):
|
||||||
@@ -52,7 +52,7 @@ class ChildOverride(BaseModel):
|
|||||||
def create_override(
|
def create_override(
|
||||||
child_id: str,
|
child_id: str,
|
||||||
entity_id: str,
|
entity_id: str,
|
||||||
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty'],
|
entity_type: Literal['task', 'reward', 'chore', 'kindness', 'penalty', 'routine'],
|
||||||
custom_value: int
|
custom_value: int
|
||||||
) -> 'ChildOverride':
|
) -> 'ChildOverride':
|
||||||
"""Factory method to create a new override."""
|
"""Factory method to create a new override."""
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DigestActionToken(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
child_id: str
|
||||||
|
entity_id: str
|
||||||
|
entity_type: str # 'chore' or 'reward'
|
||||||
|
action: str # 'approve' or 'deny'
|
||||||
|
expires_at: str # ISO timestamp
|
||||||
|
used: bool = False
|
||||||
|
signature: str = ''
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict) -> 'DigestActionToken':
|
||||||
|
return cls(
|
||||||
|
user_id=d.get('user_id'),
|
||||||
|
child_id=d.get('child_id'),
|
||||||
|
entity_id=d.get('entity_id'),
|
||||||
|
entity_type=d.get('entity_type'),
|
||||||
|
action=d.get('action'),
|
||||||
|
expires_at=d.get('expires_at'),
|
||||||
|
used=d.get('used', False),
|
||||||
|
signature=d.get('signature', ''),
|
||||||
|
id=d.get('id'),
|
||||||
|
created_at=d.get('created_at'),
|
||||||
|
updated_at=d.get('updated_at'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
base = super().to_dict()
|
||||||
|
base.update({
|
||||||
|
'user_id': self.user_id,
|
||||||
|
'child_id': self.child_id,
|
||||||
|
'entity_id': self.entity_id,
|
||||||
|
'entity_type': self.entity_type,
|
||||||
|
'action': self.action,
|
||||||
|
'expires_at': self.expires_at,
|
||||||
|
'used': self.used,
|
||||||
|
'signature': self.signature,
|
||||||
|
})
|
||||||
|
return base
|
||||||
@@ -3,7 +3,7 @@ from typing import Literal, Optional
|
|||||||
from models.base import BaseModel
|
from models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
PendingEntityType = Literal['chore', 'reward']
|
PendingEntityType = Literal['chore', 'reward', 'routine']
|
||||||
PendingStatus = Literal['pending', 'approved', 'rejected']
|
PendingStatus = Literal['pending', 'approved', 'rejected']
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PushSubscription(BaseModel):
|
||||||
|
user_id: str
|
||||||
|
endpoint: str
|
||||||
|
keys: dict # {'p256dh': str, 'auth': str}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict) -> 'PushSubscription':
|
||||||
|
return cls(
|
||||||
|
user_id=d.get('user_id'),
|
||||||
|
endpoint=d.get('endpoint'),
|
||||||
|
keys=d.get('keys', {}),
|
||||||
|
id=d.get('id'),
|
||||||
|
created_at=d.get('created_at'),
|
||||||
|
updated_at=d.get('updated_at'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
base = super().to_dict()
|
||||||
|
base.update({
|
||||||
|
'user_id': self.user_id,
|
||||||
|
'endpoint': self.endpoint,
|
||||||
|
'keys': self.keys,
|
||||||
|
})
|
||||||
|
return base
|
||||||
@@ -9,6 +9,7 @@ class RefreshToken(BaseModel):
|
|||||||
token_family: str = ''
|
token_family: str = ''
|
||||||
expires_at: str = ''
|
expires_at: str = ''
|
||||||
is_used: bool = False
|
is_used: bool = False
|
||||||
|
rotated_at: str | None = None
|
||||||
|
|
||||||
def to_dict(self):
|
def to_dict(self):
|
||||||
return {
|
return {
|
||||||
@@ -18,6 +19,7 @@ class RefreshToken(BaseModel):
|
|||||||
'token_family': self.token_family,
|
'token_family': self.token_family,
|
||||||
'expires_at': self.expires_at,
|
'expires_at': self.expires_at,
|
||||||
'is_used': self.is_used,
|
'is_used': self.is_used,
|
||||||
|
'rotated_at': self.rotated_at,
|
||||||
}
|
}
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
@@ -31,4 +33,5 @@ class RefreshToken(BaseModel):
|
|||||||
token_family=data.get('token_family', ''),
|
token_family=data.get('token_family', ''),
|
||||||
expires_at=data.get('expires_at', ''),
|
expires_at=data.get('expires_at', ''),
|
||||||
is_used=data.get('is_used', False),
|
is_used=data.get('is_used', False),
|
||||||
|
rotated_at=data.get('rotated_at'),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Routine(BaseModel):
|
||||||
|
name: str
|
||||||
|
points: int
|
||||||
|
image_id: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict):
|
||||||
|
return cls(
|
||||||
|
name=d.get('name'),
|
||||||
|
points=d.get('points', 0),
|
||||||
|
image_id=d.get('image_id'),
|
||||||
|
user_id=d.get('user_id'),
|
||||||
|
id=d.get('id'),
|
||||||
|
created_at=d.get('created_at'),
|
||||||
|
updated_at=d.get('updated_at')
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
base = super().to_dict()
|
||||||
|
base.update({
|
||||||
|
'name': self.name,
|
||||||
|
'points': self.points,
|
||||||
|
'image_id': self.image_id,
|
||||||
|
'user_id': self.user_id
|
||||||
|
})
|
||||||
|
return base
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RoutineExtension(BaseModel):
|
||||||
|
child_id: str
|
||||||
|
routine_id: str
|
||||||
|
date: str
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict) -> 'RoutineExtension':
|
||||||
|
return cls(
|
||||||
|
child_id=d.get('child_id'),
|
||||||
|
routine_id=d.get('routine_id'),
|
||||||
|
date=d.get('date'),
|
||||||
|
id=d.get('id'),
|
||||||
|
created_at=d.get('created_at'),
|
||||||
|
updated_at=d.get('updated_at'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
base = super().to_dict()
|
||||||
|
base.update({
|
||||||
|
'child_id': self.child_id,
|
||||||
|
'routine_id': self.routine_id,
|
||||||
|
'date': self.date,
|
||||||
|
})
|
||||||
|
return base
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
from dataclasses import dataclass
|
||||||
|
from models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RoutineItem(BaseModel):
|
||||||
|
routine_id: str
|
||||||
|
name: str
|
||||||
|
image_id: str | None = None
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict):
|
||||||
|
return cls(
|
||||||
|
routine_id=d.get('routine_id'),
|
||||||
|
name=d.get('name'),
|
||||||
|
image_id=d.get('image_id'),
|
||||||
|
order=d.get('order', 0),
|
||||||
|
id=d.get('id'),
|
||||||
|
created_at=d.get('created_at'),
|
||||||
|
updated_at=d.get('updated_at')
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self):
|
||||||
|
base = super().to_dict()
|
||||||
|
base.update({
|
||||||
|
'routine_id': self.routine_id,
|
||||||
|
'name': self.name,
|
||||||
|
'image_id': self.image_id,
|
||||||
|
'order': self.order
|
||||||
|
})
|
||||||
|
return base
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Literal
|
||||||
|
from models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RoutineSchedule(BaseModel):
|
||||||
|
child_id: str
|
||||||
|
routine_id: str
|
||||||
|
mode: Literal['days', 'interval']
|
||||||
|
|
||||||
|
day_configs: list = field(default_factory=list)
|
||||||
|
default_hour: int = 8
|
||||||
|
default_minute: int = 0
|
||||||
|
default_has_deadline: bool = True
|
||||||
|
|
||||||
|
interval_days: int = 2
|
||||||
|
anchor_date: str = ""
|
||||||
|
interval_has_deadline: bool = True
|
||||||
|
interval_hour: int = 0
|
||||||
|
interval_minute: int = 0
|
||||||
|
|
||||||
|
enabled: bool = True
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, d: dict) -> 'RoutineSchedule':
|
||||||
|
return cls(
|
||||||
|
child_id=d.get('child_id'),
|
||||||
|
routine_id=d.get('routine_id'),
|
||||||
|
mode=d.get('mode', 'days'),
|
||||||
|
day_configs=d.get('day_configs', []),
|
||||||
|
default_hour=d.get('default_hour', 8),
|
||||||
|
default_minute=d.get('default_minute', 0),
|
||||||
|
default_has_deadline=d.get('default_has_deadline', True),
|
||||||
|
interval_days=d.get('interval_days', 2),
|
||||||
|
anchor_date=d.get('anchor_date', ''),
|
||||||
|
interval_has_deadline=d.get('interval_has_deadline', True),
|
||||||
|
interval_hour=d.get('interval_hour', 0),
|
||||||
|
interval_minute=d.get('interval_minute', 0),
|
||||||
|
enabled=d.get('enabled', True),
|
||||||
|
id=d.get('id'),
|
||||||
|
created_at=d.get('created_at'),
|
||||||
|
updated_at=d.get('updated_at'),
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
base = super().to_dict()
|
||||||
|
base.update({
|
||||||
|
'child_id': self.child_id,
|
||||||
|
'routine_id': self.routine_id,
|
||||||
|
'mode': self.mode,
|
||||||
|
'day_configs': self.day_configs,
|
||||||
|
'default_hour': self.default_hour,
|
||||||
|
'default_minute': self.default_minute,
|
||||||
|
'default_has_deadline': self.default_has_deadline,
|
||||||
|
'interval_days': self.interval_days,
|
||||||
|
'anchor_date': self.anchor_date,
|
||||||
|
'interval_has_deadline': self.interval_has_deadline,
|
||||||
|
'interval_hour': self.interval_hour,
|
||||||
|
'interval_minute': self.interval_minute,
|
||||||
|
'enabled': self.enabled,
|
||||||
|
})
|
||||||
|
return base
|
||||||
@@ -22,6 +22,11 @@ class User(BaseModel):
|
|||||||
deletion_attempted_at: str | None = None
|
deletion_attempted_at: str | None = None
|
||||||
role: str = 'user'
|
role: str = 'user'
|
||||||
token_version: int = 0
|
token_version: int = 0
|
||||||
|
timezone: str | None = None
|
||||||
|
email_digest_enabled: bool = True
|
||||||
|
push_notifications_enabled: bool = True
|
||||||
|
tutorial_enabled: bool = True
|
||||||
|
tutorial_progress: dict = field(default_factory=dict)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, d: dict):
|
def from_dict(cls, d: dict):
|
||||||
@@ -45,6 +50,11 @@ class User(BaseModel):
|
|||||||
deletion_attempted_at=d.get('deletion_attempted_at'),
|
deletion_attempted_at=d.get('deletion_attempted_at'),
|
||||||
role=d.get('role', 'user'),
|
role=d.get('role', 'user'),
|
||||||
token_version=d.get('token_version', 0),
|
token_version=d.get('token_version', 0),
|
||||||
|
timezone=d.get('timezone'),
|
||||||
|
email_digest_enabled=d.get('email_digest_enabled', True),
|
||||||
|
push_notifications_enabled=d.get('push_notifications_enabled', True),
|
||||||
|
tutorial_enabled=d.get('tutorial_enabled', True),
|
||||||
|
tutorial_progress=d.get('tutorial_progress', {}) or {},
|
||||||
id=d.get('id'),
|
id=d.get('id'),
|
||||||
created_at=d.get('created_at'),
|
created_at=d.get('created_at'),
|
||||||
updated_at=d.get('updated_at')
|
updated_at=d.get('updated_at')
|
||||||
@@ -73,5 +83,10 @@ class User(BaseModel):
|
|||||||
'deletion_attempted_at': self.deletion_attempted_at,
|
'deletion_attempted_at': self.deletion_attempted_at,
|
||||||
'role': self.role,
|
'role': self.role,
|
||||||
'token_version': self.token_version,
|
'token_version': self.token_version,
|
||||||
|
'timezone': self.timezone,
|
||||||
|
'email_digest_enabled': self.email_digest_enabled,
|
||||||
|
'push_notifications_enabled': self.push_notifications_enabled,
|
||||||
|
'tutorial_enabled': self.tutorial_enabled,
|
||||||
|
'tutorial_progress': self.tutorial_progress,
|
||||||
})
|
})
|
||||||
return base
|
return base
|
||||||
|
|||||||
Binary file not shown.
@@ -42,23 +42,35 @@ def create_admin_user(email: str, password: str, first_name: str, last_name: str
|
|||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
print("=== Create Admin User ===\n")
|
print("=== Create Admin User ===\n")
|
||||||
|
|
||||||
email = input("Email: ").strip()
|
env_email = os.environ.get('ADMIN_EMAIL')
|
||||||
password = input("Password: ").strip()
|
env_password = os.environ.get('ADMIN_PASSWORD')
|
||||||
first_name = input("First name: ").strip()
|
env_first_name = os.environ.get('ADMIN_FIRST_NAME')
|
||||||
last_name = input("Last name: ").strip()
|
env_last_name = os.environ.get('ADMIN_LAST_NAME')
|
||||||
|
|
||||||
if not all([email, password, first_name, last_name]):
|
if env_email and env_password:
|
||||||
print("Error: All fields are required")
|
email = env_email
|
||||||
sys.exit(1)
|
password = env_password
|
||||||
|
first_name = env_first_name or 'First Name'
|
||||||
|
last_name = env_last_name or 'Last Name'
|
||||||
|
print(f"Using environment variables for admin user '{email}'")
|
||||||
|
else:
|
||||||
|
email = input("Email: ").strip()
|
||||||
|
password = input("Password: ").strip()
|
||||||
|
first_name = input("First name: ").strip()
|
||||||
|
last_name = input("Last name: ").strip()
|
||||||
|
|
||||||
|
if not all([email, password, first_name, last_name]):
|
||||||
|
print("Error: All fields are required")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
confirm = input(f"\nCreate admin user '{email}'? (yes/no): ").strip().lower()
|
||||||
|
if confirm != 'yes':
|
||||||
|
print("Cancelled")
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
if len(password) < 8:
|
if len(password) < 8:
|
||||||
print("Error: Password must be at least 8 characters")
|
print("Error: Password must be at least 8 characters")
|
||||||
sys.exit(1)
|
sys.exit(1)
|
||||||
|
|
||||||
confirm = input(f"\nCreate admin user '{email}'? (yes/no): ").strip().lower()
|
create_admin_user(email, password, first_name, last_name)
|
||||||
|
|
||||||
if confirm == 'yes':
|
|
||||||
create_admin_user(email, password, first_name, last_name)
|
|
||||||
else:
|
|
||||||
print("Cancelled")
|
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
# python
|
||||||
|
"""
|
||||||
|
Migrate existing TinyDB JSON files into MongoDB.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
cd backend
|
||||||
|
python -m scripts.migrate_to_mongodb [--dry-run]
|
||||||
|
|
||||||
|
The script reads files from ``data/db/`` (or ``test_data/db/`` when
|
||||||
|
``DB_ENV=test``), maps each record's ``id`` field to MongoDB's ``_id`` field,
|
||||||
|
and inserts the records idempotently. TinyDB files are backed up to
|
||||||
|
``<db_dir>/backups/<timestamp>/`` before the first migration run.
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||||
|
|
||||||
|
from config.paths import get_database_dir
|
||||||
|
from db.db import COLLECTION_INDEXES, ensure_mongodb_indexes
|
||||||
|
from db.mongo_client import get_mongo_client, get_mongo_db_name
|
||||||
|
|
||||||
|
|
||||||
|
# Map TinyDB JSON filenames to MongoDB collection names.
|
||||||
|
COLLECTION_FILE_MAP = {
|
||||||
|
'children.json': 'children',
|
||||||
|
'tasks.json': 'tasks',
|
||||||
|
'routines.json': 'routines',
|
||||||
|
'routine_items.json': 'routine_items',
|
||||||
|
'routine_schedules.json': 'routine_schedules',
|
||||||
|
'routine_extensions.json': 'routine_extensions',
|
||||||
|
'rewards.json': 'rewards',
|
||||||
|
'images.json': 'images',
|
||||||
|
'pending_rewards.json': 'pending_rewards',
|
||||||
|
'pending_confirmations.json': 'pending_confirmations',
|
||||||
|
'users.json': 'users',
|
||||||
|
'tracking_events.json': 'tracking_events',
|
||||||
|
'child_overrides.json': 'child_overrides',
|
||||||
|
'chore_schedules.json': 'chore_schedules',
|
||||||
|
'task_extensions.json': 'task_extensions',
|
||||||
|
'refresh_tokens.json': 'refresh_tokens',
|
||||||
|
'push_subscriptions.json': 'push_subscriptions',
|
||||||
|
'digest_action_tokens.json': 'digest_action_tokens',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _load_tinydb_records(path: str) -> list[dict]:
|
||||||
|
"""Load all records from a TinyDB JSON file."""
|
||||||
|
with open(path, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
default_table = data.get('_default', {})
|
||||||
|
return list(default_table.values())
|
||||||
|
|
||||||
|
|
||||||
|
def _doc_to_mongo(doc: dict) -> dict:
|
||||||
|
"""Map the model ``id`` field to MongoDB's ``_id`` field.
|
||||||
|
|
||||||
|
The original ``id`` field is removed so documents do not store both
|
||||||
|
``_id`` and ``id`` with identical values.
|
||||||
|
"""
|
||||||
|
mongo_doc = dict(doc)
|
||||||
|
if 'id' in mongo_doc:
|
||||||
|
mongo_doc['_id'] = mongo_doc.pop('id')
|
||||||
|
return mongo_doc
|
||||||
|
|
||||||
|
|
||||||
|
def migrate(dry_run: bool = False) -> dict:
|
||||||
|
"""Migrate TinyDB files to MongoDB and return a per-collection summary."""
|
||||||
|
db_dir = get_database_dir()
|
||||||
|
if not os.path.isdir(db_dir):
|
||||||
|
raise FileNotFoundError(f'Database directory does not exist: {db_dir}')
|
||||||
|
|
||||||
|
client = get_mongo_client()
|
||||||
|
db_name = get_mongo_db_name()
|
||||||
|
db = client[db_name]
|
||||||
|
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
backup_dir = os.path.join(db_dir, 'backups', timestamp)
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
os.makedirs(backup_dir, exist_ok=True)
|
||||||
|
ensure_mongodb_indexes(client=client, db_name=db_name)
|
||||||
|
|
||||||
|
summary: dict[str, dict] = {}
|
||||||
|
|
||||||
|
for filename, collection_name in COLLECTION_FILE_MAP.items():
|
||||||
|
path = os.path.join(db_dir, filename)
|
||||||
|
if not os.path.exists(path):
|
||||||
|
summary[collection_name] = {
|
||||||
|
'source_file': filename,
|
||||||
|
'total': 0,
|
||||||
|
'migrated': 0,
|
||||||
|
'skipped': 0,
|
||||||
|
'status': 'missing',
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
records = _load_tinydb_records(path)
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
shutil.copy2(path, backup_dir)
|
||||||
|
|
||||||
|
collection = db[collection_name]
|
||||||
|
to_insert: list[dict] = []
|
||||||
|
skipped = 0
|
||||||
|
|
||||||
|
for record in records:
|
||||||
|
doc_id = record.get('id')
|
||||||
|
if not doc_id:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not dry_run:
|
||||||
|
existing = collection.find_one({'_id': doc_id})
|
||||||
|
if existing:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
to_insert.append(_doc_to_mongo(record))
|
||||||
|
|
||||||
|
if not dry_run and to_insert:
|
||||||
|
try:
|
||||||
|
collection.insert_many(to_insert, ordered=False)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
print(
|
||||||
|
f' Warning: error inserting into {collection_name}: {exc}',
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
summary[collection_name] = {
|
||||||
|
'source_file': filename,
|
||||||
|
'total': len(records),
|
||||||
|
'migrated': len(to_insert),
|
||||||
|
'skipped': skipped,
|
||||||
|
'status': 'migrated' if not dry_run else 'dry-run',
|
||||||
|
}
|
||||||
|
|
||||||
|
return summary
|
||||||
|
|
||||||
|
|
||||||
|
def _print_summary(summary: dict) -> None:
|
||||||
|
"""Print a human-readable migration summary."""
|
||||||
|
print('\nMigration Summary')
|
||||||
|
print('-' * 70)
|
||||||
|
print(f'{"Collection":<30}{"Total":>8}{"Migrated":>10}{"Skipped":>10}{"Status":>10}')
|
||||||
|
print('-' * 70)
|
||||||
|
total_records = 0
|
||||||
|
total_migrated = 0
|
||||||
|
total_skipped = 0
|
||||||
|
for collection_name, info in summary.items():
|
||||||
|
print(
|
||||||
|
f'{collection_name:<30}'
|
||||||
|
f'{info["total"]:>8}'
|
||||||
|
f'{info["migrated"]:>10}'
|
||||||
|
f'{info["skipped"]:>10}'
|
||||||
|
f'{info["status"]:>10}'
|
||||||
|
)
|
||||||
|
total_records += info['total']
|
||||||
|
total_migrated += info['migrated']
|
||||||
|
total_skipped += info['skipped']
|
||||||
|
print('-' * 70)
|
||||||
|
print(
|
||||||
|
f'{"TOTAL":<30}'
|
||||||
|
f'{total_records:>8}'
|
||||||
|
f'{total_migrated:>10}'
|
||||||
|
f'{total_skipped:>10}'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description='Migrate TinyDB JSON files to MongoDB.'
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
'--dry-run',
|
||||||
|
action='store_true',
|
||||||
|
help='Analyze files and report counts without writing to MongoDB.',
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if os.environ.get('USE_MONGODB', 'true').lower() != 'true':
|
||||||
|
print('Set USE_MONGODB=true to run the migration.', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if not os.environ.get('MONGO_URI'):
|
||||||
|
print('MONGO_URI is required when USE_MONGODB=true.', file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if args.dry_run:
|
||||||
|
print('Dry run: no data will be written to MongoDB.')
|
||||||
|
|
||||||
|
summary = migrate(dry_run=args.dry_run)
|
||||||
|
_print_summary(summary)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
<#
|
||||||
|
.SYNOPSIS
|
||||||
|
Run the MongoDB adapter integration tests against a local Docker MongoDB container.
|
||||||
|
|
||||||
|
.DESCRIPTION
|
||||||
|
Starts a temporary MongoDB container, runs a targeted pytest suite with
|
||||||
|
USE_MONGODB=true, then stops and removes the container.
|
||||||
|
|
||||||
|
.EXAMPLE
|
||||||
|
cd backend
|
||||||
|
.\scripts\run_integration_tests.ps1
|
||||||
|
#>
|
||||||
|
[CmdletBinding()]
|
||||||
|
param(
|
||||||
|
[string]$ContainerName = 'chore-db-integration-test',
|
||||||
|
[int]$HostPort = 27017,
|
||||||
|
[string]$Image = 'mongo:8',
|
||||||
|
[string]$DbName = 'chore_db_test',
|
||||||
|
[string]$TestPath = 'tests/test_mongo_adapter.py'
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$mongoUri = "mongodb://localhost:${HostPort}/${DbName}"
|
||||||
|
|
||||||
|
function Test-ContainerRunning {
|
||||||
|
$containers = docker ps --filter "name=$ContainerName" --format '{{.Names}}' 2>$null
|
||||||
|
return $containers -contains $ContainerName
|
||||||
|
}
|
||||||
|
|
||||||
|
function Wait-MongoReady {
|
||||||
|
param([int]$TimeoutSeconds = 30)
|
||||||
|
$start = Get-Date
|
||||||
|
while (((Get-Date) - $start).TotalSeconds -lt $TimeoutSeconds) {
|
||||||
|
try {
|
||||||
|
$null = docker exec $ContainerName mongosh --eval 'db.adminCommand({ ping: 1 })' --quiet 2>$null
|
||||||
|
if ($LASTEXITCODE -eq 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
# Container or mongosh may not be ready yet.
|
||||||
|
}
|
||||||
|
Start-Sleep -Seconds 1
|
||||||
|
}
|
||||||
|
throw "MongoDB container did not become ready within ${TimeoutSeconds} seconds."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Clean up any leftover container from a previous aborted run.
|
||||||
|
if (Test-ContainerRunning) {
|
||||||
|
Write-Host "Removing existing container '$ContainerName'..."
|
||||||
|
docker rm -f $ContainerName | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Starting MongoDB container '$ContainerName' on port $HostPort..."
|
||||||
|
docker run -d `
|
||||||
|
--name $ContainerName `
|
||||||
|
-p "${HostPort}:27017" `
|
||||||
|
$Image | Out-Null
|
||||||
|
|
||||||
|
try {
|
||||||
|
Wait-MongoReady
|
||||||
|
Write-Host "MongoDB is ready. Running integration tests..."
|
||||||
|
|
||||||
|
$env:USE_MONGODB = 'true'
|
||||||
|
$env:MONGO_URI = $mongoUri
|
||||||
|
$env:MONGO_DB_NAME = $DbName
|
||||||
|
$env:DB_ENV = 'test'
|
||||||
|
$env:DATA_ENV = 'test'
|
||||||
|
|
||||||
|
pytest $TestPath
|
||||||
|
if ($LASTEXITCODE -ne 0) {
|
||||||
|
throw "Integration tests failed with exit code $LASTEXITCODE."
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
Write-Host "Stopping and removing container '$ContainerName'..."
|
||||||
|
docker rm -f $ContainerName | Out-Null
|
||||||
|
}
|
||||||
|
|
||||||
|
Write-Host "Integration tests complete."
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"""
|
||||||
|
Script to seed a test user account from environment variables.
|
||||||
|
Intended for use in the test/staging deployment pipeline.
|
||||||
|
|
||||||
|
Required environment variables:
|
||||||
|
SEED_EMAIL - User email address
|
||||||
|
SEED_PASSWORD - Plain-text password (will be hashed)
|
||||||
|
SEED_PIN - 4-6 digit parent PIN (stored as plain text)
|
||||||
|
SEED_FIRST_NAME - User first name
|
||||||
|
SEED_LAST_NAME - User last name
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/seed_test_user.py
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||||
|
|
||||||
|
from db.db import users_db
|
||||||
|
from models.user import User
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
from tinydb import Query
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
|
||||||
|
def seed_test_user() -> bool:
|
||||||
|
email = os.environ.get('SEED_EMAIL', '').strip().lower()
|
||||||
|
password = os.environ.get('SEED_PASSWORD', '').strip()
|
||||||
|
pin = os.environ.get('SEED_PIN', '').strip()
|
||||||
|
first_name = os.environ.get('SEED_FIRST_NAME', '').strip()
|
||||||
|
last_name = os.environ.get('SEED_LAST_NAME', '').strip()
|
||||||
|
|
||||||
|
missing = [name for name, val in [
|
||||||
|
('SEED_EMAIL', email),
|
||||||
|
('SEED_PASSWORD', password),
|
||||||
|
('SEED_PIN', pin),
|
||||||
|
('SEED_FIRST_NAME', first_name),
|
||||||
|
('SEED_LAST_NAME', last_name),
|
||||||
|
] if not val]
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
print(f"Error: Missing required environment variables: {', '.join(missing)}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not (4 <= len(pin) <= 6 and pin.isdigit()):
|
||||||
|
print("Error: SEED_PIN must be 4-6 digits")
|
||||||
|
return False
|
||||||
|
|
||||||
|
hashed_password = generate_password_hash(password)
|
||||||
|
Query_ = Query()
|
||||||
|
existing = users_db.get(Query_.email == email)
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
users_db.update(
|
||||||
|
{'password': hashed_password, 'pin': pin},
|
||||||
|
Query_.email == email
|
||||||
|
)
|
||||||
|
print(f"✓ Test user updated successfully")
|
||||||
|
print(f" Email: {email}")
|
||||||
|
print(f" Name: {first_name} {last_name}")
|
||||||
|
print(f" PIN: {'*' * len(pin)}")
|
||||||
|
else:
|
||||||
|
user = User(
|
||||||
|
id=str(uuid.uuid4()),
|
||||||
|
email=email,
|
||||||
|
first_name=first_name,
|
||||||
|
last_name=last_name,
|
||||||
|
password=hashed_password,
|
||||||
|
pin=pin,
|
||||||
|
verified=True,
|
||||||
|
role='user',
|
||||||
|
)
|
||||||
|
users_db.insert(user.to_dict())
|
||||||
|
print(f"✓ Test user created successfully")
|
||||||
|
print(f" Email: {email}")
|
||||||
|
print(f" Name: {first_name} {last_name}")
|
||||||
|
print(f" PIN: {'*' * len(pin)}")
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
print("=== Seed Test User ===\n")
|
||||||
|
success = seed_test_user()
|
||||||
|
sys.exit(0 if success else 1)
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"""
|
||||||
|
Script to trigger a digest email for a specific user via the admin API.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/send_digest.py <email>
|
||||||
|
|
||||||
|
The script logs in as an admin using the ADMIN_EMAIL and ADMIN_PASSWORD
|
||||||
|
environment variables (defaults to localhost:5000).
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
ADMIN_EMAIL - Admin account email (required)
|
||||||
|
ADMIN_PASSWORD - Admin account password (required)
|
||||||
|
API_BASE_URL - Base URL of the backend API (default: http://localhost:5000)
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
# NOTE: When targeting a proxied frontend URL (e.g. nginx), append /api to the URL.
|
||||||
|
# e.g. API_BASE_URL=https://dev.chores.ryankegel.com/api
|
||||||
|
# Without /api, nginx will not forward requests to the backend and will return 405.
|
||||||
|
API_BASE_URL = os.environ.get('API_BASE_URL', 'http://localhost:5000').rstrip('/')
|
||||||
|
|
||||||
|
|
||||||
|
def _post(path: str, body: dict, cookie: str | None = None) -> tuple[int, dict]:
|
||||||
|
url = f"{API_BASE_URL}{path}"
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if cookie:
|
||||||
|
headers['Cookie'] = cookie
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method='POST')
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return resp.status, json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(e.read())
|
||||||
|
except Exception:
|
||||||
|
return e.code, {}
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print('Usage: python scripts/send_digest.py <email>')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
target_email = sys.argv[1].strip()
|
||||||
|
|
||||||
|
admin_email = os.environ.get('ADMIN_EMAIL', '').strip()
|
||||||
|
admin_password = os.environ.get('ADMIN_PASSWORD', '').strip()
|
||||||
|
|
||||||
|
if not admin_email or not admin_password:
|
||||||
|
print('Error: ADMIN_EMAIL and ADMIN_PASSWORD environment variables are required')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Log in to get a session cookie
|
||||||
|
status, body = _post('/auth/login', {'email': admin_email, 'password': admin_password})
|
||||||
|
if status != 200:
|
||||||
|
print(f'Login failed ({status}): {body.get("error", body)}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# urllib doesn't automatically store cookies; we need to capture Set-Cookie manually.
|
||||||
|
# Re-do the login request to grab the cookie header.
|
||||||
|
login_url = f"{API_BASE_URL}/auth/login"
|
||||||
|
login_data = json.dumps({'email': admin_email, 'password': admin_password}).encode()
|
||||||
|
login_req = urllib.request.Request(
|
||||||
|
login_url,
|
||||||
|
data=login_data,
|
||||||
|
headers={'Content-Type': 'application/json'},
|
||||||
|
method='POST',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(login_req) as resp:
|
||||||
|
raw_cookies = resp.headers.get_all('Set-Cookie') or []
|
||||||
|
except urllib.error.HTTPError:
|
||||||
|
print('Login failed (could not retrieve cookie)')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Extract access_token cookie value
|
||||||
|
cookie_header = '; '.join(
|
||||||
|
part.split(';')[0] for part in raw_cookies
|
||||||
|
)
|
||||||
|
if not cookie_header:
|
||||||
|
print('Login succeeded but no cookies were returned')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Call the send-digest endpoint
|
||||||
|
status, body = _post(
|
||||||
|
'/admin/test/send-digest',
|
||||||
|
{'email': target_email},
|
||||||
|
cookie=cookie_header,
|
||||||
|
)
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
items_sent = body.get('items_sent', 0)
|
||||||
|
if items_sent == 0:
|
||||||
|
print(f'No pending items found for {target_email} — no email sent.')
|
||||||
|
else:
|
||||||
|
print(f'Digest sent to {target_email} with {items_sent} item(s).')
|
||||||
|
elif status == 404 and body.get('code') == 'NOT_FOUND':
|
||||||
|
print('Error: This endpoint is disabled in the current environment (DB_ENV=production).')
|
||||||
|
sys.exit(1)
|
||||||
|
elif status == 404 and body.get('code') == 'USER_NOT_FOUND':
|
||||||
|
print(f'Error: No user found with email {target_email}')
|
||||||
|
sys.exit(1)
|
||||||
|
elif status == 403:
|
||||||
|
print(f'Error: {admin_email} is not an admin account.')
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print(f'Error ({status}): {body.get("error", body)}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
"""
|
||||||
|
Script to trigger the chore expiry notification check for a specific user via the admin API.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python scripts/trigger_chore_expiry.py <email>
|
||||||
|
|
||||||
|
The script logs in as an admin using the ADMIN_EMAIL and ADMIN_PASSWORD
|
||||||
|
environment variables (defaults to localhost:5000).
|
||||||
|
|
||||||
|
Environment variables:
|
||||||
|
ADMIN_EMAIL - Admin account email (required)
|
||||||
|
ADMIN_PASSWORD - Admin account password (required)
|
||||||
|
API_BASE_URL - Base URL of the backend API (default: http://localhost:5000)
|
||||||
|
|
||||||
|
NOTE: When targeting a proxied frontend URL (e.g. nginx), append /api to the URL.
|
||||||
|
e.g. API_BASE_URL=https://dev.chores.ryankegel.com/api
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import urllib.request
|
||||||
|
import urllib.error
|
||||||
|
|
||||||
|
API_BASE_URL = os.environ.get('API_BASE_URL', 'http://localhost:5000').rstrip('/')
|
||||||
|
|
||||||
|
|
||||||
|
def _post(path: str, body: dict, cookie: str | None = None) -> tuple[int, dict]:
|
||||||
|
url = f"{API_BASE_URL}{path}"
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
if cookie:
|
||||||
|
headers['Cookie'] = cookie
|
||||||
|
req = urllib.request.Request(url, data=data, headers=headers, method='POST')
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req) as resp:
|
||||||
|
return resp.status, json.loads(resp.read())
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
try:
|
||||||
|
return e.code, json.loads(e.read())
|
||||||
|
except Exception:
|
||||||
|
return e.code, {}
|
||||||
|
|
||||||
|
|
||||||
|
def _login() -> str:
|
||||||
|
"""Log in as admin and return the cookie header string."""
|
||||||
|
admin_email = os.environ.get('ADMIN_EMAIL', '').strip()
|
||||||
|
admin_password = os.environ.get('ADMIN_PASSWORD', '').strip()
|
||||||
|
|
||||||
|
if not admin_email or not admin_password:
|
||||||
|
print('Error: ADMIN_EMAIL and ADMIN_PASSWORD environment variables are required')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
login_url = f"{API_BASE_URL}/auth/login"
|
||||||
|
login_data = json.dumps({'email': admin_email, 'password': admin_password}).encode()
|
||||||
|
login_req = urllib.request.Request(
|
||||||
|
login_url,
|
||||||
|
data=login_data,
|
||||||
|
headers={'Content-Type': 'application/json'},
|
||||||
|
method='POST',
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(login_req) as resp:
|
||||||
|
raw_cookies = resp.headers.get_all('Set-Cookie') or []
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
print(f'Login failed ({e.code})')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
cookie_header = '; '.join(part.split(';')[0] for part in raw_cookies)
|
||||||
|
if not cookie_header:
|
||||||
|
print('Login succeeded but no cookies were returned')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
return cookie_header
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
if len(sys.argv) != 2:
|
||||||
|
print('Usage: python scripts/trigger_chore_expiry.py <email>')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
target_email = sys.argv[1].strip()
|
||||||
|
cookie = _login()
|
||||||
|
|
||||||
|
status, body = _post(
|
||||||
|
'/admin/test/trigger-chore-expiry',
|
||||||
|
{'email': target_email},
|
||||||
|
cookie=cookie,
|
||||||
|
)
|
||||||
|
|
||||||
|
if status == 200:
|
||||||
|
count = body.get('chores_notified', 0)
|
||||||
|
if count == 0:
|
||||||
|
print(f'No expiring chores found for {target_email} in the next 75 minutes — no push sent.')
|
||||||
|
else:
|
||||||
|
print(f'Push notification sent for {count} expiring chore(s) for {target_email}.')
|
||||||
|
elif status == 404 and body.get('code') == 'NOT_FOUND':
|
||||||
|
print('Error: This endpoint is disabled in the current environment (DB_ENV=production).')
|
||||||
|
sys.exit(1)
|
||||||
|
elif status == 404 and body.get('code') == 'USER_NOT_FOUND':
|
||||||
|
print(f'Error: No user found with email {target_email}')
|
||||||
|
sys.exit(1)
|
||||||
|
elif status == 403:
|
||||||
|
admin_email = os.environ.get('ADMIN_EMAIL', '')
|
||||||
|
print(f'Error: {admin_email} is not an admin account.')
|
||||||
|
sys.exit(1)
|
||||||
|
else:
|
||||||
|
print(f'Error ({status}): {body.get("error", body)}')
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
import os
|
import os
|
||||||
os.environ['DB_ENV'] = 'test'
|
os.environ['DB_ENV'] = 'test'
|
||||||
|
os.environ['USE_MONGODB'] = 'true'
|
||||||
|
os.environ['MONGO_URI'] = 'mongomock'
|
||||||
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
|
os.environ.setdefault('SECRET_KEY', 'test-secret-key')
|
||||||
os.environ.setdefault('REFRESH_TOKEN_EXPIRY_DAYS', '90')
|
os.environ.setdefault('REFRESH_TOKEN_EXPIRY_DAYS', '90')
|
||||||
|
os.environ.setdefault('DIGEST_TOKEN_SECRET', 'test-digest-secret')
|
||||||
|
os.environ.setdefault('VAPID_PUBLIC_KEY', 'test-vapid-public-key')
|
||||||
|
os.environ.setdefault('VAPID_PRIVATE_KEY', 'test-vapid-private-key')
|
||||||
import sys
|
import sys
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
@@ -15,5 +20,11 @@ TEST_REFRESH_TOKEN_EXPIRY_DAYS = 90
|
|||||||
@pytest.fixture(scope="session", autouse=True)
|
@pytest.fixture(scope="session", autouse=True)
|
||||||
def set_test_db_env():
|
def set_test_db_env():
|
||||||
os.environ['DB_ENV'] = 'test'
|
os.environ['DB_ENV'] = 'test'
|
||||||
|
os.environ['USE_MONGODB'] = 'true'
|
||||||
|
os.environ['MONGO_URI'] = 'mongomock'
|
||||||
os.environ['SECRET_KEY'] = TEST_SECRET_KEY
|
os.environ['SECRET_KEY'] = TEST_SECRET_KEY
|
||||||
os.environ['REFRESH_TOKEN_EXPIRY_DAYS'] = str(TEST_REFRESH_TOKEN_EXPIRY_DAYS)
|
os.environ['REFRESH_TOKEN_EXPIRY_DAYS'] = str(TEST_REFRESH_TOKEN_EXPIRY_DAYS)
|
||||||
|
# Ensure indexes are created once for the test session. This is safe to
|
||||||
|
# call repeatedly because MongoDB treats index creation as idempotent.
|
||||||
|
from db.db import ensure_mongodb_indexes
|
||||||
|
ensure_mongodb_indexes()
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import pytest
|
import pytest
|
||||||
from werkzeug.security import generate_password_hash, check_password_hash
|
from werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from api.auth_api import auth_api
|
from api.auth_api import auth_api, _hash_token
|
||||||
from db.db import users_db, refresh_tokens_db
|
from db.db import users_db, refresh_tokens_db
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
from models.user import User
|
from models.user import User
|
||||||
from datetime import datetime
|
from datetime import datetime, timedelta, timezone
|
||||||
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@@ -188,4 +188,245 @@ def test_migration_script_hashes_plain_text_passwords():
|
|||||||
|
|
||||||
# Check user2 password unchanged
|
# Check user2 password unchanged
|
||||||
user2_dict = users_db.get(Query().email == 'test2@example.com')
|
user2_dict = users_db.get(Query().email == 'test2@example.com')
|
||||||
assert user2_dict['password'] == already_hashed
|
assert user2_dict['password'] == already_hashed
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_cookie_value(response, cookie_name):
|
||||||
|
"""Extract a raw cookie value from a response's Set-Cookie headers."""
|
||||||
|
for cookie in response.headers.getlist('Set-Cookie'):
|
||||||
|
if cookie.startswith(f'{cookie_name}='):
|
||||||
|
return cookie.split(f'{cookie_name}=', 1)[1].split(';', 1)[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _create_verified_user(email, password):
|
||||||
|
"""Insert a verified user with the given credentials."""
|
||||||
|
users_db.remove(Query().email == email)
|
||||||
|
user = User(
|
||||||
|
first_name='Test',
|
||||||
|
last_name='User',
|
||||||
|
email=email,
|
||||||
|
password=generate_password_hash(password),
|
||||||
|
verified=True,
|
||||||
|
)
|
||||||
|
users_db.insert(user.to_dict())
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
def _set_refresh_cookie(client, raw_token):
|
||||||
|
"""
|
||||||
|
Set the refresh token cookie on a test client so it is sent to /auth/refresh.
|
||||||
|
Production uses path='/api/auth' because the frontend calls /api/auth/refresh,
|
||||||
|
but the test fixture exposes the blueprint at /auth/refresh directly.
|
||||||
|
"""
|
||||||
|
client.set_cookie('refresh_token', raw_token, path='/')
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_rotates_token(client):
|
||||||
|
"""A successful refresh marks the old token used and issues a new one in the same family."""
|
||||||
|
email = 'refresh-rotate@test.com'
|
||||||
|
password = 'password123'
|
||||||
|
_create_verified_user(email, password)
|
||||||
|
|
||||||
|
login_response = client.post('/auth/login', json={'email': email, 'password': password})
|
||||||
|
assert login_response.status_code == 200
|
||||||
|
old_refresh = _extract_cookie_value(login_response, 'refresh_token')
|
||||||
|
assert old_refresh
|
||||||
|
|
||||||
|
_set_refresh_cookie(client, old_refresh)
|
||||||
|
refresh_response = client.post('/auth/refresh')
|
||||||
|
assert refresh_response.status_code == 200
|
||||||
|
new_refresh = _extract_cookie_value(refresh_response, 'refresh_token')
|
||||||
|
assert new_refresh
|
||||||
|
assert new_refresh != old_refresh
|
||||||
|
|
||||||
|
user_dict = users_db.get(Query().email == email)
|
||||||
|
old_hash = _hash_token(old_refresh)
|
||||||
|
new_hash = _hash_token(new_refresh)
|
||||||
|
|
||||||
|
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
|
||||||
|
assert old_record is not None
|
||||||
|
assert old_record['is_used'] is True
|
||||||
|
assert old_record['rotated_at'] is not None
|
||||||
|
|
||||||
|
new_record = refresh_tokens_db.get(Query().token_hash == new_hash)
|
||||||
|
assert new_record is not None
|
||||||
|
assert new_record['is_used'] is False
|
||||||
|
assert new_record['token_family'] == old_record['token_family']
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_reuse_only_invalidates_family(client):
|
||||||
|
"""Replay of a used refresh token only kills its own family, not other devices."""
|
||||||
|
email = 'refresh-family@test.com'
|
||||||
|
password = 'password123'
|
||||||
|
user = _create_verified_user(email, password)
|
||||||
|
|
||||||
|
# Device A logs in
|
||||||
|
client_a = client
|
||||||
|
login_a = client_a.post('/auth/login', json={'email': email, 'password': password})
|
||||||
|
assert login_a.status_code == 200
|
||||||
|
refresh_a = _extract_cookie_value(login_a, 'refresh_token')
|
||||||
|
|
||||||
|
# Device B logs in (separate client = separate cookie jar)
|
||||||
|
app = client_a.application
|
||||||
|
client_b = app.test_client()
|
||||||
|
login_b = client_b.post('/auth/login', json={'email': email, 'password': password})
|
||||||
|
assert login_b.status_code == 200
|
||||||
|
refresh_b = _extract_cookie_value(login_b, 'refresh_token')
|
||||||
|
|
||||||
|
assert refresh_a != refresh_b
|
||||||
|
|
||||||
|
# Device A refreshes normally
|
||||||
|
_set_refresh_cookie(client_a, refresh_a)
|
||||||
|
refresh_a_response = client_a.post('/auth/refresh')
|
||||||
|
assert refresh_a_response.status_code == 200
|
||||||
|
|
||||||
|
# Capture families before any purge so we can assert afterwards.
|
||||||
|
family_a = refresh_tokens_db.get(Query().token_hash == _hash_token(refresh_a))['token_family']
|
||||||
|
family_b = refresh_tokens_db.get(Query().token_hash == _hash_token(refresh_b))['token_family']
|
||||||
|
assert family_a != family_b
|
||||||
|
|
||||||
|
# Backdate rotation so the replay is past the grace period and treated as theft.
|
||||||
|
old_hash_a = _hash_token(refresh_a)
|
||||||
|
backdated = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat()
|
||||||
|
refresh_tokens_db.update({'rotated_at': backdated}, Query().token_hash == old_hash_a)
|
||||||
|
|
||||||
|
# Attacker replays device A's old token
|
||||||
|
_set_refresh_cookie(client_a, refresh_a)
|
||||||
|
reuse_response = client_a.post('/auth/refresh')
|
||||||
|
assert reuse_response.status_code == 401
|
||||||
|
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
|
||||||
|
|
||||||
|
# Device B's refresh token should still be valid
|
||||||
|
_set_refresh_cookie(client_b, refresh_b)
|
||||||
|
refresh_b_response = client_b.post('/auth/refresh')
|
||||||
|
assert refresh_b_response.status_code == 200
|
||||||
|
|
||||||
|
# Only family A should be purged; family B should remain
|
||||||
|
remaining = refresh_tokens_db.search(Query().user_id == user.id)
|
||||||
|
remaining_families = {t['token_family'] for t in remaining}
|
||||||
|
assert family_a not in remaining_families
|
||||||
|
assert family_b in remaining_families
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_reuse_within_grace_period_is_tolerated(client):
|
||||||
|
"""A replay within the grace period is treated as a race condition, not theft."""
|
||||||
|
email = 'refresh-race@test.com'
|
||||||
|
password = 'password123'
|
||||||
|
_create_verified_user(email, password)
|
||||||
|
|
||||||
|
login_response = client.post('/auth/login', json={'email': email, 'password': password})
|
||||||
|
assert login_response.status_code == 200
|
||||||
|
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
|
||||||
|
|
||||||
|
# First refresh marks the token as used
|
||||||
|
_set_refresh_cookie(client, refresh_token)
|
||||||
|
first_refresh = client.post('/auth/refresh')
|
||||||
|
assert first_refresh.status_code == 200
|
||||||
|
|
||||||
|
# Immediate replay (same legitimate client racing) should succeed
|
||||||
|
_set_refresh_cookie(client, refresh_token)
|
||||||
|
race_response = client.post('/auth/refresh')
|
||||||
|
assert race_response.status_code == 200
|
||||||
|
|
||||||
|
# The family should still be valid
|
||||||
|
user_dict = users_db.get(Query().email == email)
|
||||||
|
family = refresh_tokens_db.get(Query().token_hash == _hash_token(refresh_token))['token_family']
|
||||||
|
family_tokens = refresh_tokens_db.search(
|
||||||
|
(Query().user_id == user_dict['id']) & (Query().token_family == family)
|
||||||
|
)
|
||||||
|
assert len(family_tokens) >= 1
|
||||||
|
assert any(t['is_used'] is False for t in family_tokens)
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_reuse_after_grace_period_invalidates_family(client):
|
||||||
|
"""A replay after the grace period is treated as theft and kills only that family."""
|
||||||
|
email = 'refresh-theft@test.com'
|
||||||
|
password = 'password123'
|
||||||
|
user = _create_verified_user(email, password)
|
||||||
|
|
||||||
|
login_response = client.post('/auth/login', json={'email': email, 'password': password})
|
||||||
|
assert login_response.status_code == 200
|
||||||
|
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
|
||||||
|
|
||||||
|
# Refresh once, then backdate the rotation timestamp past the grace period
|
||||||
|
_set_refresh_cookie(client, refresh_token)
|
||||||
|
client.post('/auth/refresh')
|
||||||
|
|
||||||
|
old_hash = _hash_token(refresh_token)
|
||||||
|
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
|
||||||
|
old_family = old_record['token_family']
|
||||||
|
backdated = (datetime.now(timezone.utc) - timedelta(seconds=60)).isoformat()
|
||||||
|
refresh_tokens_db.update({'rotated_at': backdated}, Query().token_hash == old_hash)
|
||||||
|
|
||||||
|
# Replay now should be detected as theft
|
||||||
|
_set_refresh_cookie(client, refresh_token)
|
||||||
|
reuse_response = client.post('/auth/refresh')
|
||||||
|
assert reuse_response.status_code == 401
|
||||||
|
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
|
||||||
|
|
||||||
|
remaining = refresh_tokens_db.search(Query().user_id == user.id)
|
||||||
|
remaining_families = {t['token_family'] for t in remaining}
|
||||||
|
assert old_family not in remaining_families
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_reuse_without_rotated_at_invalidates_family(client):
|
||||||
|
"""Legacy used tokens without rotated_at are treated as theft, not race conditions."""
|
||||||
|
email = 'refresh-legacy@test.com'
|
||||||
|
password = 'password123'
|
||||||
|
user = _create_verified_user(email, password)
|
||||||
|
|
||||||
|
login_response = client.post('/auth/login', json={'email': email, 'password': password})
|
||||||
|
assert login_response.status_code == 200
|
||||||
|
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
|
||||||
|
|
||||||
|
# Rotate the token, then strip rotated_at to simulate pre-migration data
|
||||||
|
_set_refresh_cookie(client, refresh_token)
|
||||||
|
client.post('/auth/refresh')
|
||||||
|
old_hash = _hash_token(refresh_token)
|
||||||
|
refresh_tokens_db.update({'rotated_at': None}, Query().token_hash == old_hash)
|
||||||
|
|
||||||
|
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
|
||||||
|
old_family = old_record['token_family']
|
||||||
|
|
||||||
|
# Replay should be treated as theft because rotated_at is missing
|
||||||
|
_set_refresh_cookie(client, refresh_token)
|
||||||
|
reuse_response = client.post('/auth/refresh')
|
||||||
|
assert reuse_response.status_code == 401
|
||||||
|
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
|
||||||
|
|
||||||
|
remaining = refresh_tokens_db.search(Query().user_id == user.id)
|
||||||
|
remaining_families = {t['token_family'] for t in remaining}
|
||||||
|
assert old_family not in remaining_families
|
||||||
|
|
||||||
|
|
||||||
|
def test_refresh_reuse_with_zero_grace_period(client):
|
||||||
|
"""A grace period of 0 means any replay of a used token is treated as theft."""
|
||||||
|
email = 'refresh-zero-grace@test.com'
|
||||||
|
password = 'password123'
|
||||||
|
user = _create_verified_user(email, password)
|
||||||
|
|
||||||
|
# Configure the app with a 0-second grace period
|
||||||
|
client.application.config['REFRESH_TOKEN_REUSE_GRACE_PERIOD_SECONDS'] = 0
|
||||||
|
|
||||||
|
login_response = client.post('/auth/login', json={'email': email, 'password': password})
|
||||||
|
assert login_response.status_code == 200
|
||||||
|
refresh_token = _extract_cookie_value(login_response, 'refresh_token')
|
||||||
|
|
||||||
|
# Rotate the token; rotated_at is within the normal default grace period
|
||||||
|
_set_refresh_cookie(client, refresh_token)
|
||||||
|
client.post('/auth/refresh')
|
||||||
|
|
||||||
|
old_hash = _hash_token(refresh_token)
|
||||||
|
old_record = refresh_tokens_db.get(Query().token_hash == old_hash)
|
||||||
|
old_family = old_record['token_family']
|
||||||
|
|
||||||
|
# Immediate replay should still be theft with a 0-second grace period
|
||||||
|
_set_refresh_cookie(client, refresh_token)
|
||||||
|
reuse_response = client.post('/auth/refresh')
|
||||||
|
assert reuse_response.status_code == 401
|
||||||
|
assert reuse_response.json['code'] == 'REFRESH_TOKEN_REUSE'
|
||||||
|
|
||||||
|
remaining = refresh_tokens_db.search(Query().user_id == user.id)
|
||||||
|
remaining_families = {t['token_family'] for t in remaining}
|
||||||
|
assert old_family not in remaining_families
|
||||||
|
|||||||
@@ -4,13 +4,14 @@ import os
|
|||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from api.child_api import child_api
|
from api.child_api import child_api
|
||||||
|
import api.child_api as child_api_module
|
||||||
from api.auth_api import auth_api
|
from api.auth_api import auth_api
|
||||||
from db.db import child_db, reward_db, task_db, users_db, chore_schedules_db, task_extensions_db
|
from db.db import child_db, reward_db, task_db, users_db, chore_schedules_db, task_extensions_db, pending_confirmations_db
|
||||||
from tinydb import Query
|
from tinydb import Query
|
||||||
from models.child import Child
|
from models.child import Child
|
||||||
import jwt
|
import jwt
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type, datetime, timedelta, timezone
|
||||||
|
|
||||||
|
|
||||||
# Test user credentials
|
# Test user credentials
|
||||||
@@ -382,6 +383,7 @@ def _setup_sched_child_and_tasks(task_db, child_db):
|
|||||||
})
|
})
|
||||||
chore_schedules_db.remove(Query().child_id == CHILD_SCHED_ID)
|
chore_schedules_db.remove(Query().child_id == CHILD_SCHED_ID)
|
||||||
task_extensions_db.remove(Query().child_id == CHILD_SCHED_ID)
|
task_extensions_db.remove(Query().child_id == CHILD_SCHED_ID)
|
||||||
|
pending_confirmations_db.remove(Query().child_id == CHILD_SCHED_ID)
|
||||||
|
|
||||||
|
|
||||||
def test_list_child_tasks_always_has_schedule_and_extension_date_keys(client):
|
def test_list_child_tasks_always_has_schedule_and_extension_date_keys(client):
|
||||||
@@ -514,4 +516,316 @@ def test_list_child_tasks_no_server_side_filtering(client):
|
|||||||
returned_ids = {t['id'] for t in resp.get_json()['tasks']}
|
returned_ids = {t['id'] for t in resp.get_json()['tasks']}
|
||||||
# Both good tasks must be present; server never filters based on schedule/time
|
# Both good tasks must be present; server never filters based on schedule/time
|
||||||
assert TASK_GOOD_ID in returned_ids
|
assert TASK_GOOD_ID in returned_ids
|
||||||
assert extra_id in returned_ids
|
assert extra_id in returned_ids
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_child_tasks_shows_pending_for_today(client):
|
||||||
|
"""A chore confirmed today should return pending_status='pending'."""
|
||||||
|
_setup_sched_child_and_tasks(task_db, child_db)
|
||||||
|
now_ts = datetime.now(timezone.utc).timestamp()
|
||||||
|
pending_confirmations_db.insert({
|
||||||
|
'id': 'pend_today_chore',
|
||||||
|
'child_id': CHILD_SCHED_ID,
|
||||||
|
'entity_id': TASK_GOOD_ID,
|
||||||
|
'entity_type': 'chore',
|
||||||
|
'user_id': 'testuserid',
|
||||||
|
'status': 'pending',
|
||||||
|
'approved_at': None,
|
||||||
|
'created_at': now_ts,
|
||||||
|
'updated_at': now_ts,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
|
||||||
|
assert resp.status_code == 200
|
||||||
|
tasks = {t['id']: t for t in resp.get_json()['tasks']}
|
||||||
|
assert tasks[TASK_GOOD_ID]['pending_status'] == 'pending'
|
||||||
|
assert tasks[TASK_GOOD_ID]['approved_at'] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_child_tasks_clears_stale_approved_and_pending(client):
|
||||||
|
"""Yesterday's chore pending/approved records should be reset and ignored."""
|
||||||
|
_setup_sched_child_and_tasks(task_db, child_db)
|
||||||
|
old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp()
|
||||||
|
old_approved = (datetime.now(timezone.utc) - timedelta(days=2)).isoformat()
|
||||||
|
|
||||||
|
pending_confirmations_db.insert({
|
||||||
|
'id': 'pend_old_chore_pending',
|
||||||
|
'child_id': CHILD_SCHED_ID,
|
||||||
|
'entity_id': TASK_GOOD_ID,
|
||||||
|
'entity_type': 'chore',
|
||||||
|
'user_id': 'testuserid',
|
||||||
|
'status': 'pending',
|
||||||
|
'approved_at': None,
|
||||||
|
'created_at': old_ts,
|
||||||
|
'updated_at': old_ts,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
|
||||||
|
assert resp.status_code == 200
|
||||||
|
tasks = {t['id']: t for t in resp.get_json()['tasks']}
|
||||||
|
assert tasks[TASK_GOOD_ID]['pending_status'] is None
|
||||||
|
assert tasks[TASK_GOOD_ID]['approved_at'] is None
|
||||||
|
|
||||||
|
# Reinsert as stale approved and ensure it is also cleared.
|
||||||
|
pending_confirmations_db.insert({
|
||||||
|
'id': 'pend_old_chore_approved',
|
||||||
|
'child_id': CHILD_SCHED_ID,
|
||||||
|
'entity_id': TASK_GOOD_ID,
|
||||||
|
'entity_type': 'chore',
|
||||||
|
'user_id': 'testuserid',
|
||||||
|
'status': 'approved',
|
||||||
|
'approved_at': old_approved,
|
||||||
|
'created_at': old_ts,
|
||||||
|
'updated_at': old_ts,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp2 = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
|
||||||
|
assert resp2.status_code == 200
|
||||||
|
tasks2 = {t['id']: t for t in resp2.get_json()['tasks']}
|
||||||
|
assert tasks2[TASK_GOOD_ID]['pending_status'] is None
|
||||||
|
assert tasks2[TASK_GOOD_ID]['approved_at'] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_list_child_tasks_local_day_keeps_approved_across_utc_rollover(client, monkeypatch):
|
||||||
|
"""Approved chore should remain completed when UTC date differs but user-local day matches.
|
||||||
|
|
||||||
|
Example: 2026-05-11T22:30:00Z is 2026-05-12 local day in Pacific/Kiritimati (UTC+14).
|
||||||
|
"""
|
||||||
|
_setup_sched_child_and_tasks(task_db, child_db)
|
||||||
|
|
||||||
|
# Force deterministic local-day basis for this endpoint call.
|
||||||
|
monkeypatch.setattr(
|
||||||
|
child_api_module,
|
||||||
|
'_get_user_today_local',
|
||||||
|
lambda user_id: ('2026-05-12', 'Pacific/Kiritimati'),
|
||||||
|
)
|
||||||
|
|
||||||
|
pending_confirmations_db.insert({
|
||||||
|
'id': 'pend_local_day_approved',
|
||||||
|
'child_id': CHILD_SCHED_ID,
|
||||||
|
'entity_id': TASK_GOOD_ID,
|
||||||
|
'entity_type': 'chore',
|
||||||
|
'user_id': 'testuserid',
|
||||||
|
'status': 'approved',
|
||||||
|
'approved_at': '2026-05-11T22:30:00+00:00',
|
||||||
|
'created_at': 1778538600,
|
||||||
|
'updated_at': 1778538600,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = client.get(f'/child/{CHILD_SCHED_ID}/list-tasks')
|
||||||
|
assert resp.status_code == 200
|
||||||
|
tasks = {t['id']: t for t in resp.get_json()['tasks']}
|
||||||
|
assert tasks[TASK_GOOD_ID]['pending_status'] == 'approved'
|
||||||
|
assert tasks[TASK_GOOD_ID]['approved_at'] == '2026-05-11T22:30:00+00:00'
|
||||||
|
|
||||||
|
|
||||||
|
def test_confirm_chore_allows_when_previous_pending_is_stale(client):
|
||||||
|
"""A stale pending chore record from a prior day must not block confirm-chore."""
|
||||||
|
old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp()
|
||||||
|
task_db.insert({'id': 't_stale_chore', 'name': 'Stale Chore', 'points': 2, 'type': 'chore', 'user_id': 'testuserid'})
|
||||||
|
child_db.insert({
|
||||||
|
'id': 'child_stale_chore',
|
||||||
|
'name': 'Stale Chore Kid',
|
||||||
|
'age': 8,
|
||||||
|
'points': 0,
|
||||||
|
'tasks': ['t_stale_chore'],
|
||||||
|
'rewards': [],
|
||||||
|
'user_id': 'testuserid',
|
||||||
|
})
|
||||||
|
pending_confirmations_db.insert({
|
||||||
|
'id': 'pend_stale_chore',
|
||||||
|
'child_id': 'child_stale_chore',
|
||||||
|
'entity_id': 't_stale_chore',
|
||||||
|
'entity_type': 'chore',
|
||||||
|
'user_id': 'testuserid',
|
||||||
|
'status': 'pending',
|
||||||
|
'approved_at': None,
|
||||||
|
'created_at': old_ts,
|
||||||
|
'updated_at': old_ts,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = client.post('/child/child_stale_chore/confirm-chore', json={'task_id': 't_stale_chore'})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
active_pending = pending_confirmations_db.search(
|
||||||
|
(Query().child_id == 'child_stale_chore') & (Query().entity_id == 't_stale_chore') &
|
||||||
|
(Query().entity_type == 'chore') & (Query().status == 'pending')
|
||||||
|
)
|
||||||
|
assert len(active_pending) == 1
|
||||||
|
assert active_pending[0].get('id') != 'pend_stale_chore'
|
||||||
|
|
||||||
|
pending_confirmations_db.remove(Query().child_id == 'child_stale_chore')
|
||||||
|
child_db.remove(Query().id == 'child_stale_chore')
|
||||||
|
task_db.remove(Query().id == 't_stale_chore')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# request-reward: duplicate guard
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_request_reward_duplicate_returns_409(client):
|
||||||
|
"""Requesting the same reward twice without resolution returns 409 Conflict."""
|
||||||
|
reward_db.insert({'id': 'r_dup', 'name': 'Duplicate Reward', 'cost': 5, 'user_id': 'testuserid'})
|
||||||
|
child_db.insert({
|
||||||
|
'id': 'child_dup',
|
||||||
|
'name': 'Dupe Kid',
|
||||||
|
'age': 8,
|
||||||
|
'points': 20,
|
||||||
|
'tasks': [],
|
||||||
|
'rewards': ['r_dup'],
|
||||||
|
'user_id': 'testuserid',
|
||||||
|
})
|
||||||
|
|
||||||
|
first = client.post('/child/child_dup/request-reward', json={'reward_id': 'r_dup'})
|
||||||
|
assert first.status_code == 200
|
||||||
|
|
||||||
|
second = client.post('/child/child_dup/request-reward', json={'reward_id': 'r_dup'})
|
||||||
|
assert second.status_code == 409
|
||||||
|
assert second.get_json()['code'] == 'DUPLICATE_REWARD_REQUEST'
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
pending_confirmations_db.remove(Query().child_id == 'child_dup')
|
||||||
|
child_db.remove(Query().id == 'child_dup')
|
||||||
|
reward_db.remove(Query().id == 'r_dup')
|
||||||
|
|
||||||
|
|
||||||
|
def test_request_reward_allows_new_when_stale_pending_exists(client):
|
||||||
|
"""A stale pending reward from a prior day must not block a new request."""
|
||||||
|
old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp()
|
||||||
|
reward_db.insert({'id': 'r_stale', 'name': 'Stale Reward', 'cost': 5, 'user_id': 'testuserid'})
|
||||||
|
child_db.insert({
|
||||||
|
'id': 'child_stale',
|
||||||
|
'name': 'Stale Kid',
|
||||||
|
'age': 8,
|
||||||
|
'points': 20,
|
||||||
|
'tasks': [],
|
||||||
|
'rewards': ['r_stale'],
|
||||||
|
'user_id': 'testuserid',
|
||||||
|
})
|
||||||
|
pending_confirmations_db.insert({
|
||||||
|
'id': 'pend_stale_reward',
|
||||||
|
'child_id': 'child_stale',
|
||||||
|
'entity_id': 'r_stale',
|
||||||
|
'entity_type': 'reward',
|
||||||
|
'user_id': 'testuserid',
|
||||||
|
'status': 'pending',
|
||||||
|
'approved_at': None,
|
||||||
|
'created_at': old_ts,
|
||||||
|
'updated_at': old_ts,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = client.post('/child/child_stale/request-reward', json={'reward_id': 'r_stale'})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
active_pending = pending_confirmations_db.search(
|
||||||
|
(Query().child_id == 'child_stale') & (Query().entity_id == 'r_stale') &
|
||||||
|
(Query().entity_type == 'reward') & (Query().status == 'pending')
|
||||||
|
)
|
||||||
|
assert len(active_pending) == 1
|
||||||
|
assert active_pending[0].get('id') != 'pend_stale_reward'
|
||||||
|
|
||||||
|
pending_confirmations_db.remove(Query().child_id == 'child_stale')
|
||||||
|
child_db.remove(Query().id == 'child_stale')
|
||||||
|
reward_db.remove(Query().id == 'r_stale')
|
||||||
|
|
||||||
|
|
||||||
|
def test_reward_status_ignores_stale_pending_reward(client):
|
||||||
|
"""reward-status should not mark a reward as redeeming if pending is stale."""
|
||||||
|
old_ts = (datetime.now(timezone.utc) - timedelta(days=2)).timestamp()
|
||||||
|
reward_db.insert({'id': 'r_status_stale', 'name': 'Status Reward', 'cost': 4, 'user_id': 'testuserid'})
|
||||||
|
child_db.insert({
|
||||||
|
'id': 'child_status_stale',
|
||||||
|
'name': 'Status Kid',
|
||||||
|
'age': 9,
|
||||||
|
'points': 10,
|
||||||
|
'tasks': [],
|
||||||
|
'rewards': ['r_status_stale'],
|
||||||
|
'user_id': 'testuserid',
|
||||||
|
})
|
||||||
|
pending_confirmations_db.insert({
|
||||||
|
'id': 'pend_status_stale',
|
||||||
|
'child_id': 'child_status_stale',
|
||||||
|
'entity_id': 'r_status_stale',
|
||||||
|
'entity_type': 'reward',
|
||||||
|
'user_id': 'testuserid',
|
||||||
|
'status': 'pending',
|
||||||
|
'approved_at': None,
|
||||||
|
'created_at': old_ts,
|
||||||
|
'updated_at': old_ts,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = client.get('/child/child_status_stale/reward-status')
|
||||||
|
assert resp.status_code == 200
|
||||||
|
statuses = {s['id']: s for s in resp.get_json()['reward_status']}
|
||||||
|
assert statuses['r_status_stale']['redeeming'] is False
|
||||||
|
assert pending_confirmations_db.get(Query().id == 'pend_status_stale') is None
|
||||||
|
|
||||||
|
pending_confirmations_db.remove(Query().child_id == 'child_status_stale')
|
||||||
|
child_db.remove(Query().id == 'child_status_stale')
|
||||||
|
reward_db.remove(Query().id == 'r_status_stale')
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# deny-reward-request endpoint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_deny_reward_request_removes_pending(client):
|
||||||
|
"""Denying a reward request removes the pending confirmation and fires REQUEST_CANCELLED."""
|
||||||
|
reward_db.insert({'id': 'r_deny1', 'name': 'Deny Reward', 'cost': 5, 'user_id': 'testuserid'})
|
||||||
|
child_db.insert({
|
||||||
|
'id': 'child_deny1',
|
||||||
|
'name': 'Deny Kid',
|
||||||
|
'age': 9,
|
||||||
|
'points': 10,
|
||||||
|
'tasks': [],
|
||||||
|
'rewards': ['r_deny1'],
|
||||||
|
'user_id': 'testuserid',
|
||||||
|
})
|
||||||
|
pending_confirmations_db.insert({
|
||||||
|
'id': 'pend_deny1',
|
||||||
|
'child_id': 'child_deny1',
|
||||||
|
'entity_id': 'r_deny1',
|
||||||
|
'entity_type': 'reward',
|
||||||
|
'user_id': 'testuserid',
|
||||||
|
'status': 'pending',
|
||||||
|
'approved_at': None,
|
||||||
|
})
|
||||||
|
|
||||||
|
resp = client.post('/child/child_deny1/deny-reward-request', json={'reward_id': 'r_deny1'})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
remaining = pending_confirmations_db.get(
|
||||||
|
(Query().child_id == 'child_deny1') & (Query().entity_id == 'r_deny1')
|
||||||
|
)
|
||||||
|
assert remaining is None
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
child_db.remove(Query().id == 'child_deny1')
|
||||||
|
reward_db.remove(Query().id == 'r_deny1')
|
||||||
|
|
||||||
|
|
||||||
|
def test_deny_reward_request_already_resolved_returns_200(client):
|
||||||
|
"""If no pending request exists, denying returns 200 with an informational message."""
|
||||||
|
resp = client.post('/child/child_gone/deny-reward-request', json={'reward_id': 'r_gone'})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert 'already been resolved' in data['message']
|
||||||
|
|
||||||
|
|
||||||
|
def test_deny_reward_request_requires_auth(client):
|
||||||
|
"""Deny-reward-request endpoint rejects unauthenticated requests."""
|
||||||
|
# Create a fresh unauthenticated client
|
||||||
|
from flask import Flask
|
||||||
|
from api.child_api import child_api as child_api_bp
|
||||||
|
from api.auth_api import auth_api as auth_api_bp
|
||||||
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
|
|
||||||
|
app2 = Flask(__name__)
|
||||||
|
app2.register_blueprint(child_api_bp)
|
||||||
|
app2.register_blueprint(auth_api_bp, url_prefix='/auth')
|
||||||
|
app2.config['TESTING'] = True
|
||||||
|
app2.config['SECRET_KEY'] = TEST_SECRET_KEY
|
||||||
|
app2.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
|
with app2.test_client() as anon:
|
||||||
|
resp = anon.post('/child/someid/deny-reward-request', json={'reward_id': 'r1'})
|
||||||
|
assert resp.status_code == 401
|
||||||
@@ -3,6 +3,7 @@ from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
|||||||
import os
|
import os
|
||||||
from werkzeug.security import generate_password_hash
|
from werkzeug.security import generate_password_hash
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
|
import api.child_api as child_api_module
|
||||||
|
|
||||||
from flask import Flask
|
from flask import Flask
|
||||||
from api.child_api import child_api
|
from api.child_api import child_api
|
||||||
@@ -71,6 +72,24 @@ def setup_child_and_chore(child_name='TestChild', age=8, chore_points=10):
|
|||||||
return child['id'], 'chore1'
|
return child['id'], 'chore1'
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_day_iso_check_handles_utc_rollover():
|
||||||
|
# 22:30 UTC on 2026-05-11 is 12:30 local on 2026-05-12 in Pacific/Kiritimati (UTC+14).
|
||||||
|
assert child_api_module._is_iso_timestamp_on_local_day(
|
||||||
|
'2026-05-11T22:30:00+00:00',
|
||||||
|
'2026-05-12',
|
||||||
|
'Pacific/Kiritimati',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_day_epoch_check_handles_utc_rollover():
|
||||||
|
# Same instant as above represented as epoch seconds.
|
||||||
|
assert child_api_module._is_epoch_timestamp_on_local_day(
|
||||||
|
1778538600,
|
||||||
|
'2026-05-12',
|
||||||
|
'Pacific/Kiritimati',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Child Confirm Flow
|
# Child Confirm Flow
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -0,0 +1,544 @@
|
|||||||
|
"""Tests for the chore expiry notification scheduler."""
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from flask import Flask
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from utils.chore_expiry_notification_scheduler import (
|
||||||
|
get_expiring_chores_for_user,
|
||||||
|
_build_push_payload,
|
||||||
|
send_chore_expiry_notifications_for_user,
|
||||||
|
run_chore_expiry_check,
|
||||||
|
)
|
||||||
|
from utils.schedule_utils import interval_hits_today, is_scheduled_today, get_due_time_today
|
||||||
|
from db.db import users_db, child_db, task_db, chore_schedules_db, pending_confirmations_db
|
||||||
|
from tests.conftest import TEST_SECRET_KEY
|
||||||
|
|
||||||
|
USER_ID = "expiry_notif_user"
|
||||||
|
CHILD_ID = "expiry_notif_child"
|
||||||
|
CHILD_ID_2 = "expiry_notif_child_2"
|
||||||
|
TASK_ID = "expiry_notif_task"
|
||||||
|
TASK_ID_2 = "expiry_notif_task_2"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _seed_user(push_enabled: bool = True, verified: bool = True):
|
||||||
|
users_db.remove(Query().id == USER_ID)
|
||||||
|
users_db.insert({
|
||||||
|
"id": USER_ID,
|
||||||
|
"first_name": "Notify",
|
||||||
|
"last_name": "Tester",
|
||||||
|
"email": f"{USER_ID}@example.com",
|
||||||
|
"password": "hashed",
|
||||||
|
"verified": verified,
|
||||||
|
"role": "user",
|
||||||
|
"image_id": None,
|
||||||
|
"marked_for_deletion": False,
|
||||||
|
"timezone": "UTC",
|
||||||
|
"email_digest_enabled": False,
|
||||||
|
"push_notifications_enabled": push_enabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_child(child_id: str = CHILD_ID, task_ids: list = None):
|
||||||
|
if task_ids is None:
|
||||||
|
task_ids = [TASK_ID]
|
||||||
|
child_db.remove(Query().id == child_id)
|
||||||
|
child_db.insert({
|
||||||
|
"id": child_id,
|
||||||
|
"user_id": USER_ID,
|
||||||
|
"name": "Alex" if child_id == CHILD_ID else "Sam",
|
||||||
|
"age": 8,
|
||||||
|
"points": 0,
|
||||||
|
"tasks": task_ids,
|
||||||
|
"rewards": [],
|
||||||
|
"image_id": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_task(task_id: str = TASK_ID, name: str = "Clean Room"):
|
||||||
|
task_db.remove(Query().id == task_id)
|
||||||
|
task_db.insert({
|
||||||
|
"id": task_id,
|
||||||
|
"user_id": USER_ID,
|
||||||
|
"name": name,
|
||||||
|
"points": 10,
|
||||||
|
"type": "chore",
|
||||||
|
"image_id": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_schedule(
|
||||||
|
child_id: str = CHILD_ID,
|
||||||
|
task_id: str = TASK_ID,
|
||||||
|
mode: str = "days",
|
||||||
|
day_configs: list = None,
|
||||||
|
default_has_deadline: bool = True,
|
||||||
|
default_hour: int = 21,
|
||||||
|
default_minute: int = 0,
|
||||||
|
enabled: bool = True,
|
||||||
|
interval_days: int = 1,
|
||||||
|
anchor_date: str = "",
|
||||||
|
interval_has_deadline: bool = True,
|
||||||
|
interval_hour: int = 21,
|
||||||
|
interval_minute: int = 0,
|
||||||
|
):
|
||||||
|
"""Seed a chore schedule. For 'days' mode, day_configs defaults to all 7 days."""
|
||||||
|
if day_configs is None:
|
||||||
|
day_configs = [
|
||||||
|
{"day": d, "hour": default_hour, "minute": default_minute}
|
||||||
|
for d in range(7)
|
||||||
|
]
|
||||||
|
chore_schedules_db.remove(
|
||||||
|
(Query().child_id == child_id) & (Query().task_id == task_id)
|
||||||
|
)
|
||||||
|
chore_schedules_db.insert({
|
||||||
|
"id": f"sched_{child_id}_{task_id}",
|
||||||
|
"child_id": child_id,
|
||||||
|
"task_id": task_id,
|
||||||
|
"mode": mode,
|
||||||
|
"day_configs": day_configs,
|
||||||
|
"default_hour": default_hour,
|
||||||
|
"default_minute": default_minute,
|
||||||
|
"default_has_deadline": default_has_deadline,
|
||||||
|
"interval_days": interval_days,
|
||||||
|
"anchor_date": anchor_date,
|
||||||
|
"interval_has_deadline": interval_has_deadline,
|
||||||
|
"interval_hour": interval_hour,
|
||||||
|
"interval_minute": interval_minute,
|
||||||
|
"enabled": enabled,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_confirmation(child_id: str = CHILD_ID, task_id: str = TASK_ID, status: str = "pending"):
|
||||||
|
pending_confirmations_db.remove(
|
||||||
|
(Query().child_id == child_id) & (Query().entity_id == task_id)
|
||||||
|
)
|
||||||
|
pending_confirmations_db.insert({
|
||||||
|
"id": f"conf_{child_id}_{task_id}",
|
||||||
|
"user_id": USER_ID,
|
||||||
|
"child_id": child_id,
|
||||||
|
"entity_id": task_id,
|
||||||
|
"entity_type": "chore",
|
||||||
|
"status": status,
|
||||||
|
"approved_at": None,
|
||||||
|
"created_at": 0,
|
||||||
|
"updated_at": 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup():
|
||||||
|
for cid in (CHILD_ID, CHILD_ID_2):
|
||||||
|
child_db.remove(Query().id == cid)
|
||||||
|
chore_schedules_db.remove(Query().child_id == cid)
|
||||||
|
pending_confirmations_db.remove(Query().child_id == cid)
|
||||||
|
for tid in (TASK_ID, TASK_ID_2):
|
||||||
|
task_db.remove(Query().id == tid)
|
||||||
|
users_db.remove(Query().id == USER_ID)
|
||||||
|
|
||||||
|
|
||||||
|
def _now_with_deadline_in_window(minutes_ahead: int = 30) -> tuple[datetime, int, int]:
|
||||||
|
"""Return (now_dt, hour, minute) so that deadline = now + minutes_ahead."""
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
deadline = now + timedelta(minutes=minutes_ahead)
|
||||||
|
return now, deadline.hour, deadline.minute
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
flask_app = Flask(__name__)
|
||||||
|
flask_app.config['TESTING'] = True
|
||||||
|
flask_app.config['SECRET_KEY'] = TEST_SECRET_KEY
|
||||||
|
return flask_app
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# schedule_utils unit tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestIntervalHitsToday:
|
||||||
|
def test_same_day_as_anchor_hits(self):
|
||||||
|
from datetime import date
|
||||||
|
d = date(2026, 4, 22)
|
||||||
|
assert interval_hits_today("2026-04-22", 3, d) is True
|
||||||
|
|
||||||
|
def test_interval_day_hits(self):
|
||||||
|
from datetime import date
|
||||||
|
assert interval_hits_today("2026-04-22", 3, date(2026, 4, 25)) is True
|
||||||
|
|
||||||
|
def test_non_interval_day_misses(self):
|
||||||
|
from datetime import date
|
||||||
|
assert interval_hits_today("2026-04-22", 3, date(2026, 4, 24)) is False
|
||||||
|
|
||||||
|
def test_before_anchor_misses(self):
|
||||||
|
from datetime import date
|
||||||
|
assert interval_hits_today("2026-04-22", 1, date(2026, 4, 21)) is False
|
||||||
|
|
||||||
|
def test_empty_anchor_hits_today(self):
|
||||||
|
from datetime import date
|
||||||
|
d = date(2026, 4, 22)
|
||||||
|
assert interval_hits_today("", 1, d) is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsScheduledToday:
|
||||||
|
def test_days_mode_matching_weekday(self):
|
||||||
|
from datetime import date
|
||||||
|
# 2026-04-22 is Wednesday → JS weekday 3 (Sun=0..Sat=6)
|
||||||
|
schedule = {"mode": "days", "enabled": True, "day_configs": [{"day": 3}]}
|
||||||
|
assert is_scheduled_today(schedule, date(2026, 4, 22)) is True
|
||||||
|
|
||||||
|
def test_days_mode_non_matching_weekday(self):
|
||||||
|
from datetime import date
|
||||||
|
schedule = {"mode": "days", "enabled": True, "day_configs": [{"day": 1}]}
|
||||||
|
assert is_scheduled_today(schedule, date(2026, 4, 22)) is False
|
||||||
|
|
||||||
|
def test_paused_schedule_always_true(self):
|
||||||
|
from datetime import date
|
||||||
|
schedule = {"mode": "days", "enabled": False, "day_configs": []}
|
||||||
|
assert is_scheduled_today(schedule, date(2026, 4, 22)) is True
|
||||||
|
|
||||||
|
def test_interval_mode_hit(self):
|
||||||
|
from datetime import date
|
||||||
|
schedule = {
|
||||||
|
"mode": "interval", "enabled": True,
|
||||||
|
"interval_days": 1, "anchor_date": "2026-04-22",
|
||||||
|
}
|
||||||
|
assert is_scheduled_today(schedule, date(2026, 4, 22)) is True
|
||||||
|
|
||||||
|
def test_interval_mode_miss(self):
|
||||||
|
from datetime import date
|
||||||
|
schedule = {
|
||||||
|
"mode": "interval", "enabled": True,
|
||||||
|
"interval_days": 3, "anchor_date": "2026-04-22",
|
||||||
|
}
|
||||||
|
assert is_scheduled_today(schedule, date(2026, 4, 23)) is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetDueTimeToday:
|
||||||
|
def test_days_mode_returns_due_time(self):
|
||||||
|
from datetime import date
|
||||||
|
# Wednesday → JS 3 (Sun=0..Sat=6)
|
||||||
|
schedule = {
|
||||||
|
"mode": "days", "enabled": True,
|
||||||
|
"default_has_deadline": True,
|
||||||
|
"day_configs": [{"day": 3, "hour": 21, "minute": 0}],
|
||||||
|
}
|
||||||
|
assert get_due_time_today(schedule, date(2026, 4, 22)) == (21, 0)
|
||||||
|
|
||||||
|
def test_days_mode_anytime_returns_none(self):
|
||||||
|
from datetime import date
|
||||||
|
schedule = {
|
||||||
|
"mode": "days", "enabled": True,
|
||||||
|
"default_has_deadline": False,
|
||||||
|
"day_configs": [{"day": 4, "hour": 21, "minute": 0}],
|
||||||
|
}
|
||||||
|
assert get_due_time_today(schedule, date(2026, 4, 22)) is None
|
||||||
|
|
||||||
|
def test_days_mode_wrong_day_returns_none(self):
|
||||||
|
from datetime import date
|
||||||
|
schedule = {
|
||||||
|
"mode": "days", "enabled": True,
|
||||||
|
"default_has_deadline": True,
|
||||||
|
"day_configs": [{"day": 1, "hour": 21, "minute": 0}],
|
||||||
|
}
|
||||||
|
assert get_due_time_today(schedule, date(2026, 4, 22)) is None
|
||||||
|
|
||||||
|
def test_paused_returns_none(self):
|
||||||
|
from datetime import date
|
||||||
|
schedule = {
|
||||||
|
"mode": "days", "enabled": False,
|
||||||
|
"default_has_deadline": True,
|
||||||
|
"day_configs": [{"day": 4, "hour": 21, "minute": 0}],
|
||||||
|
}
|
||||||
|
assert get_due_time_today(schedule, date(2026, 4, 22)) is None
|
||||||
|
|
||||||
|
def test_interval_mode_returns_due_time(self):
|
||||||
|
from datetime import date
|
||||||
|
schedule = {
|
||||||
|
"mode": "interval", "enabled": True,
|
||||||
|
"interval_days": 1, "anchor_date": "2026-04-22",
|
||||||
|
"interval_has_deadline": True,
|
||||||
|
"interval_hour": 20, "interval_minute": 30,
|
||||||
|
}
|
||||||
|
assert get_due_time_today(schedule, date(2026, 4, 22)) == (20, 30)
|
||||||
|
|
||||||
|
def test_interval_mode_anytime_returns_none(self):
|
||||||
|
from datetime import date
|
||||||
|
schedule = {
|
||||||
|
"mode": "interval", "enabled": True,
|
||||||
|
"interval_days": 1, "anchor_date": "2026-04-22",
|
||||||
|
"interval_has_deadline": False,
|
||||||
|
"interval_hour": 20, "interval_minute": 30,
|
||||||
|
}
|
||||||
|
assert get_due_time_today(schedule, date(2026, 4, 22)) is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# get_expiring_chores_for_user tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestGetExpiringChoresForUser:
|
||||||
|
def setup_method(self):
|
||||||
|
_cleanup()
|
||||||
|
_seed_user()
|
||||||
|
_seed_child()
|
||||||
|
_seed_task()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
_cleanup()
|
||||||
|
|
||||||
|
def test_chore_in_window_included(self, app):
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(default_hour=h, default_minute=m)
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]['task_id'] == TASK_ID
|
||||||
|
assert result[0]['child_id'] == CHILD_ID
|
||||||
|
|
||||||
|
def test_chore_deadline_past_excluded(self, app):
|
||||||
|
# Deadline 10 minutes in the past
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
past = now - timedelta(minutes=10)
|
||||||
|
_seed_schedule(default_hour=past.hour, default_minute=past.minute)
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_chore_deadline_beyond_window_excluded(self, app):
|
||||||
|
# Deadline 80 minutes ahead (beyond 75-min window)
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=80)
|
||||||
|
_seed_schedule(default_hour=h, default_minute=m)
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_anytime_chore_excluded(self, app):
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(default_has_deadline=False, default_hour=h, default_minute=m)
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_wrong_day_excluded(self, app):
|
||||||
|
from datetime import date
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
# Schedule only on a day that is NOT today
|
||||||
|
today_js = (now.weekday() + 1) % 7
|
||||||
|
wrong_day = (today_js + 1) % 7
|
||||||
|
h = (now + timedelta(minutes=30)).hour
|
||||||
|
m = (now + timedelta(minutes=30)).minute
|
||||||
|
_seed_schedule(
|
||||||
|
day_configs=[{"day": wrong_day, "hour": h, "minute": m}],
|
||||||
|
default_hour=h,
|
||||||
|
default_minute=m,
|
||||||
|
)
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_pending_confirmation_excludes_chore(self, app):
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(default_hour=h, default_minute=m)
|
||||||
|
_seed_confirmation(status="pending")
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_approved_confirmation_excludes_chore(self, app):
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(default_hour=h, default_minute=m)
|
||||||
|
_seed_confirmation(status="approved")
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_no_schedule_excluded(self, app):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
# No schedule seeded
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_paused_schedule_excluded(self, app):
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(enabled=False, default_hour=h, default_minute=m)
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_interval_mode_hit_included(self, app):
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
today_iso = now.strftime("%Y-%m-%d")
|
||||||
|
_seed_schedule(
|
||||||
|
mode="interval",
|
||||||
|
interval_days=1,
|
||||||
|
anchor_date=today_iso,
|
||||||
|
interval_has_deadline=True,
|
||||||
|
interval_hour=h,
|
||||||
|
interval_minute=m,
|
||||||
|
)
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert len(result) == 1
|
||||||
|
|
||||||
|
def test_interval_mode_miss_excluded(self, app):
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
h = (now + timedelta(minutes=30)).hour
|
||||||
|
m = (now + timedelta(minutes=30)).minute
|
||||||
|
# Anchor yesterday with interval_days=2 → not today
|
||||||
|
yesterday = (now - timedelta(days=1)).strftime("%Y-%m-%d")
|
||||||
|
_seed_schedule(
|
||||||
|
mode="interval",
|
||||||
|
interval_days=2,
|
||||||
|
anchor_date=yesterday,
|
||||||
|
interval_has_deadline=True,
|
||||||
|
interval_hour=h,
|
||||||
|
interval_minute=m,
|
||||||
|
)
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_multiple_children_grouped(self, app):
|
||||||
|
_seed_child(CHILD_ID_2, task_ids=[TASK_ID_2])
|
||||||
|
_seed_task(TASK_ID_2, name="Take Out Trash")
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(child_id=CHILD_ID, task_id=TASK_ID, default_hour=h, default_minute=m)
|
||||||
|
_seed_schedule(child_id=CHILD_ID_2, task_id=TASK_ID_2, default_hour=h, default_minute=m)
|
||||||
|
with app.app_context():
|
||||||
|
result = get_expiring_chores_for_user(USER_ID, "UTC", now)
|
||||||
|
assert len(result) == 2
|
||||||
|
child_ids = {r['child_id'] for r in result}
|
||||||
|
assert CHILD_ID in child_ids
|
||||||
|
assert CHILD_ID_2 in child_ids
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# _build_push_payload tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestBuildPushPayload:
|
||||||
|
def test_single_chore_sets_title_and_deep_link(self):
|
||||||
|
expiring = [{"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"}]
|
||||||
|
payload = _build_push_payload(expiring)
|
||||||
|
assert payload['title'] == "Chore ending soon: Clean Room"
|
||||||
|
assert payload['child_id'] == "c1"
|
||||||
|
assert payload['entity_id'] == "t1"
|
||||||
|
assert payload['type'] == 'chore_expiring_soon'
|
||||||
|
|
||||||
|
def test_multiple_chores_uses_generic_title(self):
|
||||||
|
expiring = [
|
||||||
|
{"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"},
|
||||||
|
{"child_id": "c1", "child_name": "Alex", "task_id": "t2", "task_name": "Make Bed"},
|
||||||
|
]
|
||||||
|
payload = _build_push_payload(expiring)
|
||||||
|
assert payload['title'] == "Chores ending soon"
|
||||||
|
assert payload['child_id'] is None
|
||||||
|
assert payload['entity_id'] is None
|
||||||
|
|
||||||
|
def test_body_groups_by_child(self):
|
||||||
|
expiring = [
|
||||||
|
{"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"},
|
||||||
|
{"child_id": "c1", "child_name": "Alex", "task_id": "t2", "task_name": "Make Bed"},
|
||||||
|
{"child_id": "c2", "child_name": "Sam", "task_id": "t3", "task_name": "Trash"},
|
||||||
|
]
|
||||||
|
payload = _build_push_payload(expiring)
|
||||||
|
assert "Alex: Clean Room, Make Bed" in payload['body']
|
||||||
|
assert "Sam: Trash" in payload['body']
|
||||||
|
|
||||||
|
def test_no_approve_deny_tokens(self):
|
||||||
|
expiring = [{"child_id": "c1", "child_name": "Alex", "task_id": "t1", "task_name": "Clean Room"}]
|
||||||
|
payload = _build_push_payload(expiring)
|
||||||
|
assert 'approve_token' not in payload
|
||||||
|
assert 'deny_token' not in payload
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# send_chore_expiry_notifications_for_user tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestSendChoreExpiryNotificationsForUser:
|
||||||
|
def setup_method(self):
|
||||||
|
_cleanup()
|
||||||
|
_seed_user()
|
||||||
|
_seed_child()
|
||||||
|
_seed_task()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
_cleanup()
|
||||||
|
|
||||||
|
def test_sends_push_when_chore_expiring(self, app):
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(default_hour=h, default_minute=m)
|
||||||
|
with app.app_context():
|
||||||
|
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
|
||||||
|
count = send_chore_expiry_notifications_for_user(USER_ID, "UTC")
|
||||||
|
assert count == 1
|
||||||
|
mock_push.assert_called_once()
|
||||||
|
payload = mock_push.call_args[0][1]
|
||||||
|
assert payload['type'] == 'chore_expiring_soon'
|
||||||
|
|
||||||
|
def test_no_push_when_no_expiring_chores(self, app):
|
||||||
|
# No schedule seeded → nothing expiring
|
||||||
|
with app.app_context():
|
||||||
|
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
|
||||||
|
count = send_chore_expiry_notifications_for_user(USER_ID, "UTC")
|
||||||
|
assert count == 0
|
||||||
|
mock_push.assert_not_called()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# run_chore_expiry_check tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TestRunChoreExpiryCheck:
|
||||||
|
def setup_method(self):
|
||||||
|
_cleanup()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
_cleanup()
|
||||||
|
|
||||||
|
def test_skips_when_db_env_is_e2e(self, app):
|
||||||
|
with patch.dict(os.environ, {'DB_ENV': 'e2e'}):
|
||||||
|
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
|
||||||
|
run_chore_expiry_check(app)
|
||||||
|
mock_push.assert_not_called()
|
||||||
|
|
||||||
|
def test_skips_user_with_push_disabled(self, app):
|
||||||
|
_seed_user(push_enabled=False)
|
||||||
|
_seed_child()
|
||||||
|
_seed_task()
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(default_hour=h, default_minute=m)
|
||||||
|
with app.app_context():
|
||||||
|
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
|
||||||
|
run_chore_expiry_check(app)
|
||||||
|
mock_push.assert_not_called()
|
||||||
|
|
||||||
|
def test_skips_unverified_user(self, app):
|
||||||
|
_seed_user(verified=False)
|
||||||
|
_seed_child()
|
||||||
|
_seed_task()
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(default_hour=h, default_minute=m)
|
||||||
|
with app.app_context():
|
||||||
|
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
|
||||||
|
run_chore_expiry_check(app)
|
||||||
|
mock_push.assert_not_called()
|
||||||
|
|
||||||
|
def test_sends_push_for_eligible_user(self, app):
|
||||||
|
_seed_user(push_enabled=True, verified=True)
|
||||||
|
_seed_child()
|
||||||
|
_seed_task()
|
||||||
|
now, h, m = _now_with_deadline_in_window(minutes_ahead=30)
|
||||||
|
_seed_schedule(default_hour=h, default_minute=m)
|
||||||
|
with app.app_context():
|
||||||
|
with patch('utils.chore_expiry_notification_scheduler.send_push_to_user') as mock_push:
|
||||||
|
run_chore_expiry_check(app)
|
||||||
|
mock_push.assert_called_once()
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
import os
|
||||||
|
import jwt
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from flask import Flask
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from api.digest_action_api import digest_action_api
|
||||||
|
from db.db import (
|
||||||
|
users_db, child_db, task_db, reward_db,
|
||||||
|
pending_confirmations_db, digest_action_tokens_db,
|
||||||
|
tracking_events_db,
|
||||||
|
)
|
||||||
|
from utils.digest_token import (
|
||||||
|
create_action_token, create_unsubscribe_token, validate_unsubscribe_token
|
||||||
|
)
|
||||||
|
from tests.conftest import TEST_SECRET_KEY
|
||||||
|
|
||||||
|
TEST_USER_ID = "daa_user_id"
|
||||||
|
OTHER_USER_ID = "daa_other_user_id"
|
||||||
|
TEST_CHILD_ID = "daa_child_id"
|
||||||
|
TEST_TASK_ID = "daa_task_id"
|
||||||
|
TEST_REWARD_ID = "daa_reward_id"
|
||||||
|
FRONTEND_URL = "http://localhost:5173"
|
||||||
|
|
||||||
|
|
||||||
|
def make_auth_token(user_id: str) -> str:
|
||||||
|
"""Create a JWT access token for the given user_id, for use in tests."""
|
||||||
|
payload = {
|
||||||
|
'user_id': user_id,
|
||||||
|
'token_version': 0,
|
||||||
|
'exp': datetime.now(timezone.utc) + timedelta(minutes=15),
|
||||||
|
}
|
||||||
|
return jwt.encode(payload, TEST_SECRET_KEY, algorithm='HS256')
|
||||||
|
|
||||||
|
|
||||||
|
def seed_data():
|
||||||
|
users_db.remove(Query().id == TEST_USER_ID)
|
||||||
|
users_db.remove(Query().id == OTHER_USER_ID)
|
||||||
|
child_db.remove(Query().id == TEST_CHILD_ID)
|
||||||
|
task_db.remove(Query().id == TEST_TASK_ID)
|
||||||
|
reward_db.remove(Query().id == TEST_REWARD_ID)
|
||||||
|
pending_confirmations_db.remove(Query().user_id == TEST_USER_ID)
|
||||||
|
digest_action_tokens_db.truncate()
|
||||||
|
tracking_events_db.remove(Query().user_id == TEST_USER_ID)
|
||||||
|
|
||||||
|
users_db.insert({
|
||||||
|
"id": TEST_USER_ID,
|
||||||
|
"first_name": "Digest",
|
||||||
|
"last_name": "Tester",
|
||||||
|
"email": "digest@example.com",
|
||||||
|
"password": generate_password_hash("password"),
|
||||||
|
"verified": True,
|
||||||
|
"role": "user",
|
||||||
|
"image_id": None,
|
||||||
|
"marked_for_deletion": False,
|
||||||
|
"marked_for_deletion_at": None,
|
||||||
|
"email_digest_enabled": True,
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
"token_version": 0,
|
||||||
|
})
|
||||||
|
users_db.insert({
|
||||||
|
"id": OTHER_USER_ID,
|
||||||
|
"first_name": "Other",
|
||||||
|
"last_name": "User",
|
||||||
|
"email": "other@example.com",
|
||||||
|
"password": generate_password_hash("password"),
|
||||||
|
"verified": True,
|
||||||
|
"role": "user",
|
||||||
|
"image_id": None,
|
||||||
|
"marked_for_deletion": False,
|
||||||
|
"marked_for_deletion_at": None,
|
||||||
|
"email_digest_enabled": True,
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
"token_version": 0,
|
||||||
|
})
|
||||||
|
child_db.insert({
|
||||||
|
"id": TEST_CHILD_ID,
|
||||||
|
"user_id": TEST_USER_ID,
|
||||||
|
"name": "Test Child",
|
||||||
|
"age": 8,
|
||||||
|
"tasks": [TEST_TASK_ID],
|
||||||
|
"rewards": [TEST_REWARD_ID],
|
||||||
|
"points": 100,
|
||||||
|
"image_id": None,
|
||||||
|
})
|
||||||
|
task_db.insert({
|
||||||
|
"id": TEST_TASK_ID,
|
||||||
|
"user_id": TEST_USER_ID,
|
||||||
|
"name": "Clean Room",
|
||||||
|
"points": 10,
|
||||||
|
"type": "chore",
|
||||||
|
"description": "",
|
||||||
|
"image_id": None,
|
||||||
|
})
|
||||||
|
reward_db.insert({
|
||||||
|
"id": TEST_REWARD_ID,
|
||||||
|
"user_id": TEST_USER_ID,
|
||||||
|
"name": "Extra Screen Time",
|
||||||
|
"cost": 20,
|
||||||
|
"description": "",
|
||||||
|
"image_id": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def add_pending_chore():
|
||||||
|
pending_confirmations_db.insert({
|
||||||
|
"id": "pending_chore_id",
|
||||||
|
"user_id": TEST_USER_ID,
|
||||||
|
"child_id": TEST_CHILD_ID,
|
||||||
|
"entity_id": TEST_TASK_ID,
|
||||||
|
"entity_type": "chore",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2024-01-01T10:00:00+00:00",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def add_pending_reward():
|
||||||
|
pending_confirmations_db.insert({
|
||||||
|
"id": "pending_reward_id",
|
||||||
|
"user_id": TEST_USER_ID,
|
||||||
|
"child_id": TEST_CHILD_ID,
|
||||||
|
"entity_id": TEST_REWARD_ID,
|
||||||
|
"entity_type": "reward",
|
||||||
|
"status": "pending",
|
||||||
|
"created_at": "2024-01-01T10:00:00+00:00",
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(digest_action_api)
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
app.config['SECRET_KEY'] = TEST_SECRET_KEY
|
||||||
|
app.config['FRONTEND_URL'] = FRONTEND_URL
|
||||||
|
seed_data()
|
||||||
|
with app.test_client() as c:
|
||||||
|
yield c
|
||||||
|
seed_data()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_client(client):
|
||||||
|
"""Client pre-authenticated as TEST_USER_ID via JWT cookie."""
|
||||||
|
client.set_cookie('access_token', make_auth_token(TEST_USER_ID))
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandleDigestActionGet:
|
||||||
|
"""GET /digest-action/<token_id>: validates token, redirects with digestToken param, executes nothing."""
|
||||||
|
|
||||||
|
def test_redirects_to_parent_view_with_digest_token(self, client):
|
||||||
|
add_pending_chore()
|
||||||
|
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
|
||||||
|
res = client.get(f'/digest-action/{token.id}')
|
||||||
|
assert res.status_code == 302
|
||||||
|
location = res.headers['Location']
|
||||||
|
assert f'/parent/{TEST_CHILD_ID}' in location
|
||||||
|
assert f'digestToken={token.id}' in location
|
||||||
|
assert 'scrollTo=' in location
|
||||||
|
|
||||||
|
def test_does_not_execute_action(self, client):
|
||||||
|
"""GET must not change any data; action is deferred to the authenticated POST."""
|
||||||
|
add_pending_chore()
|
||||||
|
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
|
||||||
|
client.get(f'/digest-action/{token.id}')
|
||||||
|
child = child_db.get(Query().id == TEST_CHILD_ID)
|
||||||
|
assert child['points'] == 100 # unchanged
|
||||||
|
|
||||||
|
def test_does_not_consume_token(self, client):
|
||||||
|
"""GET can be called multiple times without consuming the token."""
|
||||||
|
add_pending_chore()
|
||||||
|
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
|
||||||
|
res1 = client.get(f'/digest-action/{token.id}')
|
||||||
|
res2 = client.get(f'/digest-action/{token.id}')
|
||||||
|
assert res1.status_code == 302
|
||||||
|
assert res2.status_code == 302
|
||||||
|
|
||||||
|
def test_invalid_token_returns_400(self, client):
|
||||||
|
res = client.get('/digest-action/nonexistent-token-id')
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
def test_already_resolved_chore_still_redirects(self, client):
|
||||||
|
"""GET still redirects even if the pending chore no longer exists; token is valid."""
|
||||||
|
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
|
||||||
|
# No pending chore added — already resolved
|
||||||
|
res = client.get(f'/digest-action/{token.id}')
|
||||||
|
assert res.status_code == 302
|
||||||
|
|
||||||
|
|
||||||
|
class TestExecuteDigestActionPost:
|
||||||
|
"""POST /digest-action/<token_id>: requires auth, consumes token, executes action."""
|
||||||
|
|
||||||
|
def test_approve_chore_awards_points(self, auth_client):
|
||||||
|
add_pending_chore()
|
||||||
|
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
|
||||||
|
res = auth_client.post(f'/digest-action/{token.id}')
|
||||||
|
assert res.status_code == 200
|
||||||
|
child = child_db.get(Query().id == TEST_CHILD_ID)
|
||||||
|
assert child['points'] == 110 # 100 + 10
|
||||||
|
|
||||||
|
def test_deny_chore_removes_pending(self, auth_client):
|
||||||
|
add_pending_chore()
|
||||||
|
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'deny')
|
||||||
|
res = auth_client.post(f'/digest-action/{token.id}')
|
||||||
|
assert res.status_code == 200
|
||||||
|
pending = pending_confirmations_db.get(
|
||||||
|
(Query().child_id == TEST_CHILD_ID) & (Query().entity_id == TEST_TASK_ID)
|
||||||
|
)
|
||||||
|
assert pending is None
|
||||||
|
|
||||||
|
def test_approve_reward_deducts_points(self, auth_client):
|
||||||
|
add_pending_reward()
|
||||||
|
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_REWARD_ID, 'reward', 'approve')
|
||||||
|
res = auth_client.post(f'/digest-action/{token.id}')
|
||||||
|
assert res.status_code == 200
|
||||||
|
child = child_db.get(Query().id == TEST_CHILD_ID)
|
||||||
|
assert child['points'] == 80 # 100 - 20
|
||||||
|
|
||||||
|
def test_deny_reward_removes_pending(self, auth_client):
|
||||||
|
add_pending_reward()
|
||||||
|
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_REWARD_ID, 'reward', 'deny')
|
||||||
|
res = auth_client.post(f'/digest-action/{token.id}')
|
||||||
|
assert res.status_code == 200
|
||||||
|
pending = pending_confirmations_db.get(
|
||||||
|
(Query().child_id == TEST_CHILD_ID) & (Query().entity_id == TEST_REWARD_ID)
|
||||||
|
)
|
||||||
|
assert pending is None
|
||||||
|
|
||||||
|
def test_response_contains_success_payload(self, auth_client):
|
||||||
|
add_pending_chore()
|
||||||
|
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
|
||||||
|
res = auth_client.post(f'/digest-action/{token.id}')
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.get_json()
|
||||||
|
assert data['success'] is True
|
||||||
|
assert data['child_id'] == TEST_CHILD_ID
|
||||||
|
assert data['entity_id'] == TEST_TASK_ID
|
||||||
|
assert data['action'] == 'approve'
|
||||||
|
|
||||||
|
def test_unauthenticated_returns_401(self, client):
|
||||||
|
add_pending_chore()
|
||||||
|
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
|
||||||
|
res = client.post(f'/digest-action/{token.id}')
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
def test_wrong_user_returns_403(self, client):
|
||||||
|
"""A token created for TEST_USER_ID cannot be used by OTHER_USER_ID."""
|
||||||
|
add_pending_chore()
|
||||||
|
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
|
||||||
|
client.set_cookie('access_token', make_auth_token(OTHER_USER_ID))
|
||||||
|
res = client.post(f'/digest-action/{token.id}')
|
||||||
|
assert res.status_code == 403
|
||||||
|
|
||||||
|
def test_invalid_token_returns_400(self, auth_client):
|
||||||
|
res = auth_client.post('/digest-action/nonexistent-token-id')
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
def test_used_token_returns_400(self, auth_client):
|
||||||
|
add_pending_chore()
|
||||||
|
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
|
||||||
|
auth_client.post(f'/digest-action/{token.id}') # first use — consumes token
|
||||||
|
res = auth_client.post(f'/digest-action/{token.id}') # second use
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
def test_get_after_post_returns_400(self, auth_client):
|
||||||
|
"""Once the token is consumed via POST, the GET redirect should also fail."""
|
||||||
|
add_pending_chore()
|
||||||
|
token = create_action_token(TEST_USER_ID, TEST_CHILD_ID, TEST_TASK_ID, 'chore', 'approve')
|
||||||
|
auth_client.post(f'/digest-action/{token.id}') # consumes token
|
||||||
|
res = auth_client.get(f'/digest-action/{token.id}')
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
class TestHandleDigestUnsubscribe:
|
||||||
|
def test_valid_token_unsubscribes_user(self, client):
|
||||||
|
token = create_unsubscribe_token(TEST_USER_ID)
|
||||||
|
res = client.get(f'/digest-unsubscribe/{token}')
|
||||||
|
assert res.status_code == 200
|
||||||
|
user = users_db.get(Query().id == TEST_USER_ID)
|
||||||
|
assert user['email_digest_enabled'] is False
|
||||||
|
|
||||||
|
def test_invalid_token_returns_400(self, client):
|
||||||
|
res = client.get('/digest-unsubscribe/garbage-token')
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
def test_response_contains_unsubscribed_message(self, client):
|
||||||
|
token = create_unsubscribe_token(TEST_USER_ID)
|
||||||
|
res = client.get(f'/digest-unsubscribe/{token}')
|
||||||
|
assert b'unsubscribed' in res.data.lower()
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
"""Tests for the digest scheduler and email HTML generation."""
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from flask import Flask
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from utils.digest_scheduler import send_digests
|
||||||
|
from utils.email_sender import send_digest_email
|
||||||
|
from db.db import users_db, pending_confirmations_db, child_db, task_db, reward_db
|
||||||
|
from tests.conftest import TEST_SECRET_KEY
|
||||||
|
|
||||||
|
SCHED_USER_ID = "sched_test_user"
|
||||||
|
SCHED_EMAIL = "schedtest@example.com"
|
||||||
|
SCHED_CHILD_ID = "sched_child_id"
|
||||||
|
SCHED_TASK_ID = "sched_task_id"
|
||||||
|
SCHED_REWARD_ID = "sched_reward_id"
|
||||||
|
|
||||||
|
|
||||||
|
def seed_scheduler_data(
|
||||||
|
verified=True,
|
||||||
|
email_digest_enabled=True,
|
||||||
|
timezone="UTC",
|
||||||
|
has_pending=True,
|
||||||
|
):
|
||||||
|
users_db.remove(Query().id == SCHED_USER_ID)
|
||||||
|
child_db.remove(Query().id == SCHED_CHILD_ID)
|
||||||
|
task_db.remove(Query().id == SCHED_TASK_ID)
|
||||||
|
reward_db.remove(Query().id == SCHED_REWARD_ID)
|
||||||
|
pending_confirmations_db.remove(Query().user_id == SCHED_USER_ID)
|
||||||
|
|
||||||
|
users_db.insert({
|
||||||
|
"id": SCHED_USER_ID,
|
||||||
|
"first_name": "Sched",
|
||||||
|
"last_name": "Tester",
|
||||||
|
"email": SCHED_EMAIL,
|
||||||
|
"password": generate_password_hash("schedpass"),
|
||||||
|
"verified": verified,
|
||||||
|
"role": "user",
|
||||||
|
"image_id": None,
|
||||||
|
"marked_for_deletion": False,
|
||||||
|
"marked_for_deletion_at": None,
|
||||||
|
"timezone": timezone,
|
||||||
|
"email_digest_enabled": email_digest_enabled,
|
||||||
|
})
|
||||||
|
child_db.insert({
|
||||||
|
"id": SCHED_CHILD_ID,
|
||||||
|
"user_id": SCHED_USER_ID,
|
||||||
|
"name": "Sched Child",
|
||||||
|
"age": 8,
|
||||||
|
"points": 50,
|
||||||
|
"tasks": [SCHED_TASK_ID],
|
||||||
|
"rewards": [SCHED_REWARD_ID],
|
||||||
|
"image_id": None,
|
||||||
|
})
|
||||||
|
task_db.insert({
|
||||||
|
"id": SCHED_TASK_ID,
|
||||||
|
"user_id": SCHED_USER_ID,
|
||||||
|
"name": "Clean Room",
|
||||||
|
"points": 10,
|
||||||
|
"type": "chore",
|
||||||
|
"image_id": None,
|
||||||
|
})
|
||||||
|
reward_db.insert({
|
||||||
|
"id": SCHED_REWARD_ID,
|
||||||
|
"user_id": SCHED_USER_ID,
|
||||||
|
"name": "Movie Night",
|
||||||
|
"cost": 20,
|
||||||
|
"image_id": None,
|
||||||
|
})
|
||||||
|
if has_pending:
|
||||||
|
pending_confirmations_db.insert({
|
||||||
|
"id": "sched_pending_id",
|
||||||
|
"user_id": SCHED_USER_ID,
|
||||||
|
"child_id": SCHED_CHILD_ID,
|
||||||
|
"entity_id": SCHED_TASK_ID,
|
||||||
|
"entity_type": "chore",
|
||||||
|
"status": "pending",
|
||||||
|
"approved_at": None,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_scheduler_data():
|
||||||
|
users_db.remove(Query().id == SCHED_USER_ID)
|
||||||
|
child_db.remove(Query().id == SCHED_CHILD_ID)
|
||||||
|
task_db.remove(Query().id == SCHED_TASK_ID)
|
||||||
|
reward_db.remove(Query().id == SCHED_REWARD_ID)
|
||||||
|
pending_confirmations_db.remove(Query().user_id == SCHED_USER_ID)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
flask_app = Flask(__name__)
|
||||||
|
flask_app.config['TESTING'] = True
|
||||||
|
flask_app.config['SECRET_KEY'] = TEST_SECRET_KEY
|
||||||
|
flask_app.config['FRONTEND_URL'] = 'http://localhost:5173'
|
||||||
|
flask_app.config['MAIL_DEFAULT_SENDER'] = 'no-reply@reward-app.local'
|
||||||
|
return flask_app
|
||||||
|
|
||||||
|
|
||||||
|
class TestSendDigests:
|
||||||
|
def setup_method(self):
|
||||||
|
cleanup_scheduler_data()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
cleanup_scheduler_data()
|
||||||
|
|
||||||
|
def test_sends_digest_to_eligible_user_at_9pm(self, app):
|
||||||
|
"""Identifies verified, digest-enabled users whose local time is 9 pm and sends digest."""
|
||||||
|
seed_scheduler_data()
|
||||||
|
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
|
||||||
|
patch('utils.email_sender.Mail') as mock_mail:
|
||||||
|
send_digests(app)
|
||||||
|
assert mock_mail.return_value.send.called
|
||||||
|
|
||||||
|
def test_skips_user_with_no_pending_items(self, app):
|
||||||
|
"""Digest is not sent if there are no pending items."""
|
||||||
|
seed_scheduler_data(has_pending=False)
|
||||||
|
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
|
||||||
|
patch('utils.email_sender.Mail') as mock_mail:
|
||||||
|
send_digests(app)
|
||||||
|
assert not mock_mail.return_value.send.called
|
||||||
|
|
||||||
|
def test_skips_unverified_user(self, app):
|
||||||
|
"""Digest is not sent to unverified users."""
|
||||||
|
seed_scheduler_data(verified=False)
|
||||||
|
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
|
||||||
|
patch('utils.email_sender.Mail') as mock_mail:
|
||||||
|
send_digests(app)
|
||||||
|
assert not mock_mail.return_value.send.called
|
||||||
|
|
||||||
|
def test_skips_user_with_digest_disabled(self, app):
|
||||||
|
"""Digest is not sent to users who have email_digest_enabled == False."""
|
||||||
|
seed_scheduler_data(email_digest_enabled=False)
|
||||||
|
with patch('utils.digest_scheduler._get_local_hour', return_value=21), \
|
||||||
|
patch('utils.email_sender.Mail') as mock_mail:
|
||||||
|
send_digests(app)
|
||||||
|
assert not mock_mail.return_value.send.called
|
||||||
|
|
||||||
|
def test_skips_user_not_at_9pm(self, app):
|
||||||
|
"""Digest is not sent when the user's local time is not 21."""
|
||||||
|
seed_scheduler_data()
|
||||||
|
with patch('utils.digest_scheduler._get_local_hour', return_value=10), \
|
||||||
|
patch('utils.email_sender.Mail') as mock_mail:
|
||||||
|
send_digests(app)
|
||||||
|
assert not mock_mail.return_value.send.called
|
||||||
|
|
||||||
|
def test_reads_timezone_and_falls_back_to_utc(self, app):
|
||||||
|
"""_get_local_hour uses User.timezone; None falls back to UTC."""
|
||||||
|
from utils.digest_scheduler import _get_local_hour
|
||||||
|
from datetime import datetime, timezone as tz
|
||||||
|
|
||||||
|
utc_hour = datetime.now(tz.utc).hour
|
||||||
|
assert _get_local_hour(None) == utc_hour
|
||||||
|
# A real timezone that differs from UTC (New York is UTC-4 or UTC-5)
|
||||||
|
result = _get_local_hour("America/New_York")
|
||||||
|
assert 0 <= result <= 23
|
||||||
|
|
||||||
|
def test_skips_when_db_env_is_e2e(self, app):
|
||||||
|
"""Digest scheduler does nothing in the e2e test environment."""
|
||||||
|
seed_scheduler_data()
|
||||||
|
original = os.environ.get('DB_ENV')
|
||||||
|
try:
|
||||||
|
os.environ['DB_ENV'] = 'e2e'
|
||||||
|
with patch('utils.email_sender.Mail') as mock_mail:
|
||||||
|
send_digests(app)
|
||||||
|
assert not mock_mail.return_value.send.called
|
||||||
|
finally:
|
||||||
|
if original is not None:
|
||||||
|
os.environ['DB_ENV'] = original
|
||||||
|
else:
|
||||||
|
os.environ.pop('DB_ENV', None)
|
||||||
|
|
||||||
|
|
||||||
|
class TestDigestEmailHtml:
|
||||||
|
"""Tests for the HTML content of send_digest_email."""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app_ctx(self, app):
|
||||||
|
with app.app_context():
|
||||||
|
yield
|
||||||
|
|
||||||
|
def _build_items(self):
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'child_name': 'Alice',
|
||||||
|
'entity_name': 'Clean Room',
|
||||||
|
'entity_type': 'chore',
|
||||||
|
'view_url': 'http://localhost:5173/parent/child1?scrollTo=task1&entityType=chore',
|
||||||
|
'approve_url': 'http://localhost:5173/api/digest-action/approve_tok',
|
||||||
|
'deny_url': 'http://localhost:5173/api/digest-action/deny_tok',
|
||||||
|
'child_id': 'child1',
|
||||||
|
'entity_id': 'task1',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'child_name': 'Alice',
|
||||||
|
'entity_name': 'Movie Night',
|
||||||
|
'entity_type': 'reward',
|
||||||
|
'view_url': 'http://localhost:5173/parent/child1?scrollTo=reward1&entityType=reward',
|
||||||
|
'approve_url': 'http://localhost:5173/api/digest-action/approve_tok2',
|
||||||
|
'deny_url': 'http://localhost:5173/api/digest-action/deny_tok2',
|
||||||
|
'child_id': 'child1',
|
||||||
|
'entity_id': 'reward1',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
def test_email_contains_child_section(self, app_ctx):
|
||||||
|
"""Email HTML contains a section for each child."""
|
||||||
|
with patch('utils.email_sender.Mail') as mock_mail:
|
||||||
|
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
|
||||||
|
msg = mock_mail.return_value.send.call_args[0][0]
|
||||||
|
assert 'Alice' in msg.html
|
||||||
|
|
||||||
|
def test_email_contains_item_names(self, app_ctx):
|
||||||
|
"""Email HTML lists each pending item's name."""
|
||||||
|
with patch('utils.email_sender.Mail') as mock_mail:
|
||||||
|
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
|
||||||
|
msg = mock_mail.return_value.send.call_args[0][0]
|
||||||
|
assert 'Clean Room' in msg.html
|
||||||
|
assert 'Movie Night' in msg.html
|
||||||
|
|
||||||
|
def test_email_contains_approve_and_deny_links(self, app_ctx):
|
||||||
|
"""Email HTML contains Approve and Deny links for each item."""
|
||||||
|
with patch('utils.email_sender.Mail') as mock_mail:
|
||||||
|
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
|
||||||
|
msg = mock_mail.return_value.send.call_args[0][0]
|
||||||
|
assert 'approve_tok' in msg.html
|
||||||
|
assert 'deny_tok' in msg.html
|
||||||
|
assert 'Approve' in msg.html
|
||||||
|
assert 'Deny' in msg.html
|
||||||
|
|
||||||
|
def test_email_contains_view_links(self, app_ctx):
|
||||||
|
"""Email HTML contains a View link for each item."""
|
||||||
|
with patch('utils.email_sender.Mail') as mock_mail:
|
||||||
|
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
|
||||||
|
msg = mock_mail.return_value.send.call_args[0][0]
|
||||||
|
assert 'View' in msg.html
|
||||||
|
assert 'scrollTo=task1' in msg.html
|
||||||
|
|
||||||
|
def test_email_contains_unsubscribe_link(self, app_ctx):
|
||||||
|
"""Email HTML footer contains the unsubscribe link."""
|
||||||
|
with patch('utils.email_sender.Mail') as mock_mail:
|
||||||
|
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
|
||||||
|
msg = mock_mail.return_value.send.call_args[0][0]
|
||||||
|
assert 'unsub_tok' in msg.html
|
||||||
|
assert 'Unsubscribe' in msg.html
|
||||||
|
|
||||||
|
def test_approve_link_styled_green(self, app_ctx):
|
||||||
|
"""Approve links use green color styling."""
|
||||||
|
with patch('utils.email_sender.Mail') as mock_mail:
|
||||||
|
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
|
||||||
|
msg = mock_mail.return_value.send.call_args[0][0]
|
||||||
|
# Find the Approve link and verify green color is adjacent
|
||||||
|
assert '#22c55e' in msg.html # green used for Approve
|
||||||
|
|
||||||
|
def test_deny_link_styled_red(self, app_ctx):
|
||||||
|
"""Deny links use red color styling."""
|
||||||
|
with patch('utils.email_sender.Mail') as mock_mail:
|
||||||
|
send_digest_email(SCHED_EMAIL, self._build_items(), 'unsub_tok')
|
||||||
|
msg = mock_mail.return_value.send.call_args[0][0]
|
||||||
|
assert '#ef4444' in msg.html # red used for Deny
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import os
|
||||||
|
import time
|
||||||
|
import pytest
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from db.db import digest_action_tokens_db
|
||||||
|
from utils.digest_token import (
|
||||||
|
create_action_token,
|
||||||
|
validate_and_consume_token,
|
||||||
|
create_unsubscribe_token,
|
||||||
|
validate_unsubscribe_token,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup():
|
||||||
|
digest_action_tokens_db.truncate()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clear_tokens():
|
||||||
|
cleanup()
|
||||||
|
yield
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateActionToken:
|
||||||
|
def test_returns_token_with_correct_fields(self):
|
||||||
|
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
|
||||||
|
assert token.user_id == 'u1'
|
||||||
|
assert token.child_id == 'c1'
|
||||||
|
assert token.entity_id == 'e1'
|
||||||
|
assert token.entity_type == 'chore'
|
||||||
|
assert token.action == 'approve'
|
||||||
|
assert token.used is False
|
||||||
|
assert token.signature
|
||||||
|
|
||||||
|
def test_persists_to_db(self):
|
||||||
|
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
|
||||||
|
stored = digest_action_tokens_db.get(Query().id == token.id)
|
||||||
|
assert stored is not None
|
||||||
|
assert stored['entity_type'] == 'chore'
|
||||||
|
|
||||||
|
def test_different_tokens_have_unique_ids(self):
|
||||||
|
t1 = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
|
||||||
|
t2 = create_action_token('u1', 'c1', 'e1', 'reward', 'deny')
|
||||||
|
assert t1.id != t2.id
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateAndConsumeToken:
|
||||||
|
def test_valid_token_is_returned_and_marked_used(self):
|
||||||
|
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
|
||||||
|
result = validate_and_consume_token(token.id)
|
||||||
|
assert result is not None
|
||||||
|
assert result.id == token.id
|
||||||
|
|
||||||
|
stored = digest_action_tokens_db.get(Query().id == token.id)
|
||||||
|
assert stored['used'] is True
|
||||||
|
|
||||||
|
def test_nonexistent_token_returns_none(self):
|
||||||
|
assert validate_and_consume_token('nonexistent-id') is None
|
||||||
|
|
||||||
|
def test_already_used_token_returns_none(self):
|
||||||
|
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
|
||||||
|
validate_and_consume_token(token.id) # first use
|
||||||
|
result = validate_and_consume_token(token.id) # second use
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_expired_token_returns_none(self):
|
||||||
|
# Create a token that is already expired
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from db.digest_action_tokens import insert_token
|
||||||
|
from models.digest_action_token import DigestActionToken
|
||||||
|
import uuid, json, hmac, hashlib
|
||||||
|
|
||||||
|
token_id = str(uuid.uuid4())
|
||||||
|
expires_at = (datetime.now(timezone.utc) - timedelta(hours=1)).isoformat()
|
||||||
|
payload = {
|
||||||
|
'id': token_id,
|
||||||
|
'user_id': 'u1',
|
||||||
|
'child_id': 'c1',
|
||||||
|
'entity_id': 'e1',
|
||||||
|
'entity_type': 'chore',
|
||||||
|
'action': 'approve',
|
||||||
|
'expires_at': expires_at,
|
||||||
|
}
|
||||||
|
secret = os.environ.get('DIGEST_TOKEN_SECRET')
|
||||||
|
canonical = json.dumps(payload, sort_keys=True, separators=(',', ':'))
|
||||||
|
sig = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest()
|
||||||
|
token = DigestActionToken(
|
||||||
|
id=token_id, user_id='u1', child_id='c1', entity_id='e1',
|
||||||
|
entity_type='chore', action='approve', expires_at=expires_at,
|
||||||
|
used=False, signature=sig,
|
||||||
|
)
|
||||||
|
insert_token(token)
|
||||||
|
assert validate_and_consume_token(token_id) is None
|
||||||
|
|
||||||
|
def test_tampered_signature_returns_none(self):
|
||||||
|
token = create_action_token('u1', 'c1', 'e1', 'chore', 'approve')
|
||||||
|
# Tamper the signature in DB
|
||||||
|
digest_action_tokens_db.update(
|
||||||
|
{'signature': 'deadbeef' * 8},
|
||||||
|
Query().id == token.id
|
||||||
|
)
|
||||||
|
assert validate_and_consume_token(token.id) is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnsubscribeToken:
|
||||||
|
def test_create_and_validate(self):
|
||||||
|
token = create_unsubscribe_token('user123')
|
||||||
|
assert token
|
||||||
|
result = validate_unsubscribe_token(token)
|
||||||
|
assert result == 'user123'
|
||||||
|
|
||||||
|
def test_invalid_token_returns_none(self):
|
||||||
|
assert validate_unsubscribe_token('garbage-token') is None
|
||||||
|
|
||||||
|
def test_tampered_token_returns_none(self):
|
||||||
|
token = create_unsubscribe_token('user123')
|
||||||
|
# Change one character
|
||||||
|
tampered = token[:-1] + ('A' if token[-1] != 'A' else 'B')
|
||||||
|
assert validate_unsubscribe_token(tampered) is None
|
||||||
|
|
||||||
|
def test_expired_token_returns_none(self):
|
||||||
|
"""Build a token with a past expiry timestamp by mocking time."""
|
||||||
|
import base64, hmac, hashlib
|
||||||
|
user_id = 'user_expired'
|
||||||
|
expiry_ts = int(time.time()) - 1 # already expired
|
||||||
|
payload_str = f"{user_id}:{expiry_ts}"
|
||||||
|
secret = os.environ.get('DIGEST_TOKEN_SECRET')
|
||||||
|
sig = hmac.new(secret.encode(), payload_str.encode(), hashlib.sha256).hexdigest()
|
||||||
|
raw = f"{payload_str}:{sig}"
|
||||||
|
token = base64.urlsafe_b64encode(raw.encode()).decode()
|
||||||
|
assert validate_unsubscribe_token(token) is None
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from db.db import (
|
||||||
|
MongoLockedTable,
|
||||||
|
_query_to_mongo_filter,
|
||||||
|
child_db,
|
||||||
|
task_db,
|
||||||
|
users_db,
|
||||||
|
refresh_tokens_db,
|
||||||
|
)
|
||||||
|
from db.mongo_client import get_mongo_client, get_mongo_db_name
|
||||||
|
|
||||||
|
|
||||||
|
# All tests in this module require the mongomock-backed MongoDB adapter.
|
||||||
|
pytestmark = pytest.mark.skipif(
|
||||||
|
os.environ.get('USE_MONGODB', 'true').lower() != 'true',
|
||||||
|
reason='MongoDB adapter tests require USE_MONGODB=true',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def clean_mongo_collections():
|
||||||
|
"""Truncate relevant collections before each test."""
|
||||||
|
child_db.truncate()
|
||||||
|
task_db.truncate()
|
||||||
|
users_db.truncate()
|
||||||
|
refresh_tokens_db.truncate()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
class TestQueryTranslation:
|
||||||
|
"""Unit tests for TinyDB Query -> MongoDB filter translation."""
|
||||||
|
|
||||||
|
def test_simple_equality(self):
|
||||||
|
q = Query()
|
||||||
|
assert _query_to_mongo_filter(q.id == 'abc') == {'_id': {'$eq': 'abc'}}
|
||||||
|
|
||||||
|
def test_field_other_than_id(self):
|
||||||
|
q = Query()
|
||||||
|
assert _query_to_mongo_filter(q.user_id == 'u1') == {
|
||||||
|
'user_id': {'$eq': 'u1'}
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_and_query(self):
|
||||||
|
q = Query()
|
||||||
|
mongo_filter = _query_to_mongo_filter(
|
||||||
|
(q.id == 'abc') & (q.user_id == 'u1')
|
||||||
|
)
|
||||||
|
assert mongo_filter == {'_id': {'$eq': 'abc'}, 'user_id': {'$eq': 'u1'}}
|
||||||
|
|
||||||
|
def test_or_query(self):
|
||||||
|
q = Query()
|
||||||
|
mongo_filter = _query_to_mongo_filter(
|
||||||
|
(q.user_id == 'u1') | (q.user_id == None) # noqa: E711
|
||||||
|
)
|
||||||
|
assert mongo_filter == {
|
||||||
|
'$or': [
|
||||||
|
{'user_id': {'$eq': 'u1'}},
|
||||||
|
{'user_id': {'$eq': None}},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
def test_and_with_nested_or(self):
|
||||||
|
q = Query()
|
||||||
|
mongo_filter = _query_to_mongo_filter(
|
||||||
|
(q.id == 'abc') & ((q.user_id == 'u1') | (q.user_id == None)) # noqa: E711
|
||||||
|
)
|
||||||
|
assert mongo_filter == {
|
||||||
|
'_id': {'$eq': 'abc'},
|
||||||
|
'$or': [
|
||||||
|
{'user_id': {'$eq': 'u1'}},
|
||||||
|
{'user_id': {'$eq': None}},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class TestMongoCrud:
|
||||||
|
"""CRUD tests against the mongomock-backed MongoLockedTable."""
|
||||||
|
|
||||||
|
def test_insert_maps_id_to_underscore_id(self):
|
||||||
|
child_db.insert({'id': 'c1', 'name': 'Alice', 'age': 8})
|
||||||
|
|
||||||
|
raw = get_mongo_client()[get_mongo_db_name()]['children'].find_one(
|
||||||
|
{'_id': 'c1'}
|
||||||
|
)
|
||||||
|
assert raw is not None
|
||||||
|
assert raw['_id'] == 'c1'
|
||||||
|
assert 'id' not in raw
|
||||||
|
assert raw['name'] == 'Alice'
|
||||||
|
|
||||||
|
def test_get_returns_document_without_underscore_id(self):
|
||||||
|
child_db.insert({'id': 'c1', 'name': 'Alice', 'age': 8})
|
||||||
|
|
||||||
|
doc = child_db.get(Query().id == 'c1')
|
||||||
|
assert doc is not None
|
||||||
|
assert doc['id'] == 'c1'
|
||||||
|
assert doc['name'] == 'Alice'
|
||||||
|
assert '_id' not in doc
|
||||||
|
|
||||||
|
def test_get_none_when_missing(self):
|
||||||
|
assert child_db.get(Query().id == 'missing') is None
|
||||||
|
|
||||||
|
def test_search_with_query(self):
|
||||||
|
child_db.insert({'id': 'c1', 'name': 'Alice', 'user_id': 'u1'})
|
||||||
|
child_db.insert({'id': 'c2', 'name': 'Bob', 'user_id': 'u1'})
|
||||||
|
child_db.insert({'id': 'c3', 'name': 'Carol', 'user_id': 'u2'})
|
||||||
|
|
||||||
|
results = child_db.search(Query().user_id == 'u1')
|
||||||
|
assert len(results) == 2
|
||||||
|
assert {r['id'] for r in results} == {'c1', 'c2'}
|
||||||
|
|
||||||
|
def test_search_with_or(self):
|
||||||
|
task_db.insert({'id': 't1', 'name': 'Default', 'user_id': None})
|
||||||
|
task_db.insert({'id': 't2', 'name': 'User task', 'user_id': 'u1'})
|
||||||
|
|
||||||
|
q = Query()
|
||||||
|
results = task_db.search((q.user_id == 'u1') | (q.user_id == None)) # noqa: E711
|
||||||
|
assert len(results) == 2
|
||||||
|
|
||||||
|
def test_update_modifies_matching_documents(self):
|
||||||
|
child_db.insert({'id': 'c1', 'name': 'Alice', 'points': 0})
|
||||||
|
child_db.insert({'id': 'c2', 'name': 'Bob', 'points': 0})
|
||||||
|
|
||||||
|
modified = child_db.update({'points': 10}, Query().id == 'c1')
|
||||||
|
# TinyDB returns a list of updated document ids; the adapter mirrors that.
|
||||||
|
assert modified == ['c1']
|
||||||
|
|
||||||
|
doc = child_db.get(Query().id == 'c1')
|
||||||
|
assert doc['points'] == 10
|
||||||
|
|
||||||
|
other = child_db.get(Query().id == 'c2')
|
||||||
|
assert other['points'] == 0
|
||||||
|
|
||||||
|
def test_update_does_not_overwrite_id(self):
|
||||||
|
child_db.insert({'id': 'c1', 'name': 'Alice'})
|
||||||
|
|
||||||
|
child_db.update({'id': 'c2', 'name': 'Alice Smith'}, Query().id == 'c1')
|
||||||
|
# The id field must remain unchanged; update should have stripped id.
|
||||||
|
assert child_db.get(Query().id == 'c1')['name'] == 'Alice Smith'
|
||||||
|
assert child_db.get(Query().id == 'c2') is None
|
||||||
|
|
||||||
|
def test_remove_deletes_matching_documents(self):
|
||||||
|
child_db.insert({'id': 'c1', 'name': 'Alice'})
|
||||||
|
child_db.insert({'id': 'c2', 'name': 'Bob'})
|
||||||
|
|
||||||
|
deleted = child_db.remove(Query().id == 'c1')
|
||||||
|
# TinyDB returns a list of removed document ids; the adapter mirrors that.
|
||||||
|
assert deleted == ['c1']
|
||||||
|
|
||||||
|
assert child_db.get(Query().id == 'c1') is None
|
||||||
|
assert child_db.get(Query().id == 'c2') is not None
|
||||||
|
|
||||||
|
def test_all_returns_all_documents(self):
|
||||||
|
child_db.insert({'id': 'c1', 'name': 'Alice'})
|
||||||
|
child_db.insert({'id': 'c2', 'name': 'Bob'})
|
||||||
|
|
||||||
|
docs = child_db.all()
|
||||||
|
assert len(docs) == 2
|
||||||
|
assert all('_id' not in d for d in docs)
|
||||||
|
|
||||||
|
def test_truncate_removes_all_documents(self):
|
||||||
|
child_db.insert({'id': 'c1', 'name': 'Alice'})
|
||||||
|
child_db.truncate()
|
||||||
|
assert child_db.all() == []
|
||||||
|
|
||||||
|
def test_insert_multiple(self):
|
||||||
|
ids = child_db.insert_multiple([
|
||||||
|
{'id': 'c1', 'name': 'Alice'},
|
||||||
|
{'id': 'c2', 'name': 'Bob'},
|
||||||
|
])
|
||||||
|
assert sorted(ids) == ['c1', 'c2']
|
||||||
|
assert len(child_db.all()) == 2
|
||||||
|
|
||||||
|
def test_unique_token_index(self):
|
||||||
|
refresh_tokens_db.insert({'id': 'r1', 'token': 'abc', 'user_id': 'u1'})
|
||||||
|
refresh_tokens_db.insert({'id': 'r2', 'token': 'def', 'user_id': 'u1'})
|
||||||
|
|
||||||
|
# mongomock does not enforce unique indexes by default, but we verify
|
||||||
|
# both records are readable.
|
||||||
|
assert refresh_tokens_db.get(Query().token == 'abc') is not None
|
||||||
|
assert refresh_tokens_db.get(Query().token == 'def') is not None
|
||||||
|
|
||||||
|
def test_user_id_secondary_index_is_created(self):
|
||||||
|
# Insert and query via the secondary index path used by the app.
|
||||||
|
users_db.insert({'id': 'u1', 'email': 'a@example.com', 'verified': True})
|
||||||
|
users_db.insert({'id': 'u2', 'email': 'b@example.com', 'verified': False})
|
||||||
|
|
||||||
|
found = users_db.search(Query().verified == True) # noqa: E712
|
||||||
|
assert len(found) == 1
|
||||||
|
assert found[0]['id'] == 'u1'
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdapterApi:
|
||||||
|
"""Tests that the adapter exposes the expected LockedTable-compatible API."""
|
||||||
|
|
||||||
|
def test_close_is_noop(self):
|
||||||
|
# Existing cleanup fixtures call ``*_db.close()``; ensure it does not
|
||||||
|
# raise for the MongoDB-backed adapter.
|
||||||
|
child_db.close()
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from flask import Flask
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from api.push_subscription_api import push_subscription_api
|
||||||
|
from api.auth_api import auth_api
|
||||||
|
from db.db import users_db, push_subscriptions_db
|
||||||
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
|
|
||||||
|
TEST_EMAIL = "pushtest@example.com"
|
||||||
|
TEST_PASSWORD = "pushpassword123"
|
||||||
|
TEST_USER_ID = "push_test_user_id"
|
||||||
|
TEST_ENDPOINT = "https://fcm.googleapis.com/fcm/send/test-endpoint-abc"
|
||||||
|
TEST_KEYS = {"p256dh": "BNgz3XcMv1", "auth": "abc123"}
|
||||||
|
|
||||||
|
|
||||||
|
def seed_user():
|
||||||
|
users_db.remove(Query().email == TEST_EMAIL)
|
||||||
|
users_db.insert({
|
||||||
|
"id": TEST_USER_ID,
|
||||||
|
"first_name": "Push",
|
||||||
|
"last_name": "Tester",
|
||||||
|
"email": TEST_EMAIL,
|
||||||
|
"password": generate_password_hash(TEST_PASSWORD),
|
||||||
|
"verified": True,
|
||||||
|
"image_id": None,
|
||||||
|
"marked_for_deletion": False,
|
||||||
|
"marked_for_deletion_at": None,
|
||||||
|
"role": "user",
|
||||||
|
"timezone": None,
|
||||||
|
"email_digest_enabled": True,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup():
|
||||||
|
users_db.remove(Query().email == TEST_EMAIL)
|
||||||
|
push_subscriptions_db.remove(Query().user_id == TEST_USER_ID)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(push_subscription_api)
|
||||||
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
app.config['SECRET_KEY'] = TEST_SECRET_KEY
|
||||||
|
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
|
app.config['VAPID_PUBLIC_KEY'] = 'test-vapid-public-key'
|
||||||
|
app.config['FRONTEND_URL'] = 'http://localhost:5173'
|
||||||
|
cleanup()
|
||||||
|
seed_user()
|
||||||
|
with app.test_client() as c:
|
||||||
|
yield c
|
||||||
|
cleanup()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def auth_client(client):
|
||||||
|
client.post('/auth/login', json={"email": TEST_EMAIL, "password": TEST_PASSWORD})
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
|
class TestVapidKey:
|
||||||
|
def test_returns_public_key(self, client):
|
||||||
|
res = client.get('/push-vapid-key')
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.get_json()['public_key'] == 'test-vapid-public-key'
|
||||||
|
|
||||||
|
def test_unauthenticated_ok(self, client):
|
||||||
|
# VAPID key endpoint is public
|
||||||
|
res = client.get('/push-vapid-key')
|
||||||
|
assert res.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestSubscribe:
|
||||||
|
def test_subscribe_stores_subscription(self, auth_client):
|
||||||
|
res = auth_client.post('/push-subscription', json={
|
||||||
|
"endpoint": TEST_ENDPOINT,
|
||||||
|
"keys": TEST_KEYS,
|
||||||
|
})
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.get_json()
|
||||||
|
assert 'id' in data
|
||||||
|
|
||||||
|
saved = push_subscriptions_db.get(
|
||||||
|
(Query().user_id == TEST_USER_ID) & (Query().endpoint == TEST_ENDPOINT)
|
||||||
|
)
|
||||||
|
assert saved is not None
|
||||||
|
|
||||||
|
def test_subscribe_updates_timezone(self, auth_client):
|
||||||
|
auth_client.post('/push-subscription', json={
|
||||||
|
"endpoint": TEST_ENDPOINT,
|
||||||
|
"keys": TEST_KEYS,
|
||||||
|
"timezone": "America/New_York",
|
||||||
|
})
|
||||||
|
user = users_db.get(Query().id == TEST_USER_ID)
|
||||||
|
assert user['timezone'] == 'America/New_York'
|
||||||
|
|
||||||
|
def test_subscribe_upserts_same_endpoint(self, auth_client):
|
||||||
|
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
|
||||||
|
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
|
||||||
|
count = len(push_subscriptions_db.search(
|
||||||
|
(Query().user_id == TEST_USER_ID) & (Query().endpoint == TEST_ENDPOINT)
|
||||||
|
))
|
||||||
|
assert count == 1
|
||||||
|
|
||||||
|
def test_subscribe_requires_endpoint(self, auth_client):
|
||||||
|
res = auth_client.post('/push-subscription', json={"keys": TEST_KEYS})
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
def test_subscribe_requires_keys(self, auth_client):
|
||||||
|
res = auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT})
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
def test_subscribe_requires_p256dh_and_auth(self, auth_client):
|
||||||
|
res = auth_client.post('/push-subscription', json={
|
||||||
|
"endpoint": TEST_ENDPOINT,
|
||||||
|
"keys": {"p256dh": "only-one-key"},
|
||||||
|
})
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
def test_subscribe_requires_auth(self, client):
|
||||||
|
res = client.post('/push-subscription', json={
|
||||||
|
"endpoint": TEST_ENDPOINT,
|
||||||
|
"keys": TEST_KEYS,
|
||||||
|
})
|
||||||
|
assert res.status_code == 401
|
||||||
|
|
||||||
|
def test_subscribe_multiple_endpoints_per_user(self, auth_client):
|
||||||
|
"""Multiple subscriptions can coexist for the same user (one per device)."""
|
||||||
|
endpoint2 = "https://fcm.googleapis.com/fcm/send/second-device-endpoint"
|
||||||
|
keys2 = {"p256dh": "BNgz3XcMv2", "auth": "def456"}
|
||||||
|
|
||||||
|
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
|
||||||
|
auth_client.post('/push-subscription', json={"endpoint": endpoint2, "keys": keys2})
|
||||||
|
|
||||||
|
subs = push_subscriptions_db.search(Query().user_id == TEST_USER_ID)
|
||||||
|
assert len(subs) == 2
|
||||||
|
endpoints = {s['endpoint'] for s in subs}
|
||||||
|
assert TEST_ENDPOINT in endpoints
|
||||||
|
assert endpoint2 in endpoints
|
||||||
|
|
||||||
|
|
||||||
|
class TestUnsubscribe:
|
||||||
|
def test_unsubscribe_removes_subscription(self, auth_client):
|
||||||
|
auth_client.post('/push-subscription', json={"endpoint": TEST_ENDPOINT, "keys": TEST_KEYS})
|
||||||
|
res = auth_client.delete('/push-subscription', json={"endpoint": TEST_ENDPOINT})
|
||||||
|
assert res.status_code == 200
|
||||||
|
data = res.get_json()
|
||||||
|
assert data['removed'] >= 1
|
||||||
|
|
||||||
|
saved = push_subscriptions_db.get(
|
||||||
|
(Query().user_id == TEST_USER_ID) & (Query().endpoint == TEST_ENDPOINT)
|
||||||
|
)
|
||||||
|
assert saved is None
|
||||||
|
|
||||||
|
def test_unsubscribe_nonexistent_endpoint_ok(self, auth_client):
|
||||||
|
res = auth_client.delete('/push-subscription', json={"endpoint": "https://nonexistent"})
|
||||||
|
assert res.status_code == 200
|
||||||
|
assert res.get_json()['removed'] == 0
|
||||||
|
|
||||||
|
def test_unsubscribe_requires_endpoint(self, auth_client):
|
||||||
|
res = auth_client.delete('/push-subscription', json={})
|
||||||
|
assert res.status_code == 400
|
||||||
|
|
||||||
|
def test_unsubscribe_requires_auth(self, client):
|
||||||
|
res = client.delete('/push-subscription', json={"endpoint": TEST_ENDPOINT})
|
||||||
|
assert res.status_code == 401
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
"""Unit tests for routine API endpoints."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from tinydb import Query
|
||||||
|
from models.routine import Routine
|
||||||
|
from models.routine_item import RoutineItem
|
||||||
|
from models.routine_schedule import RoutineSchedule
|
||||||
|
from models.routine_extension import RoutineExtension
|
||||||
|
from models.pending_confirmation import PendingConfirmation
|
||||||
|
from api.error_codes import ErrorCodes
|
||||||
|
from db.db import routine_db, routine_items_db, routine_schedules_db, routine_extensions_db, pending_confirmations_db
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoutineModel:
|
||||||
|
"""Test Routine model serialization and validation."""
|
||||||
|
|
||||||
|
def test_routine_creation(self):
|
||||||
|
"""Test creating a routine instance."""
|
||||||
|
routine = Routine(name="Morning Routine", points=50, image_id="img123", user_id="user1")
|
||||||
|
assert routine.name == "Morning Routine"
|
||||||
|
assert routine.points == 50
|
||||||
|
assert routine.image_id == "img123"
|
||||||
|
assert routine.user_id == "user1"
|
||||||
|
assert routine.id is not None
|
||||||
|
|
||||||
|
def test_routine_to_dict(self):
|
||||||
|
"""Test routine serialization."""
|
||||||
|
routine = Routine(name="Test", points=10, image_id=None, user_id="user1")
|
||||||
|
data = routine.to_dict()
|
||||||
|
assert data["name"] == "Test"
|
||||||
|
assert data["points"] == 10
|
||||||
|
assert data["id"] == routine.id
|
||||||
|
|
||||||
|
def test_routine_from_dict(self):
|
||||||
|
"""Test routine deserialization."""
|
||||||
|
routine_dict = {"id": "r1", "name": "Test", "points": 20, "image_id": "img1", "user_id": "u1"}
|
||||||
|
routine = Routine.from_dict(routine_dict)
|
||||||
|
assert routine.id == "r1"
|
||||||
|
assert routine.name == "Test"
|
||||||
|
assert routine.points == 20
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoutineItemModel:
|
||||||
|
"""Test RoutineItem model."""
|
||||||
|
|
||||||
|
def test_routine_item_creation(self):
|
||||||
|
"""Test creating a routine item."""
|
||||||
|
item = RoutineItem(routine_id="r1", name="Make Bed", image_id=None, order=0)
|
||||||
|
assert item.routine_id == "r1"
|
||||||
|
assert item.name == "Make Bed"
|
||||||
|
assert item.order == 0
|
||||||
|
|
||||||
|
def test_routine_item_to_dict(self):
|
||||||
|
"""Test item serialization."""
|
||||||
|
item = RoutineItem(routine_id="r1", name="Get Dressed", image_id="img1", order=1)
|
||||||
|
data = item.to_dict()
|
||||||
|
assert data["routine_id"] == "r1"
|
||||||
|
assert data["name"] == "Get Dressed"
|
||||||
|
assert data["order"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoutineScheduleModel:
|
||||||
|
"""Test RoutineSchedule model."""
|
||||||
|
|
||||||
|
def test_days_mode_schedule_creation(self):
|
||||||
|
"""Test creating days mode schedule."""
|
||||||
|
day_configs = [
|
||||||
|
{"day": 0, "hour": 8, "minute": 0},
|
||||||
|
{"day": 1, "hour": 9, "minute": 30},
|
||||||
|
]
|
||||||
|
schedule = RoutineSchedule(
|
||||||
|
child_id="c1",
|
||||||
|
routine_id="r1",
|
||||||
|
mode="days",
|
||||||
|
enabled=True,
|
||||||
|
day_configs=day_configs,
|
||||||
|
default_hour=8,
|
||||||
|
default_minute=0,
|
||||||
|
default_has_deadline=True,
|
||||||
|
)
|
||||||
|
assert schedule.mode == "days"
|
||||||
|
assert len(schedule.day_configs) == 2
|
||||||
|
assert schedule.enabled is True
|
||||||
|
|
||||||
|
def test_interval_mode_schedule_creation(self):
|
||||||
|
"""Test creating interval mode schedule."""
|
||||||
|
schedule = RoutineSchedule(
|
||||||
|
child_id="c1",
|
||||||
|
routine_id="r1",
|
||||||
|
mode="interval",
|
||||||
|
enabled=True,
|
||||||
|
interval_days=3,
|
||||||
|
anchor_date="2026-05-01",
|
||||||
|
interval_has_deadline=True,
|
||||||
|
interval_hour=10,
|
||||||
|
interval_minute=30,
|
||||||
|
)
|
||||||
|
assert schedule.mode == "interval"
|
||||||
|
assert schedule.interval_days == 3
|
||||||
|
assert schedule.interval_hour == 10
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoutineExtensionModel:
|
||||||
|
"""Test RoutineExtension model."""
|
||||||
|
|
||||||
|
def test_extension_creation(self):
|
||||||
|
"""Test creating a routine extension."""
|
||||||
|
extension = RoutineExtension(child_id="c1", routine_id="r1", date="2026-05-10")
|
||||||
|
assert extension.child_id == "c1"
|
||||||
|
assert extension.routine_id == "r1"
|
||||||
|
assert extension.date == "2026-05-10"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoutineDB:
|
||||||
|
"""Test routine database operations."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Clear routine db before each test."""
|
||||||
|
routine_db.truncate()
|
||||||
|
|
||||||
|
def test_add_routine(self):
|
||||||
|
"""Test adding a routine to database."""
|
||||||
|
routine = Routine(name="Test Routine", points=50, image_id=None, user_id="user1")
|
||||||
|
routine_db.insert(routine.to_dict())
|
||||||
|
|
||||||
|
result = routine_db.search(Query().id == routine.id)
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["name"] == "Test Routine"
|
||||||
|
|
||||||
|
def test_get_routine_by_id(self):
|
||||||
|
"""Test fetching routine by ID."""
|
||||||
|
routine = Routine(name="Fetch Test", points=30, image_id=None, user_id="user1")
|
||||||
|
routine_db.insert(routine.to_dict())
|
||||||
|
|
||||||
|
result = routine_db.get(Query().id == routine.id)
|
||||||
|
assert result is not None
|
||||||
|
assert result["name"] == "Fetch Test"
|
||||||
|
|
||||||
|
def test_list_user_routines(self):
|
||||||
|
"""Test listing routines for a user."""
|
||||||
|
r1 = Routine(name="R1", points=10, image_id=None, user_id="user1")
|
||||||
|
r2 = Routine(name="R2", points=20, image_id=None, user_id="user1")
|
||||||
|
r3 = Routine(name="R3", points=15, image_id=None, user_id="user2")
|
||||||
|
|
||||||
|
routine_db.insert(r1.to_dict())
|
||||||
|
routine_db.insert(r2.to_dict())
|
||||||
|
routine_db.insert(r3.to_dict())
|
||||||
|
|
||||||
|
results = routine_db.search(Query().user_id == "user1")
|
||||||
|
assert len(results) == 2
|
||||||
|
|
||||||
|
def test_update_routine(self):
|
||||||
|
"""Test updating a routine."""
|
||||||
|
routine = Routine(name="Original", points=50, image_id=None, user_id="user1")
|
||||||
|
routine_db.insert(routine.to_dict())
|
||||||
|
|
||||||
|
routine_db.update({"name": "Updated", "points": 100}, Query().id == routine.id)
|
||||||
|
result = routine_db.get(Query().id == routine.id)
|
||||||
|
assert result["name"] == "Updated"
|
||||||
|
assert result["points"] == 100
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoutineItemDB:
|
||||||
|
"""Test routine item database operations."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Clear item db before each test."""
|
||||||
|
routine_items_db.truncate()
|
||||||
|
|
||||||
|
def test_add_routine_item(self):
|
||||||
|
"""Test adding a routine item."""
|
||||||
|
item = RoutineItem(routine_id="r1", name="Make Bed", image_id=None, order=0)
|
||||||
|
routine_items_db.insert(item.to_dict())
|
||||||
|
|
||||||
|
result = routine_items_db.search(Query().routine_id == "r1")
|
||||||
|
assert len(result) == 1
|
||||||
|
assert result[0]["name"] == "Make Bed"
|
||||||
|
|
||||||
|
def test_get_items_for_routine(self):
|
||||||
|
"""Test fetching all items for a routine."""
|
||||||
|
i1 = RoutineItem(routine_id="r1", name="Item1", image_id=None, order=0)
|
||||||
|
i2 = RoutineItem(routine_id="r1", name="Item2", image_id=None, order=1)
|
||||||
|
i3 = RoutineItem(routine_id="r2", name="Item3", image_id=None, order=0)
|
||||||
|
|
||||||
|
routine_items_db.insert(i1.to_dict())
|
||||||
|
routine_items_db.insert(i2.to_dict())
|
||||||
|
routine_items_db.insert(i3.to_dict())
|
||||||
|
|
||||||
|
results = routine_items_db.search(Query().routine_id == "r1")
|
||||||
|
assert len(results) == 2
|
||||||
|
|
||||||
|
def test_delete_item(self):
|
||||||
|
"""Test deleting a routine item."""
|
||||||
|
item = RoutineItem(routine_id="r1", name="Test", image_id=None, order=0)
|
||||||
|
routine_items_db.insert(item.to_dict())
|
||||||
|
|
||||||
|
routine_items_db.remove(Query().id == item.id)
|
||||||
|
result = routine_items_db.search(Query().id == item.id)
|
||||||
|
assert len(result) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestRoutineScheduleDB:
|
||||||
|
"""Test routine schedule database operations."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Clear schedule db before each test."""
|
||||||
|
routine_schedules_db.truncate()
|
||||||
|
|
||||||
|
def test_upsert_schedule(self):
|
||||||
|
"""Test upserting a routine schedule."""
|
||||||
|
day_configs = [{"day": 0, "hour": 8, "minute": 0}]
|
||||||
|
schedule = RoutineSchedule(
|
||||||
|
child_id="c1",
|
||||||
|
routine_id="r1",
|
||||||
|
mode="days",
|
||||||
|
enabled=True,
|
||||||
|
day_configs=day_configs,
|
||||||
|
default_hour=8,
|
||||||
|
default_minute=0,
|
||||||
|
default_has_deadline=True,
|
||||||
|
)
|
||||||
|
routine_schedules_db.insert(schedule.to_dict())
|
||||||
|
|
||||||
|
result = routine_schedules_db.get(
|
||||||
|
(Query().child_id == "c1") & (Query().routine_id == "r1")
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert result["mode"] == "days"
|
||||||
|
|
||||||
|
def test_delete_schedule(self):
|
||||||
|
"""Test deleting a schedule."""
|
||||||
|
schedule = RoutineSchedule(
|
||||||
|
child_id="c1",
|
||||||
|
routine_id="r1",
|
||||||
|
mode="interval",
|
||||||
|
enabled=True,
|
||||||
|
interval_days=2,
|
||||||
|
anchor_date="2026-05-01",
|
||||||
|
interval_has_deadline=True,
|
||||||
|
interval_hour=10,
|
||||||
|
interval_minute=0,
|
||||||
|
)
|
||||||
|
routine_schedules_db.insert(schedule.to_dict())
|
||||||
|
|
||||||
|
routine_schedules_db.remove(
|
||||||
|
(Query().child_id == "c1") & (Query().routine_id == "r1")
|
||||||
|
)
|
||||||
|
result = routine_schedules_db.search(Query().child_id == "c1")
|
||||||
|
assert len(result) == 0
|
||||||
|
|
||||||
|
|
||||||
|
class TestPendingRoutineConfirmation:
|
||||||
|
"""Test pending routine confirmation workflow."""
|
||||||
|
|
||||||
|
def setup_method(self):
|
||||||
|
"""Clear db before each test."""
|
||||||
|
pending_confirmations_db.truncate()
|
||||||
|
|
||||||
|
def test_create_routine_confirmation(self):
|
||||||
|
"""Test creating a pending routine confirmation."""
|
||||||
|
confirmation = PendingConfirmation(
|
||||||
|
child_id="c1",
|
||||||
|
entity_id="r1",
|
||||||
|
entity_type="routine",
|
||||||
|
user_id="u1",
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
pending_confirmations_db.insert(confirmation.to_dict())
|
||||||
|
|
||||||
|
result = pending_confirmations_db.get(
|
||||||
|
(Query().child_id == "c1") & (Query().entity_id == "r1") & (Query().entity_type == "routine")
|
||||||
|
)
|
||||||
|
assert result is not None
|
||||||
|
assert result["status"] == "pending"
|
||||||
|
|
||||||
|
def test_approve_routine_confirmation(self):
|
||||||
|
"""Test approving a routine confirmation."""
|
||||||
|
confirmation = PendingConfirmation(
|
||||||
|
child_id="c1",
|
||||||
|
entity_id="r1",
|
||||||
|
entity_type="routine",
|
||||||
|
user_id="u1",
|
||||||
|
status="pending",
|
||||||
|
)
|
||||||
|
pending_confirmations_db.insert(confirmation.to_dict())
|
||||||
|
|
||||||
|
today_utc = datetime.now(timezone.utc).isoformat()
|
||||||
|
pending_confirmations_db.update(
|
||||||
|
{"status": "approved", "approved_at": today_utc},
|
||||||
|
Query().id == confirmation.id,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = pending_confirmations_db.get(Query().id == confirmation.id)
|
||||||
|
assert result["status"] == "approved"
|
||||||
|
assert result["approved_at"] is not None
|
||||||
@@ -0,0 +1,187 @@
|
|||||||
|
from flask import Flask
|
||||||
|
from tinydb import Query
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
from tests.conftest import TEST_SECRET_KEY, TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
|
from api.auth_api import auth_api
|
||||||
|
from api.routine_api import routine_api
|
||||||
|
from api.routine_item_api import routine_item_api
|
||||||
|
from api.child_routine_api import child_routine_api
|
||||||
|
from api.routine_schedule_api import routine_schedule_api
|
||||||
|
from db.db import (
|
||||||
|
users_db,
|
||||||
|
child_db,
|
||||||
|
routine_db,
|
||||||
|
routine_items_db,
|
||||||
|
routine_schedules_db,
|
||||||
|
routine_extensions_db,
|
||||||
|
pending_confirmations_db,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
TEST_USER_ID = 'routine-user-1'
|
||||||
|
TEST_EMAIL = 'routine-user@example.com'
|
||||||
|
TEST_PASSWORD = 'testpass'
|
||||||
|
|
||||||
|
|
||||||
|
def add_test_user():
|
||||||
|
users_db.remove(Query().email == TEST_EMAIL)
|
||||||
|
users_db.insert({
|
||||||
|
'id': TEST_USER_ID,
|
||||||
|
'first_name': 'Routine',
|
||||||
|
'last_name': 'Tester',
|
||||||
|
'email': TEST_EMAIL,
|
||||||
|
'password': generate_password_hash(TEST_PASSWORD),
|
||||||
|
'verified': True,
|
||||||
|
'image_id': 'boy01',
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def login_and_set_cookie(client):
|
||||||
|
resp = client.post('/auth/login', json={'email': TEST_EMAIL, 'password': TEST_PASSWORD})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def seed_child(child_id: str):
|
||||||
|
child_db.remove(Query().id == child_id)
|
||||||
|
child_db.insert({
|
||||||
|
'id': child_id,
|
||||||
|
'name': 'Routine Kid',
|
||||||
|
'age': 9,
|
||||||
|
'tasks': [],
|
||||||
|
'routines': [],
|
||||||
|
'rewards': [],
|
||||||
|
'points': 0,
|
||||||
|
'image_id': 'boy01',
|
||||||
|
'user_id': TEST_USER_ID,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _first_routine_id():
|
||||||
|
routines = routine_db.all()
|
||||||
|
assert routines
|
||||||
|
return routines[0]['id']
|
||||||
|
|
||||||
|
|
||||||
|
def _first_confirmation_id():
|
||||||
|
confirmations = pending_confirmations_db.all()
|
||||||
|
assert confirmations
|
||||||
|
return confirmations[0]['id']
|
||||||
|
|
||||||
|
|
||||||
|
def _make_client():
|
||||||
|
app = Flask(__name__)
|
||||||
|
app.register_blueprint(auth_api, url_prefix='/auth')
|
||||||
|
app.register_blueprint(routine_api)
|
||||||
|
app.register_blueprint(routine_item_api)
|
||||||
|
app.register_blueprint(child_routine_api)
|
||||||
|
app.register_blueprint(routine_schedule_api)
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
app.config['SECRET_KEY'] = TEST_SECRET_KEY
|
||||||
|
app.config['REFRESH_TOKEN_EXPIRY_DAYS'] = TEST_REFRESH_TOKEN_EXPIRY_DAYS
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
def setup_function(_):
|
||||||
|
routine_db.truncate()
|
||||||
|
routine_items_db.truncate()
|
||||||
|
routine_schedules_db.truncate()
|
||||||
|
routine_extensions_db.truncate()
|
||||||
|
pending_confirmations_db.truncate()
|
||||||
|
child_db.truncate()
|
||||||
|
|
||||||
|
|
||||||
|
def test_routine_crud_flow():
|
||||||
|
app = _make_client()
|
||||||
|
with app.test_client() as client:
|
||||||
|
add_test_user()
|
||||||
|
login_and_set_cookie(client)
|
||||||
|
|
||||||
|
add_resp = client.put('/routine/add', json={'name': 'Morning Routine', 'points': 8, 'image_id': 'sun'})
|
||||||
|
assert add_resp.status_code == 201
|
||||||
|
|
||||||
|
rid = _first_routine_id()
|
||||||
|
|
||||||
|
list_resp = client.get('/routine/list')
|
||||||
|
assert list_resp.status_code == 200
|
||||||
|
routines = list_resp.get_json()['routines']
|
||||||
|
assert len(routines) == 1
|
||||||
|
assert routines[0]['name'] == 'Morning Routine'
|
||||||
|
|
||||||
|
edit_resp = client.put(f'/routine/{rid}/edit', json={'points': 10})
|
||||||
|
assert edit_resp.status_code == 200
|
||||||
|
assert edit_resp.get_json()['points'] == 10
|
||||||
|
|
||||||
|
delete_resp = client.delete(f'/routine/{rid}')
|
||||||
|
assert delete_resp.status_code == 200
|
||||||
|
assert routine_db.all() == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_child_routine_assignment_confirmation_and_approval():
|
||||||
|
app = _make_client()
|
||||||
|
with app.test_client() as client:
|
||||||
|
add_test_user()
|
||||||
|
login_and_set_cookie(client)
|
||||||
|
|
||||||
|
seed_child('routine-child-1')
|
||||||
|
client.put('/routine/add', json={'name': 'Evening Routine', 'points': 6, 'image_id': 'moon'})
|
||||||
|
rid = _first_routine_id()
|
||||||
|
|
||||||
|
add_item_resp = client.put(f'/routine/{rid}/item/add', json={'name': 'Brush Teeth', 'order': 0})
|
||||||
|
assert add_item_resp.status_code == 201
|
||||||
|
|
||||||
|
assign_resp = client.post('/child/routine-child-1/assign-routine', json={'routine_id': rid})
|
||||||
|
assert assign_resp.status_code == 200
|
||||||
|
|
||||||
|
list_resp = client.get('/child/routine-child-1/list-routines')
|
||||||
|
assert list_resp.status_code == 200
|
||||||
|
routines = list_resp.get_json()['routines']
|
||||||
|
assert len(routines) == 1
|
||||||
|
assert routines[0]['id'] == rid
|
||||||
|
assert routines[0]['items'][0]['name'] == 'Brush Teeth'
|
||||||
|
|
||||||
|
confirm_resp = client.post('/child/routine-child-1/confirm-routine', json={'routine_id': rid})
|
||||||
|
assert confirm_resp.status_code == 200
|
||||||
|
|
||||||
|
confirmation_id = _first_confirmation_id()
|
||||||
|
approve_resp = client.post(f'/child/routine-child-1/approve-routine/{confirmation_id}')
|
||||||
|
assert approve_resp.status_code == 200
|
||||||
|
|
||||||
|
child = child_db.get(Query().id == 'routine-child-1')
|
||||||
|
assert child['points'] == 6
|
||||||
|
|
||||||
|
|
||||||
|
def test_routine_schedule_and_extend_flow():
|
||||||
|
app = _make_client()
|
||||||
|
with app.test_client() as client:
|
||||||
|
add_test_user()
|
||||||
|
login_and_set_cookie(client)
|
||||||
|
|
||||||
|
seed_child('routine-child-2')
|
||||||
|
client.put('/routine/add', json={'name': 'School Routine', 'points': 5, 'image_id': 'book'})
|
||||||
|
rid = _first_routine_id()
|
||||||
|
|
||||||
|
client.post('/child/routine-child-2/assign-routine', json={'routine_id': rid})
|
||||||
|
|
||||||
|
set_resp = client.put(
|
||||||
|
f'/child/routine-child-2/routine/{rid}/schedule',
|
||||||
|
json={
|
||||||
|
'mode': 'days',
|
||||||
|
'day_configs': [{'day': 1, 'hour': 8, 'minute': 0}],
|
||||||
|
'default_hour': 8,
|
||||||
|
'default_minute': 0,
|
||||||
|
'default_has_deadline': True,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
assert set_resp.status_code == 200
|
||||||
|
|
||||||
|
get_resp = client.get(f'/child/routine-child-2/routine/{rid}/schedule')
|
||||||
|
assert get_resp.status_code == 200
|
||||||
|
assert get_resp.get_json()['mode'] == 'days'
|
||||||
|
|
||||||
|
extend_resp = client.post(
|
||||||
|
f'/child/routine-child-2/routine/{rid}/extend',
|
||||||
|
json={'date': '2026-05-04'},
|
||||||
|
)
|
||||||
|
assert extend_resp.status_code == 200
|
||||||
|
assert extend_resp.get_json()['routine_id'] == rid
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
"""Tests for the state expiry scheduler."""
|
||||||
|
import os
|
||||||
|
import time
|
||||||
|
import pytest
|
||||||
|
from unittest.mock import patch, MagicMock
|
||||||
|
from flask import Flask
|
||||||
|
from tinydb import Query
|
||||||
|
|
||||||
|
from utils.state_expiry_scheduler import (
|
||||||
|
_get_today_start_timestamp,
|
||||||
|
expire_stale_pending_for_user,
|
||||||
|
run_state_expiry_check,
|
||||||
|
)
|
||||||
|
from db.db import users_db, pending_confirmations_db
|
||||||
|
from tests.conftest import TEST_SECRET_KEY
|
||||||
|
|
||||||
|
EXPIRY_USER_ID = "expiry_test_user"
|
||||||
|
EXPIRY_USER_ID_2 = "expiry_test_user_2"
|
||||||
|
EXPIRY_CHILD_ID = "expiry_child_id"
|
||||||
|
EXPIRY_CHILD_ID_2 = "expiry_child_id_2"
|
||||||
|
EXPIRY_TASK_ID = "expiry_task_id"
|
||||||
|
EXPIRY_REWARD_ID = "expiry_reward_id"
|
||||||
|
|
||||||
|
|
||||||
|
def _yesterday_timestamp() -> float:
|
||||||
|
"""Return a Unix timestamp from 25 hours ago (safely before today's midnight)."""
|
||||||
|
return time.time() - (25 * 3600)
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_user(user_id: str = EXPIRY_USER_ID, timezone: str = "UTC", verified: bool = True):
|
||||||
|
users_db.remove(Query().id == user_id)
|
||||||
|
users_db.insert({
|
||||||
|
"id": user_id,
|
||||||
|
"first_name": "Expiry",
|
||||||
|
"last_name": "Tester",
|
||||||
|
"email": f"{user_id}@example.com",
|
||||||
|
"password": "hashed",
|
||||||
|
"verified": verified,
|
||||||
|
"role": "user",
|
||||||
|
"image_id": None,
|
||||||
|
"marked_for_deletion": False,
|
||||||
|
"timezone": timezone,
|
||||||
|
"email_digest_enabled": False,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_pending(
|
||||||
|
pending_id: str,
|
||||||
|
user_id: str = EXPIRY_USER_ID,
|
||||||
|
child_id: str = EXPIRY_CHILD_ID,
|
||||||
|
entity_id: str = EXPIRY_TASK_ID,
|
||||||
|
entity_type: str = "chore",
|
||||||
|
status: str = "pending",
|
||||||
|
created_at: float = None,
|
||||||
|
):
|
||||||
|
pending_confirmations_db.remove(Query().id == pending_id)
|
||||||
|
pending_confirmations_db.insert({
|
||||||
|
"id": pending_id,
|
||||||
|
"user_id": user_id,
|
||||||
|
"child_id": child_id,
|
||||||
|
"entity_id": entity_id,
|
||||||
|
"entity_type": entity_type,
|
||||||
|
"status": status,
|
||||||
|
"approved_at": None,
|
||||||
|
"created_at": created_at if created_at is not None else _yesterday_timestamp(),
|
||||||
|
"updated_at": time.time(),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _cleanup():
|
||||||
|
for uid in (EXPIRY_USER_ID, EXPIRY_USER_ID_2):
|
||||||
|
users_db.remove(Query().id == uid)
|
||||||
|
pending_confirmations_db.remove(Query().user_id == uid)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def app():
|
||||||
|
flask_app = Flask(__name__)
|
||||||
|
flask_app.config['TESTING'] = True
|
||||||
|
flask_app.config['SECRET_KEY'] = TEST_SECRET_KEY
|
||||||
|
return flask_app
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetTodayStartTimestamp:
|
||||||
|
def test_returns_float(self):
|
||||||
|
ts = _get_today_start_timestamp("UTC")
|
||||||
|
assert isinstance(ts, float)
|
||||||
|
|
||||||
|
def test_today_start_is_before_now(self):
|
||||||
|
ts = _get_today_start_timestamp("UTC")
|
||||||
|
assert ts <= time.time()
|
||||||
|
|
||||||
|
def test_falls_back_to_utc_on_none(self):
|
||||||
|
ts_utc = _get_today_start_timestamp("UTC")
|
||||||
|
ts_none = _get_today_start_timestamp(None)
|
||||||
|
assert abs(ts_utc - ts_none) < 1 # within 1 second
|
||||||
|
|
||||||
|
def test_falls_back_to_utc_on_invalid_timezone(self):
|
||||||
|
ts_utc = _get_today_start_timestamp("UTC")
|
||||||
|
ts_bad = _get_today_start_timestamp("Invalid/Timezone")
|
||||||
|
assert abs(ts_utc - ts_bad) < 1
|
||||||
|
|
||||||
|
def test_timezone_shifts_midnight(self):
|
||||||
|
"""UTC-12 midnight is 12 hours later than UTC midnight in absolute terms."""
|
||||||
|
ts_utc = _get_today_start_timestamp("UTC")
|
||||||
|
ts_west = _get_today_start_timestamp("Etc/GMT+12")
|
||||||
|
# The western timezone's "today start" is at most 12 hours away from UTC's
|
||||||
|
assert abs(ts_utc - ts_west) <= 12 * 3600 + 1
|
||||||
|
|
||||||
|
|
||||||
|
class TestExpireStaleForUser:
|
||||||
|
def setup_method(self):
|
||||||
|
_cleanup()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
_cleanup()
|
||||||
|
|
||||||
|
def test_pending_chore_from_yesterday_is_deleted(self, app):
|
||||||
|
_seed_pending("exp_chore_1", entity_type="chore", created_at=_yesterday_timestamp())
|
||||||
|
with app.app_context():
|
||||||
|
with patch('events.sse.send_event_to_user') as mock_send:
|
||||||
|
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
|
||||||
|
assert count == 1
|
||||||
|
assert pending_confirmations_db.get(Query().id == "exp_chore_1") is None
|
||||||
|
|
||||||
|
def test_pending_chore_fires_reset_sse(self, app):
|
||||||
|
_seed_pending("exp_chore_2", entity_type="chore", created_at=_yesterday_timestamp())
|
||||||
|
with app.app_context():
|
||||||
|
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
|
||||||
|
expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
event = mock_send.call_args[0][1]
|
||||||
|
assert event.type == "child_chore_confirmation"
|
||||||
|
assert event.payload.data['operation'] == "RESET"
|
||||||
|
assert event.payload.data['task_id'] == EXPIRY_TASK_ID
|
||||||
|
|
||||||
|
def test_pending_reward_from_yesterday_is_deleted(self, app):
|
||||||
|
_seed_pending(
|
||||||
|
"exp_reward_1",
|
||||||
|
entity_id=EXPIRY_REWARD_ID,
|
||||||
|
entity_type="reward",
|
||||||
|
created_at=_yesterday_timestamp(),
|
||||||
|
)
|
||||||
|
with app.app_context():
|
||||||
|
with patch('utils.state_expiry_scheduler.send_event_to_user'):
|
||||||
|
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
|
||||||
|
assert count == 1
|
||||||
|
assert pending_confirmations_db.get(Query().id == "exp_reward_1") is None
|
||||||
|
|
||||||
|
def test_pending_reward_fires_cancelled_sse(self, app):
|
||||||
|
_seed_pending(
|
||||||
|
"exp_reward_2",
|
||||||
|
entity_id=EXPIRY_REWARD_ID,
|
||||||
|
entity_type="reward",
|
||||||
|
created_at=_yesterday_timestamp(),
|
||||||
|
)
|
||||||
|
with app.app_context():
|
||||||
|
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
|
||||||
|
expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
|
||||||
|
mock_send.assert_called_once()
|
||||||
|
event = mock_send.call_args[0][1]
|
||||||
|
assert event.type == "child_reward_request"
|
||||||
|
assert event.payload.data['operation'] == "CANCELLED"
|
||||||
|
assert event.payload.data['reward_id'] == EXPIRY_REWARD_ID
|
||||||
|
|
||||||
|
def test_pending_record_from_today_is_not_deleted(self, app):
|
||||||
|
_seed_pending("exp_today_1", created_at=time.time())
|
||||||
|
with app.app_context():
|
||||||
|
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
|
||||||
|
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
|
||||||
|
assert count == 0
|
||||||
|
assert pending_confirmations_db.get(Query().id == "exp_today_1") is not None
|
||||||
|
mock_send.assert_not_called()
|
||||||
|
|
||||||
|
def test_approved_record_from_yesterday_is_not_deleted(self, app):
|
||||||
|
_seed_pending("exp_approved_1", status="approved", created_at=_yesterday_timestamp())
|
||||||
|
with app.app_context():
|
||||||
|
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
|
||||||
|
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
|
||||||
|
assert count == 0
|
||||||
|
assert pending_confirmations_db.get(Query().id == "exp_approved_1") is not None
|
||||||
|
mock_send.assert_not_called()
|
||||||
|
|
||||||
|
def test_no_pending_records_returns_zero(self, app):
|
||||||
|
with app.app_context():
|
||||||
|
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
|
||||||
|
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
|
||||||
|
assert count == 0
|
||||||
|
mock_send.assert_not_called()
|
||||||
|
|
||||||
|
def test_multiple_stale_records_all_expired(self, app):
|
||||||
|
_seed_pending("exp_multi_1", entity_id=EXPIRY_TASK_ID, entity_type="chore",
|
||||||
|
created_at=_yesterday_timestamp())
|
||||||
|
_seed_pending("exp_multi_2", entity_id=EXPIRY_REWARD_ID, entity_type="reward",
|
||||||
|
created_at=_yesterday_timestamp())
|
||||||
|
with app.app_context():
|
||||||
|
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
|
||||||
|
count = expire_stale_pending_for_user(EXPIRY_USER_ID, "UTC")
|
||||||
|
assert count == 2
|
||||||
|
assert mock_send.call_count == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunStateExpiryCheck:
|
||||||
|
def setup_method(self):
|
||||||
|
_cleanup()
|
||||||
|
|
||||||
|
def teardown_method(self):
|
||||||
|
_cleanup()
|
||||||
|
|
||||||
|
def test_expires_stale_records_for_all_verified_users(self, app):
|
||||||
|
_seed_user(EXPIRY_USER_ID)
|
||||||
|
_seed_user(EXPIRY_USER_ID_2)
|
||||||
|
_seed_pending("exp_u1", user_id=EXPIRY_USER_ID, created_at=_yesterday_timestamp())
|
||||||
|
_seed_pending("exp_u2", user_id=EXPIRY_USER_ID_2, child_id=EXPIRY_CHILD_ID_2,
|
||||||
|
created_at=_yesterday_timestamp())
|
||||||
|
with patch('utils.state_expiry_scheduler.send_event_to_user'):
|
||||||
|
run_state_expiry_check(app)
|
||||||
|
assert pending_confirmations_db.get(Query().id == "exp_u1") is None
|
||||||
|
assert pending_confirmations_db.get(Query().id == "exp_u2") is None
|
||||||
|
|
||||||
|
def test_only_expires_stale_records_not_todays(self, app):
|
||||||
|
_seed_user(EXPIRY_USER_ID)
|
||||||
|
_seed_pending("exp_stale", created_at=_yesterday_timestamp())
|
||||||
|
_seed_pending("exp_fresh", created_at=time.time())
|
||||||
|
with patch('utils.state_expiry_scheduler.send_event_to_user'):
|
||||||
|
run_state_expiry_check(app)
|
||||||
|
assert pending_confirmations_db.get(Query().id == "exp_stale") is None
|
||||||
|
assert pending_confirmations_db.get(Query().id == "exp_fresh") is not None
|
||||||
|
|
||||||
|
def test_skips_when_db_env_is_e2e(self, app):
|
||||||
|
_seed_user(EXPIRY_USER_ID)
|
||||||
|
_seed_pending("exp_e2e", created_at=_yesterday_timestamp())
|
||||||
|
original = os.environ.get('DB_ENV')
|
||||||
|
try:
|
||||||
|
os.environ['DB_ENV'] = 'e2e'
|
||||||
|
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
|
||||||
|
run_state_expiry_check(app)
|
||||||
|
mock_send.assert_not_called()
|
||||||
|
assert pending_confirmations_db.get(Query().id == "exp_e2e") is not None
|
||||||
|
finally:
|
||||||
|
if original is not None:
|
||||||
|
os.environ['DB_ENV'] = original
|
||||||
|
else:
|
||||||
|
os.environ.pop('DB_ENV', None)
|
||||||
|
|
||||||
|
def test_no_error_when_user_has_no_pending_records(self, app):
|
||||||
|
_seed_user(EXPIRY_USER_ID)
|
||||||
|
with patch('utils.state_expiry_scheduler.send_event_to_user') as mock_send:
|
||||||
|
run_state_expiry_check(app)
|
||||||
|
mock_send.assert_not_called()
|
||||||
@@ -227,3 +227,36 @@ def test_update_profile_success(authenticated_client):
|
|||||||
assert user['first_name'] == 'Updated'
|
assert user['first_name'] == 'Updated'
|
||||||
assert user['last_name'] == 'Name'
|
assert user['last_name'] == 'Name'
|
||||||
assert user['image_id'] == 'new_image'
|
assert user['image_id'] == 'new_image'
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_profile_includes_email_digest_enabled(authenticated_client):
|
||||||
|
"""GET /user/profile response includes email_digest_enabled field."""
|
||||||
|
response = authenticated_client.get('/user/profile')
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.get_json()
|
||||||
|
assert 'email_digest_enabled' in data
|
||||||
|
assert isinstance(data['email_digest_enabled'], bool)
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_profile_disables_digest(authenticated_client):
|
||||||
|
"""PUT /user/profile with email_digest_enabled: false disables the digest."""
|
||||||
|
# Ensure it starts enabled
|
||||||
|
users_db.update({'email_digest_enabled': True}, Query().email == TEST_EMAIL)
|
||||||
|
|
||||||
|
response = authenticated_client.put('/user/profile', json={'email_digest_enabled': False})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
user = users_db.search(Query().email == TEST_EMAIL)[0]
|
||||||
|
assert user['email_digest_enabled'] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_update_profile_enables_digest(authenticated_client):
|
||||||
|
"""PUT /user/profile with email_digest_enabled: true re-enables the digest."""
|
||||||
|
# Start disabled
|
||||||
|
users_db.update({'email_digest_enabled': False}, Query().email == TEST_EMAIL)
|
||||||
|
|
||||||
|
response = authenticated_client.put('/user/profile', json={'email_digest_enabled': True})
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
user = users_db.search(Query().email == TEST_EMAIL)[0]
|
||||||
|
assert user['email_digest_enabled'] is True
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user