Compare commits

..

23 Commits

Author SHA1 Message Date
Barcode Betty 8f1ae26ce3 Merge origin/dev into uat: CAR-1077 (PostgreSQL connection pool fix)
CI / lint (pull_request) Successful in 4s
CI / typecheck (pull_request) Failing after 26s
CI / test (pull_request) Failing after 1m4s
CI / build-and-push (pull_request) Has been skipped
Conflicts in tests/conftest.py and tests/test_encrypted_json.py were
resolved in favor of origin/dev per CAR-1152. Dev is the source of
truth for this promotion: dev's version of the SQLite test fixtures
is a strict superset of uat's (adds hasattr guard for non-TextClause
server_default, strips additional PostgreSQL defaults, registers a
before_insert event listener for timestamp columns). No uat-only logic
was lost.

Production files (src/cartsnitch_api/database.py,
src/cartsnitch_api/routes/health.py) are unchanged from origin/dev.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-02 15:17:42 +00:00
Savannah Savings 7a7aaca064 Fix PostgreSQL connection pool issues (CAR-1077) (#39)
CI / lint (push) Successful in 5s
CI / typecheck (push) Successful in 28s
CI / lint (pull_request) Successful in 6s
CI / test (push) Failing after 1m0s
CI / build-and-push (push) Has been skipped
CI / typecheck (pull_request) Successful in 29s
CI / test (pull_request) Failing after 1m2s
CI / build-and-push (pull_request) Has been skipped
QA approved by Checkout Charlie; CTO Dev review approved by Savannah Savings. Adds pool_timeout=30 and DB-connectivity /health probe. Strict CI improvement (lint+typecheck green); remaining test failure pre-existing on dev, tracked under CAR-1132/PR#42.
2026-06-02 15:10:01 +00:00
Barcode Betty 76781ed238 style: fix ruff format in conftest.py
CI / lint (pull_request) Successful in 5s
CI / typecheck (pull_request) Successful in 29s
CI / test (pull_request) Failing after 1m0s
CI / build-and-push (pull_request) Has been skipped
Add missing blank line between the _set_timestamp_defaults helper
and the next top-level constant so `ruff format --check .` passes.
Pre-existing on dev's HEAD; surfaced after rebasing PR #39 onto dev
in 2b20946.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-02 14:58:18 +00:00
Barcode Betty 2b20946ad7 fix: /health returns 503 on DB failure, pool_timeout=30, CI typecheck fixes
CI / lint (pull_request) Failing after 4s
CI / typecheck (pull_request) Failing after 25s
CI / test (pull_request) Failing after 1m5s
CI / build-and-push (pull_request) Has been skipped
QA review of PR #39 (CAR-1121) identified three blocking issues; this
commit addresses all three plus the typecheck errors flagged as CI RED.

CAR-1077 (PR #39) changes:
- database.py: add pool_timeout=30 so the engine fails fast when the
  connection pool is exhausted (defends against the "server closed
  connection unexpectedly" pod failures).
- routes/health.py: /health now calls SELECT 1 through Depends(get_db)
  and raises HTTPException(503) when the database is unreachable, so
  Kubernetes readiness probes can correctly mark the pod unhealthy and
  stop routing traffic to it.  Logs the failure at exception level for
  observability.
- Drop .mcp.json from this PR (root-level MCP server config, not
  related to the pool fix; tracked separately).

CI typecheck fixes (pre-existing on dev, were failing mypy on PR #39):
- auth/passwords.py: cast bcrypt return values so mypy doesn't widen
  to Any.
- config.py: silence the false-positive call-arg on Settings() — the
  three required fields are populated from the environment by
  pydantic-settings at runtime.
- cache.py: coerce the bytes/str union returned by the redis client
  to the documented str | None return type.
- middleware/rate_limit.py: annotate the three module-level limiters
  with the RateLimitBackend protocol, cast the redis zrange score to
  float before arithmetic, and add max_requests/window_seconds to the
  protocol so the response-header builder can read them.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-02 14:53:16 +00:00
Flea Flicker bd6b137c68 Fix SQLite timestamp and UUID server_defaults in test fixtures
CI / lint (push) Failing after 5s
CI / typecheck (push) Failing after 32s
CI / test (push) Failing after 1m7s
CI / build-and-push (push) Has been skipped
Add _set_timestamp_defaults event listener to populate created_at/updated_at
before insert when using SQLite, since func.now() server_default is stripped.

Extended server_default stripping to include "now()" expressions for
timestamp columns (created_at, updated_at) that were failing with
NOT NULL constraint errors.

Fixes remaining CI test failures after PR #35:
- NOT NULL constraint failed: stores.created_at
- NOT NULL constraint failed: normalized_products.created_at

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-06-02 02:53:46 +00:00
Flea Flicker f18df8a40c fix: rename loop variable to avoid shadowing SQLAlchemy table import (F402) 2026-06-01 12:38:46 +00:00
Barcode Betty ebf69976d4 Fix SQLite server_default AttributeError and pool_size errors (#35)
CI / lint (push) Failing after 6s
CI / typecheck (push) Failing after 30s
CI / test (push) Failing after 1m34s
CI / build-and-push (push) Has been skipped
Fix SQLite server_default AttributeError and pool_size errors

Co-authored-by: Barcode Betty <32+cs_betty@noreply.git.farh.net>
Co-committed-by: Barcode Betty <32+cs_betty@noreply.git.farh.net>
2026-06-01 12:38:21 +00:00
Savannah Savings 7c5ee9bdc0 Promote dev → uat: remove invalid CI deploy jobs (CAR-1069) (#38)
CI / lint (push) Failing after 3s
CI / test (push) Failing after 17s
CI / build-and-push (push) Has been skipped
CI / typecheck (push) Failing after 29s
2026-05-27 01:57:23 +00:00
Barcode Betty 84c143c4e7 Remove deploy-dev/deploy-uat CI jobs (CAR-1069) (#37)
CI / lint (push) Failing after 3s
CI / typecheck (push) Failing after 19s
CI / lint (pull_request) Failing after 4s
CI / test (push) Failing after 30s
CI / build-and-push (push) Has been skipped
CI / typecheck (pull_request) Failing after 18s
CI / test (pull_request) Failing after 29s
CI / build-and-push (pull_request) Has been skipped
Co-authored-by: Barcode Betty <32+cs_betty@noreply.git.farh.net>
Co-committed-by: Barcode Betty <32+cs_betty@noreply.git.farh.net>
2026-05-27 01:56:53 +00:00
Savannah Savings 1c42e4b0af Merge pull request 'Fix: strip PostgreSQL server_defaults from SQLite test fixtures' (#32) from betty/fix-email-inbound-token-tests into dev
CI / lint (push) Failing after 7s
CI / typecheck (push) Failing after 17s
CI / test (push) Failing after 18s
CI / build-and-push (push) Has been skipped
CI / deploy-uat (push) Has been skipped
CI / deploy-dev (push) Failing after 25s
Merge PR #32: Fix SQLite server_default stripping for test fixtures
2026-05-23 23:46:59 +00:00
Savannah Savings 0c0cc63d59 Merge pull request 'Promote dev → uat: test fixes (CAR-1006)' (#33) from dev into uat
CI / lint (push) Successful in 5s
CI / deploy-dev (push) Has been skipped
CI / typecheck (push) Failing after 17s
CI / test (push) Failing after 1m19s
CI / build-and-push (push) Has been skipped
CI / deploy-uat (push) Failing after 26s
CI / lint (pull_request) Successful in 4s
CI / typecheck (pull_request) Failing after 20s
CI / build-and-push (pull_request) Has been skipped
CI / test (pull_request) Failing after 49s
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
2026-05-23 23:43:08 +00:00
Savannah Savings 280882f515 Merge pull request 'Fix test failures: email_inbound_token server_default for SQLite' (#29) from betty/fix-email-inbound-token-tests into dev
CI / test (push) Failing after 1m19s
CI / build-and-push (push) Has been skipped
CI / lint (push) Successful in 5s
CI / typecheck (push) Failing after 29s
CI / deploy-uat (push) Has been skipped
CI / deploy-dev (push) Failing after 28s
CI / lint (pull_request) Successful in 6s
CI / typecheck (pull_request) Failing after 31s
CI / test (pull_request) Failing after 43s
CI / build-and-push (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
Fix test failures: email_inbound_token server_default for SQLite (#29)

Strip PostgreSQL-only server_default from email_inbound_token before SQLite create_all(). Add email_inbound_token to test user INSERT statements.

Reviewed-by: Savannah Savings (CTO)
Approved-by: Checkout Charlie (QA)
2026-05-23 23:25:03 +00:00
Savannah Savings 21443a266a Merge pull request 'Promote dev → uat: ruff lint fixes (CAR-1004)' (#31) from dev into uat
CI / lint (push) Successful in 6s
CI / typecheck (push) Failing after 30s
CI / test (push) Failing after 1m34s
CI / build-and-push (push) Has been skipped
CI / deploy-dev (push) Has been skipped
CI / deploy-uat (push) Failing after 26s
Promote dev → uat: ruff lint fixes (CAR-1004)
2026-05-23 23:12:10 +00:00
Savannah Savings ec4eaa1f03 Merge pull request 'Fix ruff lint errors across codebase' (#30) from barcode-betty/car-1004-fix-ruff-lint into dev
CI / build-and-push (push) Has been skipped
CI / deploy-uat (push) Has been skipped
CI / build-and-push (pull_request) Has been skipped
CI / test (push) Failing after 1s
CI / deploy-dev (push) Failing after 1s
CI / typecheck (push) Failing after 0s
CI / typecheck (pull_request) Failing after 17s
CI / lint (push) Failing after 1s
CI / lint (pull_request) Failing after 3s
CI / test (pull_request) Failing after 49s
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
Merge PR #30: Fix ruff lint errors across codebase

Fixes 56 ruff lint errors (E501, F401, I001) in cartsnitch/api.
QA: cs_charlie APPROVED
CTO: cs_savannah APPROVED
2026-05-23 23:11:54 +00:00
Barcode Betty c68838acf2 Fix ruff lint errors across codebase
CI / lint (pull_request) Successful in 5s
CI / typecheck (pull_request) Failing after 29s
CI / test (pull_request) Failing after 48s
CI / build-and-push (pull_request) Has been skipped
CI / deploy-dev (pull_request) Has been skipped
CI / deploy-uat (pull_request) Has been skipped
- Auto-fix F401 (unused imports) and I001 (unsorted imports) with ruff --fix
- Manually fix E501 (line too long) in alembic migrations and src/ models
- Run ruff format to ensure consistent formatting

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-23 22:47:17 +00:00
Savannah Savings 6799b0e7b1 Merge pull request 'promote: dev → uat (CAR-995 CI registry migration)' (#27) from dev into uat
CI / lint (push) Failing after 3s
CI / typecheck (push) Failing after 29s
CI / test (push) Failing after 49s
CI / build-and-push (push) Has been skipped
CI / deploy-dev (push) Has been skipped
CI / deploy-uat (push) Failing after 41s
promote: dev → uat (CAR-995 CI registry migration) (#27)
2026-05-23 22:31:54 +00:00
Savannah Savings 50110a54b7 Merge pull request 'Promote dev → uat: CI pipeline fixes (CAR-1000)' (#24) from dev into uat
CI / lint (push) Failing after 4s
CI / typecheck (push) Failing after 30s
CI / test (push) Failing after 50s
CI / build-and-push (push) Has been skipped
CI / deploy-dev (push) Has been skipped
CI / deploy-uat (push) Failing after 30s
Promote dev → uat: CI pipeline fixes (CAR-1000)

Promotes PR #22 fixes to UAT environment.
2026-05-23 22:14:44 +00:00
Savannah Savings 28ad343759 Merge pull request 'chore: promote dev to uat (dispose_engine fix, CAR-932)' (#20) from dev into uat
CI / lint (push) Failing after 4s
CI / test (push) Failing after 10s
CI / build-and-push (push) Has been skipped
CI / deploy-dev (push) Has been skipped
CI / typecheck (push) Failing after 17s
CI / deploy-uat (push) Failing after 33s
chore: promote dev to uat (dispose_engine fix, CAR-932)
2026-05-23 21:52:24 +00:00
Savannah Savings 06c6dbed5c Merge pull request 'promote: dev → uat (CAR-992 cors_origins fix)' (#15) from dev into uat
CI / lint (push) Failing after 4s
CI / test (push) Failing after 10s
CI / build-and-push (push) Has been skipped
CI / deploy-dev (push) Has been skipped
CI / typecheck (push) Failing after 36s
CI / deploy-uat (push) Failing after 29s
promote: dev → uat (CAR-992 cors_origins fix) (#15)
2026-05-23 20:56:06 +00:00
Savannah Savings 228a83c355 Merge pull request 'promote: dev → uat (CI trigger fix)' (#10) from dev into uat
CI / lint (push) Failing after 4s
CI / test (push) Failing after 0s
CI / build-and-push (push) Has been skipped
CI / deploy-dev (push) Has been skipped
CI / typecheck (push) Failing after 16s
CI / deploy-uat (push) Failing after 42s
promote: dev → uat (CI trigger fix) (#10)

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-05-21 14:39:13 +00:00
Savannah Savings fbfedd4e8f Merge pull request 'chore: promote dev to uat (CAR-898 workflow move)' (#7) from dev into uat
chore: promote dev to uat (CAR-898 workflow move) (#7)
2026-05-21 13:05:23 +00:00
Coupon Carl 6a8db71537 Merge pull request 'ci: promote Gitea Actions conversion to UAT' (#5) from dev into uat 2026-05-21 04:55:13 +00:00
savannah-savings-cto[bot] 556b43b424 Merge pull request #2 from cartsnitch/dev
chore: promote dev to uat
2026-04-19 12:11:48 +00:00
23 changed files with 363 additions and 170 deletions
-87
View File
@@ -175,90 +175,3 @@ jobs:
git tag "v${{ steps.calver.outputs.version }}"
git push origin "v${{ steps.calver.outputs.version }}"
deploy-dev:
runs-on: ubuntu-latest
needs: [build-and-push]
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main')
steps:
- name: Checkout infra repo
uses: actions/checkout@v4
with:
repository: cartsnitch/infra
token: ${{ secrets.GITEA_TOKEN }}
ref: main
path: infra
- name: Install kubectl
uses: azure/setup-kubectl@v4
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
- name: Determine image tag
id: api_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Update api image tag
if: needs.build-and-push.result == 'success'
run: |
cd infra/apps/overlays/dev
kustomize edit set image ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.api_tag.outputs.tag }}
- name: Commit and push to infra
run: |
cd infra
git config user.name "cartsnitch-ci[bot]"
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/dev/kustomization.yaml
git commit -m "ci(dev): update api image"
git pull --rebase origin main
git push origin main
deploy-uat:
runs-on: ubuntu-latest
needs: [build-and-push]
if: always() && !cancelled() && github.event_name == 'push' && (github.ref == 'refs/heads/uat' || github.ref == 'refs/heads/main')
steps:
- name: Checkout infra repo
uses: actions/checkout@v4
with:
repository: cartsnitch/infra
token: ${{ secrets.GITEA_TOKEN }}
ref: main
path: infra
- name: Install kubectl
uses: azure/setup-kubectl@v4
- name: Install kustomize
uses: imranismail/setup-kustomize@v2
- name: Determine image tag
id: api_tag
run: |
if [ "${{ github.ref }}" == "refs/heads/main" ]; then
echo "tag=${{ needs.build-and-push.outputs.calver_tag }}" >> "$GITHUB_OUTPUT"
else
echo "tag=${{ needs.build-and-push.outputs.sha_tag }}" >> "$GITHUB_OUTPUT"
fi
- name: Update api image tag
if: needs.build-and-push.result == 'success'
run: |
cd infra/apps/overlays/uat
kustomize edit set image ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ steps.api_tag.outputs.tag }}
- name: Commit and push to infra
run: |
cd infra
git config user.name "cartsnitch-ci[bot]"
git config user.email "cartsnitch-ci[bot]@users.noreply.github.com"
git add apps/overlays/uat/kustomization.yaml
git commit -m "ci(uat): update api image"
git pull --rebase origin main
git push origin main
+11
View File
@@ -0,0 +1,11 @@
{
"mcpServers": {
"gitea": {
"type": "http",
"url": "https://git-mcp.farh.net/mcp",
"headers": {
"Authorization": "Bearer ${GITEA_TOKEN}"
}
}
}
}
+6 -1
View File
@@ -45,7 +45,11 @@ def run_migrations_online() -> None:
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(connection=connection, target_metadata=target_metadata, version_table_column_width=128)
context.configure(
connection=connection,
target_metadata=target_metadata,
version_table_column_width=128,
)
with context.begin_transaction():
context.run_migrations()
# Create any tables defined in models but not yet created by migrations.
@@ -56,6 +60,7 @@ def run_migrations_online() -> None:
connection.commit()
except Exception as exc:
import logging
logging.getLogger("alembic.env").warning(
"create_all failed (non-fatal, migrations should handle table creation): %s", exc
)
+44 -9
View File
@@ -30,7 +30,10 @@ def upgrade() -> None:
if inspector.has_table("users"):
existing_user_cols = [c["name"] for c in inspector.get_columns("users")]
if "email_verified" not in existing_user_cols:
op.add_column("users", sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"))
op.add_column(
"users",
sa.Column("email_verified", sa.Boolean(), nullable=False, server_default="false"),
)
if "image" not in existing_user_cols:
op.add_column("users", sa.Column("image", sa.Text(), nullable=True))
@@ -44,8 +47,18 @@ def upgrade() -> None:
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("ip_address", sa.Text(), nullable=True),
sa.Column("user_agent", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_sessions_token", "sessions", ["token"], unique=True)
@@ -66,8 +79,18 @@ def upgrade() -> None:
sa.Column("scope", sa.Text(), nullable=True),
sa.Column("id_token", sa.Text(), nullable=True),
sa.Column("password", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
op.create_index("ix_accounts_user_id", "accounts", ["user_id"])
@@ -80,8 +103,18 @@ def upgrade() -> None:
sa.Column("identifier", sa.Text(), nullable=False),
sa.Column("value", sa.Text(), nullable=False),
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.PrimaryKeyConstraint("id"),
)
@@ -96,8 +129,10 @@ def upgrade() -> None:
user_id_str = str(user_id)
conn.execute(
text(
"INSERT INTO accounts (id, user_id, account_id, provider_id, password, created_at, updated_at) "
"VALUES (gen_random_uuid()::text, :user_id, :account_id, 'credential', :password, now(), now())"
"INSERT INTO accounts "
"(id, user_id, account_id, provider_id, password, created_at, updated_at) "
"VALUES (gen_random_uuid()::text, :user_id, :account_id, "
"'credential', :password, now(), now())"
),
{"user_id": user_id_str, "account_id": user_id_str, "password": hashed_password},
)
+12 -2
View File
@@ -40,7 +40,12 @@ def upgrade() -> None:
return # already TEXT — nothing to do
# Step 1: Drop existing FK constraints (ignore if they don't exist)
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
op.execute(
text(
"ALTER TABLE user_store_accounts "
"DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"
)
)
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
# Step 2: Alter users.id from uuid to text
@@ -89,7 +94,12 @@ def upgrade() -> None:
def downgrade() -> None:
# Drop FK constraints
op.execute(text("ALTER TABLE user_store_accounts DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"))
op.execute(
text(
"ALTER TABLE user_store_accounts "
"DROP CONSTRAINT IF EXISTS user_store_accounts_user_id_fkey"
)
)
op.execute(text("ALTER TABLE purchases DROP CONSTRAINT IF EXISTS purchases_user_id_fkey"))
# Revert users.id from text to uuid
@@ -20,7 +20,7 @@ depends_on = None
def upgrade() -> None:
conn = op.get_bind()
inspector = sa.inspect(conn)
# Guard: on a fresh DB Base.metadata.create_all creates users table with the column already present
# Guard: on fresh DB, Base.metadata.create_all already has the column
if not inspector.has_table("users"):
return
existing_cols = [c["name"] for c in inspector.get_columns("users")]
@@ -6,6 +6,7 @@ Create Date: 2026-04-04
"""
import sqlalchemy as sa
from alembic import op
revision = "006_email_inbound_token_server_default"
@@ -29,7 +30,8 @@ def upgrade() -> None:
"users",
"email_inbound_token",
server_default=sa.text(
"replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
"replace(replace(trim(trailing '=' from "
"encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
),
)
+13 -3
View File
@@ -27,7 +27,8 @@ def upgrade() -> None:
if inspector.has_table("users"):
return # Table already exists (non-fresh DB or create_all already ran)
conn.execute(text("""
conn.execute(
text("""
CREATE TABLE users (
id TEXT PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
@@ -36,11 +37,20 @@ def upgrade() -> None:
email_verified BOOLEAN NOT NULL DEFAULT false,
image TEXT,
email_inbound_token VARCHAR(22) NOT NULL UNIQUE
DEFAULT replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_'),
DEFAULT (
replace(
replace(
trim(trailing '=' from encode(gen_random_bytes(16), 'base64')),
'+', '-'
),
'/', '_'
)
),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""))
""")
)
def downgrade() -> None:
+150 -26
View File
@@ -29,8 +29,18 @@ def upgrade() -> None:
sa.Column("slug", sa.String(20), nullable=False, unique=True),
sa.Column("logo_url", sa.String(500), nullable=True),
sa.Column("website_url", sa.String(500), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
# 2. store_locations
@@ -45,8 +55,18 @@ def upgrade() -> None:
sa.Column("zip", sa.String(10), nullable=False),
sa.Column("lat", sa.Float(), nullable=True),
sa.Column("lng", sa.Float(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
# 3. normalized_products
@@ -61,8 +81,18 @@ def upgrade() -> None:
sa.Column("size", sa.String(50), nullable=True),
sa.Column("size_unit", sa.String(10), nullable=True),
sa.Column("upc_variants", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
# 4. purchases
@@ -72,7 +102,9 @@ def upgrade() -> None:
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
sa.Column("user_id", sa.Text(), sa.ForeignKey("users.id"), nullable=False),
sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False),
sa.Column("store_location_id", sa.Uuid(), sa.ForeignKey("store_locations.id"), nullable=True),
sa.Column(
"store_location_id", sa.Uuid(), sa.ForeignKey("store_locations.id"), nullable=True
),
sa.Column("receipt_id", sa.String(200), nullable=False),
sa.Column("purchase_date", sa.Date(), nullable=False),
sa.Column("total", sa.Numeric(10, 2), nullable=False),
@@ -81,9 +113,24 @@ def upgrade() -> None:
sa.Column("savings_total", sa.Numeric(10, 2), nullable=True),
sa.Column("source_url", sa.String(500), nullable=True),
sa.Column("raw_data", sa.JSON(), nullable=True),
sa.Column("ingested_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"ingested_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.UniqueConstraint("user_id", "store_id", "receipt_id", name="uq_purchase_receipt"),
sa.Index("ix_purchases_user_store", "user_id", "store_id"),
)
@@ -104,9 +151,24 @@ def upgrade() -> None:
sa.Column("coupon_discount", sa.Numeric(10, 2), nullable=True),
sa.Column("loyalty_discount", sa.Numeric(10, 2), nullable=True),
sa.Column("category_raw", sa.String(100), nullable=True),
sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"normalized_product_id",
sa.Uuid(),
sa.ForeignKey("normalized_products.id"),
nullable=True,
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
# 6. coupons
@@ -115,7 +177,12 @@ def upgrade() -> None:
"coupons",
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False),
sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=True),
sa.Column(
"normalized_product_id",
sa.Uuid(),
sa.ForeignKey("normalized_products.id"),
nullable=True,
),
sa.Column("title", sa.String(300), nullable=False),
sa.Column("description", sa.String(1000), nullable=True),
sa.Column("discount_type", sa.String(20), nullable=False),
@@ -127,8 +194,18 @@ def upgrade() -> None:
sa.Column("coupon_code", sa.String(100), nullable=True),
sa.Column("source_url", sa.String(500), nullable=True),
sa.Column("scraped_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
# 7. price_history
@@ -136,7 +213,12 @@ def upgrade() -> None:
op.create_table(
"price_history",
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=False),
sa.Column(
"normalized_product_id",
sa.Uuid(),
sa.ForeignKey("normalized_products.id"),
nullable=False,
),
sa.Column("store_id", sa.Uuid(), sa.ForeignKey("stores.id"), nullable=False),
sa.Column("observed_date", sa.Date(), nullable=False),
sa.Column("regular_price", sa.Numeric(10, 2), nullable=False),
@@ -144,10 +226,27 @@ def upgrade() -> None:
sa.Column("loyalty_price", sa.Numeric(10, 2), nullable=True),
sa.Column("coupon_price", sa.Numeric(10, 2), nullable=True),
sa.Column("source", sa.String(20), nullable=False),
sa.Column("purchase_item_id", sa.Uuid(), sa.ForeignKey("purchase_items.id"), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Index("ix_price_history_product_store_date", "normalized_product_id", "store_id", "observed_date"),
sa.Column(
"purchase_item_id", sa.Uuid(), sa.ForeignKey("purchase_items.id"), nullable=True
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Index(
"ix_price_history_product_store_date",
"normalized_product_id",
"store_id",
"observed_date",
),
)
# 8. shrinkflation_events
@@ -155,7 +254,12 @@ def upgrade() -> None:
op.create_table(
"shrinkflation_events",
sa.Column("id", sa.Uuid(), server_default=text("gen_random_uuid()"), primary_key=True),
sa.Column("normalized_product_id", sa.Uuid(), sa.ForeignKey("normalized_products.id"), nullable=False),
sa.Column(
"normalized_product_id",
sa.Uuid(),
sa.ForeignKey("normalized_products.id"),
nullable=False,
),
sa.Column("detected_date", sa.Date(), nullable=False),
sa.Column("old_size", sa.String(50), nullable=False),
sa.Column("new_size", sa.String(50), nullable=False),
@@ -165,8 +269,18 @@ def upgrade() -> None:
sa.Column("price_at_new_size", sa.Numeric(10, 2), nullable=True),
sa.Column("confidence", sa.Numeric(3, 2), server_default=text("1.00"), nullable=False),
sa.Column("notes", sa.String(1000), nullable=True),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
# 9. user_store_accounts
@@ -180,8 +294,18 @@ def upgrade() -> None:
sa.Column("session_expires_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("last_sync_at", sa.DateTime(timezone=True), nullable=True),
sa.Column("status", sa.String(20), server_default=text("'active'"), nullable=False),
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.Column(
"updated_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
sa.UniqueConstraint("user_id", "store_id", name="uq_user_store_account"),
)
@@ -6,6 +6,7 @@ Create Date: 2026-04-14
"""
import sqlalchemy as sa
from alembic import op
revision = "009_add_gin_index_upc_variants"
+2 -1
View File
@@ -5,7 +5,8 @@ Sessions are verified by querying the shared sessions table directly.
"""
from datetime import UTC, datetime
from fastapi import Cookie, Depends, Header, HTTPException, Request, status
from fastapi import Depends, Header, HTTPException, Request, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
+2 -2
View File
@@ -4,8 +4,8 @@ import bcrypt
def hash_password(password: str) -> str:
return bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
return str(bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode())
def verify_password(plain_password: str, hashed_password: str) -> bool:
return bcrypt.checkpw(plain_password.encode(), hashed_password.encode())
return bool(bcrypt.checkpw(plain_password.encode(), hashed_password.encode()))
-3
View File
@@ -6,13 +6,10 @@ endpoints that query our own user data from the shared database.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import get_current_user
from cartsnitch_api.database import get_db
from cartsnitch_api.models import User
from cartsnitch_api.schemas import (
UpdateUserRequest,
UserResponse,
+6 -1
View File
@@ -35,7 +35,12 @@ class CacheClient:
async def get(self, key: str) -> str | None:
if not self._client:
return None
return await self._client.get(key)
value = await self._client.get(key)
if value is None:
return None
if isinstance(value, bytes):
return value.decode("utf-8", errors="replace")
return value
async def set(self, key: str, value: str, ttl_seconds: int = 300) -> None:
if not self._client:
+1 -1
View File
@@ -86,4 +86,4 @@ class Settings(BaseSettings):
return self
settings = Settings()
settings = Settings() # type: ignore[call-arg]
+16 -8
View File
@@ -6,14 +6,22 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn
from cartsnitch_api.config import settings
engine = create_async_engine(
settings.database_url,
echo=False,
pool_size=10,
max_overflow=20,
pool_pre_ping=True,
pool_recycle=3600,
)
def _build_engine_kwargs() -> dict:
url = settings.database_url
kwargs: dict = {"echo": False}
if not url.startswith("sqlite"):
kwargs.update(
pool_size=10,
max_overflow=20,
pool_timeout=30,
pool_pre_ping=True,
pool_recycle=3600,
)
return kwargs
engine = create_async_engine(settings.database_url, **_build_engine_kwargs())
async_session_factory = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
+2 -1
View File
@@ -6,10 +6,10 @@ from fastapi import APIRouter, FastAPI
from cartsnitch_api.auth.routes import router as auth_router
from cartsnitch_api.cache import cache_client
from cartsnitch_api.middleware.audit import add_audit_middleware
from cartsnitch_api.middleware.cors import add_cors_middleware
from cartsnitch_api.middleware.error_handler import add_error_handlers, add_error_monitor_middleware
from cartsnitch_api.middleware.rate_limit import add_rate_limit_middleware
from cartsnitch_api.middleware.audit import add_audit_middleware
from cartsnitch_api.routes.alerts import router as alerts_router
from cartsnitch_api.routes.coupons import router as coupons_router
from cartsnitch_api.routes.health import router as health_router
@@ -26,6 +26,7 @@ from cartsnitch_api.routes.user import router as user_router
@asynccontextmanager
async def lifespan(app: FastAPI):
from cartsnitch_api.database import dispose_engine
await cache_client.initialize()
yield
await cache_client.close()
+9 -1
View File
@@ -25,6 +25,9 @@ logger = logging.getLogger(__name__)
class RateLimitBackend(Protocol):
"""Protocol for rate limit backends."""
max_requests: int
window_seconds: int
async def is_allowed(self, key: str) -> tuple[bool, int, int]:
"""Check if request is allowed. Returns (allowed, remaining, retry_after)."""
@@ -82,7 +85,8 @@ class RedisSlidingWindow:
if current_count >= self.max_requests:
oldest = await self.redis.zrange(key, 0, 0, withscores=True)
if oldest:
retry_after = int((oldest[0][1] - cutoff) / 1000) + 1
oldest_score = float(oldest[0][1])
retry_after = int((oldest_score - cutoff) / 1000) + 1
else:
retry_after = self.window_seconds
return False, 0, retry_after
@@ -114,6 +118,10 @@ if settings.rate_limit_redis_enabled:
logger.warning("Failed to connect to Redis for rate limiting, using in-memory: %s", e)
_use_redis = False
_public_limiter: RateLimitBackend
_auth_limiter: RateLimitBackend
_auth_strict_limiter: RateLimitBackend
if _use_redis and _redis_client:
_public_limiter = RedisSlidingWindow(
_redis_client, settings.rate_limit_requests, settings.rate_limit_window_seconds
+3 -4
View File
@@ -26,9 +26,7 @@ class User(TimestampMixin, Base):
email: Mapped[str] = mapped_column(String(255), nullable=False, unique=True)
hashed_password: Mapped[str | None] = mapped_column(String(255), nullable=True)
display_name: Mapped[str | None] = mapped_column(String(100))
email_verified: Mapped[bool] = mapped_column(
Boolean, nullable=False, server_default="false"
)
email_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false")
image: Mapped[str | None] = mapped_column(Text, nullable=True)
email_inbound_token: Mapped[str] = mapped_column(
String(22),
@@ -36,7 +34,8 @@ class User(TimestampMixin, Base):
unique=True,
default=lambda: secrets.token_urlsafe(16),
server_default=sa.text(
"replace(replace(trim(trailing '=' from encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
"replace(replace(trim(trailing '=' from "
"encode(gen_random_bytes(16), 'base64')), '+', '-'), '/', '_')"
),
)
+27 -3
View File
@@ -1,16 +1,40 @@
"""Health check and error metrics endpoints."""
from fastapi import APIRouter, Depends
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from cartsnitch_api.auth.dependencies import verify_service_key
from cartsnitch_api.database import get_db
from cartsnitch_api.middleware.error_handler import get_error_monitor
logger = logging.getLogger(__name__)
router = APIRouter(tags=["health"])
@router.get("/health")
async def health():
return {"status": "ok"}
async def health(db: AsyncSession = Depends(get_db)):
"""Liveness + DB connectivity probe.
Returns HTTP 200 when the API process is responsive *and* the database
is reachable, so Kubernetes readiness probes can correctly route traffic
away from pods that have lost their database connection.
Returns HTTP 503 when the database is unreachable so K8s marks the pod
unhealthy and stops sending traffic to it.
"""
try:
await db.execute(text("SELECT 1"))
except Exception as exc:
logger.exception("Health check failed: database unreachable")
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={"status": "unavailable", "database": "disconnected"},
) from exc
return {"status": "ok", "database": "connected"}
@router.get("/internal/error-stats", dependencies=[Depends(verify_service_key)])
+37 -10
View File
@@ -19,6 +19,15 @@ from cartsnitch_api.database import get_db
from cartsnitch_api.main import create_app
from cartsnitch_api.models import Base
def _set_timestamp_defaults(mapper, connection, target):
"""Populate created_at/updated_at before insert for SQLite compatibility."""
now = datetime.now(UTC)
for col in [c for c in mapper.columns if c.key in ("created_at", "updated_at")]:
if getattr(target, col.key, None) is None:
setattr(target, col.key, now)
TEST_JWT_SECRET = secrets.token_urlsafe(32)
TEST_SERVICE_KEY = secrets.token_urlsafe(32)
TEST_FERNET_KEY = "7reF42nmTwbdN21PBoubGp7h_FU8qSimstmlaMLoRK8="
@@ -53,18 +62,27 @@ def disable_rate_limiting():
def engine():
"""Sync in-memory SQLite engine for model unit tests.
Strips PostgreSQL-specific server_default expressions so SQLite can
handle all column inserts without missing-function errors.
Strips PostgreSQL-specific server_default expressions and provides
Python-side defaults for SQLite compatibility.
"""
eng = create_engine("sqlite:///:memory:")
for table in Base.metadata.tables.values():
for col in table.columns.values():
for tbl in Base.metadata.tables.values():
for col in tbl.columns.values():
sd = col.server_default
if sd is not None:
expr_str = str(sd.expression).lower()
if "gen_random_uuid" in expr_str or "gen_random_bytes" in expr_str:
if not hasattr(sd, "expression"):
col.server_default = None
continue
expr_str = str(sd.expression).lower()
# Strip PostgreSQL-specific defaults
if any(x in expr_str for x in ["gen_random_uuid", "gen_random_bytes", "now()"]):
col.server_default = None
# Register event listener to populate timestamps on insert
for cls in Base.registry._class_registry.values():
if hasattr(cls, "__mapper__"):
event.listen(cls, "before_insert", _set_timestamp_defaults)
Base.metadata.create_all(eng)
yield eng
@@ -89,13 +107,22 @@ async def db_engine():
cursor.execute("PRAGMA foreign_keys=ON")
cursor.close()
for table in Base.metadata.tables.values():
for col in table.columns.values():
for tbl in Base.metadata.tables.values():
for col in tbl.columns.values():
sd = col.server_default
if sd is not None:
expr_str = str(sd.expression).lower()
if "gen_random_uuid" in expr_str or "gen_random_bytes" in expr_str:
if not hasattr(sd, "expression"):
col.server_default = None
continue
expr_str = str(sd.expression).lower()
# Strip PostgreSQL-specific defaults
if any(x in expr_str for x in ["gen_random_uuid", "gen_random_bytes", "now()"]):
col.server_default = None
# Register event listener to populate timestamps on insert
for cls in Base.registry._class_registry.values():
if hasattr(cls, "__mapper__"):
event.listen(cls, "before_insert", _set_timestamp_defaults)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
+12 -3
View File
@@ -28,7 +28,10 @@ def test_database_url_normalizes_plain_postgresql_prefix():
"DATABASE_URL": "postgresql://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
}
settings = Settings(**env)
assert settings.database_url == "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
assert (
settings.database_url
== "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
)
def test_database_url_preserves_asyncpg_prefix():
@@ -37,10 +40,16 @@ def test_database_url_preserves_asyncpg_prefix():
"CARTSNITCH_DATABASE_URL": "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch",
}
settings = Settings(**env)
assert settings.database_url == "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
assert (
settings.database_url
== "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
)
def test_database_url_default():
"""When neither env var is set, the hardcoded default is used."""
settings = Settings()
assert settings.database_url == "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
assert (
settings.database_url
== "postgresql+asyncpg://cartsnitch:cartsnitch@localhost:5432/cartsnitch"
)
+5 -2
View File
@@ -18,10 +18,13 @@ from cartsnitch_api.models.user import User, UserStoreAccount
def engine():
eng = create_engine("sqlite:///:memory:")
for table in Base.metadata.tables.values():
for col in table.columns.values():
for tbl in Base.metadata.tables.values():
for col in tbl.columns.values():
sd = col.server_default
if sd is not None:
if not hasattr(sd, "expression"):
col.server_default = None
continue
expr_str = str(sd.expression).lower()
if "gen_random_uuid" in expr_str or "gen_random_bytes" in expr_str:
col.server_default = None