@cachly-dev/mcp-server 0.10.55 → 0.10.57
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/eas-build-android.yml +58 -0
- package/.github/workflows/eas-update.yml +55 -0
- package/.github/workflows/publish.yml +2 -2
- package/BENCH.md +91 -0
- package/CHANGELOG.md +44 -0
- package/README.md +6 -34
- package/STRATEGY.md +228 -0
- package/apps/travel-chaos-organizer/.env.example +49 -0
- package/apps/travel-chaos-organizer/DEPLOY.md +328 -0
- package/apps/travel-chaos-organizer/Makefile +57 -0
- package/apps/travel-chaos-organizer/README.md +159 -0
- package/apps/travel-chaos-organizer/backend/.env.example +10 -0
- package/apps/travel-chaos-organizer/backend/Dockerfile +25 -0
- package/apps/travel-chaos-organizer/backend/alembic/env.py +56 -0
- package/apps/travel-chaos-organizer/backend/alembic/script.py.mako +24 -0
- package/apps/travel-chaos-organizer/backend/alembic/versions/.gitkeep +0 -0
- package/apps/travel-chaos-organizer/backend/alembic/versions/0001_stripe_customer_id.py +24 -0
- package/apps/travel-chaos-organizer/backend/alembic/versions/0002_telegram_chat_id.py +24 -0
- package/apps/travel-chaos-organizer/backend/alembic.ini +39 -0
- package/apps/travel-chaos-organizer/backend/app/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/auth/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/auth/keycloak.py +81 -0
- package/apps/travel-chaos-organizer/backend/app/config.py +66 -0
- package/apps/travel-chaos-organizer/backend/app/db/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/db/database.py +20 -0
- package/apps/travel-chaos-organizer/backend/app/db/migrations/001_init.sql +71 -0
- package/apps/travel-chaos-organizer/backend/app/db/migrations/002_telemetry_freemium.sql +31 -0
- package/apps/travel-chaos-organizer/backend/app/db/migrations/003_telegram_chat_id.sql +4 -0
- package/apps/travel-chaos-organizer/backend/app/db/migrations/004_stripe_and_email.sql +5 -0
- package/apps/travel-chaos-organizer/backend/app/limiter.py +4 -0
- package/apps/travel-chaos-organizer/backend/app/logging_config.py +25 -0
- package/apps/travel-chaos-organizer/backend/app/main.py +77 -0
- package/apps/travel-chaos-organizer/backend/app/models/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/models/schemas.py +142 -0
- package/apps/travel-chaos-organizer/backend/app/routers/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/routers/admin.py +237 -0
- package/apps/travel-chaos-organizer/backend/app/routers/cache.py +13 -0
- package/apps/travel-chaos-organizer/backend/app/routers/events.py +29 -0
- package/apps/travel-chaos-organizer/backend/app/routers/export.py +122 -0
- package/apps/travel-chaos-organizer/backend/app/routers/inbox.py +122 -0
- package/apps/travel-chaos-organizer/backend/app/routers/items.py +130 -0
- package/apps/travel-chaos-organizer/backend/app/routers/mail.py +108 -0
- package/apps/travel-chaos-organizer/backend/app/routers/parse.py +303 -0
- package/apps/travel-chaos-organizer/backend/app/routers/payments.py +179 -0
- package/apps/travel-chaos-organizer/backend/app/routers/trips.py +134 -0
- package/apps/travel-chaos-organizer/backend/app/routers/users.py +169 -0
- package/apps/travel-chaos-organizer/backend/app/routers/waitlist.py +37 -0
- package/apps/travel-chaos-organizer/backend/app/services/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/app/services/cache.py +131 -0
- package/apps/travel-chaos-organizer/backend/app/services/drip.py +128 -0
- package/apps/travel-chaos-organizer/backend/app/services/email.py +61 -0
- package/apps/travel-chaos-organizer/backend/app/services/errors.py +44 -0
- package/apps/travel-chaos-organizer/backend/app/services/notifier.py +10 -0
- package/apps/travel-chaos-organizer/backend/app/services/ollama.py +101 -0
- package/apps/travel-chaos-organizer/backend/app/services/parser.py +22 -0
- package/apps/travel-chaos-organizer/backend/app/services/quota.py +85 -0
- package/apps/travel-chaos-organizer/backend/app/services/sentry.py +11 -0
- package/apps/travel-chaos-organizer/backend/app/services/telemetry.py +35 -0
- package/apps/travel-chaos-organizer/backend/pytest.ini +4 -0
- package/apps/travel-chaos-organizer/backend/requirements-dev.txt +6 -0
- package/apps/travel-chaos-organizer/backend/requirements.txt +25 -0
- package/apps/travel-chaos-organizer/backend/tests/__init__.py +0 -0
- package/apps/travel-chaos-organizer/backend/tests/conftest.py +223 -0
- package/apps/travel-chaos-organizer/backend/tests/test_admin.py +99 -0
- package/apps/travel-chaos-organizer/backend/tests/test_cache.py +142 -0
- package/apps/travel-chaos-organizer/backend/tests/test_drip.py +32 -0
- package/apps/travel-chaos-organizer/backend/tests/test_e2e_chain.py +121 -0
- package/apps/travel-chaos-organizer/backend/tests/test_export.py +48 -0
- package/apps/travel-chaos-organizer/backend/tests/test_inbox.py +107 -0
- package/apps/travel-chaos-organizer/backend/tests/test_items.py +138 -0
- package/apps/travel-chaos-organizer/backend/tests/test_mail.py +113 -0
- package/apps/travel-chaos-organizer/backend/tests/test_ollama.py +145 -0
- package/apps/travel-chaos-organizer/backend/tests/test_parse_url.py +98 -0
- package/apps/travel-chaos-organizer/backend/tests/test_parser.py +72 -0
- package/apps/travel-chaos-organizer/backend/tests/test_payments.py +163 -0
- package/apps/travel-chaos-organizer/backend/tests/test_quota.py +46 -0
- package/apps/travel-chaos-organizer/backend/tests/test_search.py +67 -0
- package/apps/travel-chaos-organizer/backend/tests/test_trips.py +123 -0
- package/apps/travel-chaos-organizer/backend/tests/test_users.py +52 -0
- package/apps/travel-chaos-organizer/backups/.gitkeep +0 -0
- package/apps/travel-chaos-organizer/caddy/Caddyfile +27 -0
- package/apps/travel-chaos-organizer/docker-compose.node1.yml +31 -0
- package/apps/travel-chaos-organizer/docker-compose.override.yml +14 -0
- package/apps/travel-chaos-organizer/docker-compose.yml +128 -0
- package/apps/travel-chaos-organizer/frontend/.env.example +13 -0
- package/apps/travel-chaos-organizer/frontend/app/(app)/_layout.tsx +80 -0
- package/apps/travel-chaos-organizer/frontend/app/(app)/inbox.tsx +166 -0
- package/apps/travel-chaos-organizer/frontend/app/(app)/search.tsx +130 -0
- package/apps/travel-chaos-organizer/frontend/app/(app)/settings.tsx +381 -0
- package/apps/travel-chaos-organizer/frontend/app/(app)/trips/[id].tsx +346 -0
- package/apps/travel-chaos-organizer/frontend/app/(app)/trips.tsx +363 -0
- package/apps/travel-chaos-organizer/frontend/app/(app)/upgrade/cancel.tsx +34 -0
- package/apps/travel-chaos-organizer/frontend/app/(app)/upgrade/success.tsx +35 -0
- package/apps/travel-chaos-organizer/frontend/app/(auth)/login.tsx +91 -0
- package/apps/travel-chaos-organizer/frontend/app/_layout.tsx +71 -0
- package/apps/travel-chaos-organizer/frontend/app/auth.tsx +29 -0
- package/apps/travel-chaos-organizer/frontend/app/index.tsx +16 -0
- package/apps/travel-chaos-organizer/frontend/app/onboarding.tsx +119 -0
- package/apps/travel-chaos-organizer/frontend/app.config.ts +54 -0
- package/apps/travel-chaos-organizer/frontend/assets/adaptive-icon.svg +66 -0
- package/apps/travel-chaos-organizer/frontend/assets/icon.svg +79 -0
- package/apps/travel-chaos-organizer/frontend/assets/splash.svg +15 -0
- package/apps/travel-chaos-organizer/frontend/components/BottomSheet.tsx +102 -0
- package/apps/travel-chaos-organizer/frontend/components/ConfettiCannon.tsx +107 -0
- package/apps/travel-chaos-organizer/frontend/components/ConfirmDialog.tsx +109 -0
- package/apps/travel-chaos-organizer/frontend/components/ErrorBoundary.tsx +45 -0
- package/apps/travel-chaos-organizer/frontend/components/FileUploadButton.tsx +19 -0
- package/apps/travel-chaos-organizer/frontend/components/ImportSheet.tsx +155 -0
- package/apps/travel-chaos-organizer/frontend/components/OfflineBadge.tsx +55 -0
- package/apps/travel-chaos-organizer/frontend/components/QRCodeViewer.tsx +80 -0
- package/apps/travel-chaos-organizer/frontend/components/ScoreRing.tsx +39 -0
- package/apps/travel-chaos-organizer/frontend/components/Screen.tsx +39 -0
- package/apps/travel-chaos-organizer/frontend/components/Skeleton.tsx +70 -0
- package/apps/travel-chaos-organizer/frontend/components/SwipeableInboxItem.tsx +121 -0
- package/apps/travel-chaos-organizer/frontend/components/SwipeableTimelineItem.tsx +70 -0
- package/apps/travel-chaos-organizer/frontend/components/TimelineItem.tsx +73 -0
- package/apps/travel-chaos-organizer/frontend/components/TimelineItemDetail.tsx +130 -0
- package/apps/travel-chaos-organizer/frontend/components/Toast.tsx +60 -0
- package/apps/travel-chaos-organizer/frontend/components/ToastContext.tsx +42 -0
- package/apps/travel-chaos-organizer/frontend/components/TripCard.tsx +146 -0
- package/apps/travel-chaos-organizer/frontend/components/TripQRModal.tsx +70 -0
- package/apps/travel-chaos-organizer/frontend/components/UpgradeBanner.tsx +44 -0
- package/apps/travel-chaos-organizer/frontend/eas.json +22 -0
- package/apps/travel-chaos-organizer/frontend/hooks/useInbox.ts +24 -0
- package/apps/travel-chaos-organizer/frontend/hooks/useNetworkSync.ts +60 -0
- package/apps/travel-chaos-organizer/frontend/hooks/useTrips.ts +112 -0
- package/apps/travel-chaos-organizer/frontend/lib/api.ts +223 -0
- package/apps/travel-chaos-organizer/frontend/lib/auth.ts +93 -0
- package/apps/travel-chaos-organizer/frontend/lib/db.ts +86 -0
- package/apps/travel-chaos-organizer/frontend/lib/fileCache.ts +54 -0
- package/apps/travel-chaos-organizer/frontend/lib/haptics.ts +13 -0
- package/apps/travel-chaos-organizer/frontend/lib/notifications.ts +112 -0
- package/apps/travel-chaos-organizer/frontend/lib/offlineQueue.ts +145 -0
- package/apps/travel-chaos-organizer/frontend/lib/quota.tsx +43 -0
- package/apps/travel-chaos-organizer/frontend/lib/sentry.ts +28 -0
- package/apps/travel-chaos-organizer/frontend/lib/shareIntent.ts +44 -0
- package/apps/travel-chaos-organizer/frontend/package-lock.json +13221 -0
- package/apps/travel-chaos-organizer/frontend/package.json +44 -0
- package/apps/travel-chaos-organizer/frontend/tsconfig.json +23 -0
- package/apps/travel-chaos-organizer/keycloak/realm-export.json +59 -0
- package/apps/travel-chaos-organizer/landing/index.html +773 -0
- package/apps/travel-chaos-organizer/scripts/backup.sh +14 -0
- package/apps/travel-chaos-organizer/scripts/configure-env.sh +82 -0
- package/apps/travel-chaos-organizer/scripts/deploy.sh +54 -0
- package/apps/travel-chaos-organizer/scripts/health-check.sh +36 -0
- package/apps/travel-chaos-organizer/scripts/migrate.sh +7 -0
- package/apps/travel-chaos-organizer/scripts/server-setup.sh +85 -0
- package/apps/travel-chaos-organizer/scripts/smoke-test.sh +29 -0
- package/apps/travel-chaos-organizer/scripts/verify-tracking.sh +71 -0
- package/apps/travel-chaos-organizer/telegram-bot/.env.example +3 -0
- package/apps/travel-chaos-organizer/telegram-bot/Dockerfile +6 -0
- package/apps/travel-chaos-organizer/telegram-bot/bot.py +504 -0
- package/apps/travel-chaos-organizer/telegram-bot/requirements.txt +3 -0
- package/dist/packages/telegram-notify/client.d.ts +19 -0
- package/dist/packages/telegram-notify/client.js +75 -0
- package/dist/packages/telegram-notify/client.js.map +1 -0
- package/dist/src/__tests__/auth.test.js.map +1 -0
- package/dist/{__tests__ → src/__tests__}/brain-flow.test.js +141 -0
- package/dist/src/__tests__/brain-flow.test.js.map +1 -0
- package/dist/src/__tests__/confidence.test.js.map +1 -0
- package/dist/src/__tests__/e2e/api-auth.test.js.map +1 -0
- package/dist/src/__tests__/e2e/api-health.test.js.map +1 -0
- package/dist/src/__tests__/e2e/cli.test.js.map +1 -0
- package/dist/src/__tests__/e2e/config.js.map +1 -0
- package/dist/src/__tests__/e2e/funnel.test.js.map +1 -0
- package/dist/src/__tests__/e2e/mcp-tools.test.js.map +1 -0
- package/dist/src/__tests__/e2e/provisioning.test.js.map +1 -0
- package/dist/{__tests__ → src/__tests__}/handlers-unit.test.js +0 -2
- package/dist/src/__tests__/handlers-unit.test.js.map +1 -0
- package/dist/src/__tests__/namespace.test.js.map +1 -0
- package/dist/src/__tests__/rerank.test.js +115 -0
- package/dist/src/__tests__/rerank.test.js.map +1 -0
- package/dist/src/auth.js.map +1 -0
- package/dist/src/bench/cachly-bench.d.ts +35 -0
- package/dist/src/bench/cachly-bench.js +182 -0
- package/dist/src/bench/cachly-bench.js.map +1 -0
- package/dist/src/bench/fixtures.d.ts +37 -0
- package/dist/src/bench/fixtures.js +225 -0
- package/dist/src/bench/fixtures.js.map +1 -0
- package/dist/src/ckg.js.map +1 -0
- package/dist/src/confidence.js.map +1 -0
- package/dist/src/embeddings.js.map +1 -0
- package/dist/src/handlers/advanced.js.map +1 -0
- package/dist/{handlers → src/handlers}/brain.js +335 -168
- package/dist/src/handlers/brain.js.map +1 -0
- package/dist/src/handlers/cache.js.map +1 -0
- package/dist/{handlers → src/handlers}/context.js +9 -4
- package/dist/src/handlers/context.js.map +1 -0
- package/dist/{handlers → src/handlers}/fedbrain.d.ts +6 -0
- package/dist/{handlers → src/handlers}/fedbrain.js +19 -7
- package/dist/src/handlers/fedbrain.js.map +1 -0
- package/dist/src/handlers/instances.js.map +1 -0
- package/dist/{handlers → src/handlers}/roadmap.js +7 -3
- package/dist/src/handlers/roadmap.js.map +1 -0
- package/dist/src/handlers/syndicate.js.map +1 -0
- package/dist/src/handlers/tco.d.ts +16 -0
- package/dist/src/handlers/tco.js +303 -0
- package/dist/src/handlers/tco.js.map +1 -0
- package/dist/{handlers → src/handlers}/team.js +57 -7
- package/dist/src/handlers/team.js.map +1 -0
- package/dist/{index.js → src/index.js} +209 -420
- package/dist/src/index.js.map +1 -0
- package/dist/src/namespace.js.map +1 -0
- package/dist/src/notifier.d.ts +5 -0
- package/dist/src/notifier.js +6 -0
- package/dist/src/notifier.js.map +1 -0
- package/dist/src/rerank.d.ts +55 -0
- package/dist/src/rerank.js +111 -0
- package/dist/src/rerank.js.map +1 -0
- package/dist/src/search.js.map +1 -0
- package/dist/src/search.test.d.ts +1 -0
- package/dist/src/search.test.js.map +1 -0
- package/dist/{tools.d.ts → src/tools.d.ts} +226 -0
- package/dist/{tools.js → src/tools.js} +196 -0
- package/dist/src/tools.js.map +1 -0
- package/dist/src/utils.js.map +1 -0
- package/package.json +8 -7
- package/packages/telegram-notify/SPLIT_PLAN.md +106 -0
- package/packages/telegram-notify/client.ts +88 -0
- package/packages/telegram-notify/emojis.json +22 -0
- package/packages/telegram-notify/pyproject.toml +17 -0
- package/packages/telegram-notify/telegram_notify/__init__.py +3 -0
- package/packages/telegram-notify/telegram_notify/client.py +87 -0
- package/packages/telegram-notify/telegram_notify/emojis.json +22 -0
- package/scripts/generate-icons.js +28 -0
- package/server.json +3 -3
- package/smithery.yaml +21 -8
- package/src/__tests__/brain-flow.test.ts +174 -0
- package/src/__tests__/handlers-unit.test.ts +1 -2
- package/src/__tests__/rerank.test.ts +130 -0
- package/src/bench/cachly-bench.ts +217 -0
- package/src/bench/fixtures.ts +256 -0
- package/src/handlers/brain.ts +345 -172
- package/src/handlers/context.ts +9 -4
- package/src/handlers/fedbrain.ts +21 -7
- package/src/handlers/roadmap.ts +7 -3
- package/src/handlers/tco.ts +347 -0
- package/src/handlers/team.ts +67 -7
- package/src/index.ts +224 -406
- package/src/notifier.ts +5 -0
- package/src/rerank.ts +148 -0
- package/src/tools.ts +210 -0
- package/tsconfig.json +2 -3
- package/dist/__tests__/auth.test.js.map +0 -1
- package/dist/__tests__/brain-flow.test.js.map +0 -1
- package/dist/__tests__/brain-predict-failures.test.d.ts +0 -17
- package/dist/__tests__/brain-predict-failures.test.js +0 -339
- package/dist/__tests__/brain-predict-failures.test.js.map +0 -1
- package/dist/__tests__/confidence.test.js.map +0 -1
- package/dist/__tests__/e2e/api-auth.test.js.map +0 -1
- package/dist/__tests__/e2e/api-health.test.js.map +0 -1
- package/dist/__tests__/e2e/cli.test.js.map +0 -1
- package/dist/__tests__/e2e/config.js.map +0 -1
- package/dist/__tests__/e2e/funnel.test.js.map +0 -1
- package/dist/__tests__/e2e/mcp-tools.test.js.map +0 -1
- package/dist/__tests__/e2e/provisioning.test.js.map +0 -1
- package/dist/__tests__/handlers-unit.test.js.map +0 -1
- package/dist/__tests__/namespace.test.js.map +0 -1
- package/dist/auth.js.map +0 -1
- package/dist/ckg.js.map +0 -1
- package/dist/confidence.js.map +0 -1
- package/dist/embeddings.js.map +0 -1
- package/dist/handlers/advanced.js.map +0 -1
- package/dist/handlers/brain.js.map +0 -1
- package/dist/handlers/cache.js.map +0 -1
- package/dist/handlers/context.js.map +0 -1
- package/dist/handlers/fedbrain.js.map +0 -1
- package/dist/handlers/instances.js.map +0 -1
- package/dist/handlers/roadmap.js.map +0 -1
- package/dist/handlers/syndicate.js.map +0 -1
- package/dist/handlers/team.js.map +0 -1
- package/dist/index.js.map +0 -1
- package/dist/namespace.js.map +0 -1
- package/dist/search.js.map +0 -1
- package/dist/search.test.js.map +0 -1
- package/dist/tools.js.map +0 -1
- package/dist/utils.js.map +0 -1
- package/src/__tests__/brain-predict-failures.test.ts +0 -430
- /package/dist/{__tests__ → src/__tests__}/auth.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/auth.test.js +0 -0
- /package/dist/{__tests__ → src/__tests__}/brain-flow.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/confidence.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/confidence.test.js +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/api-auth.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/api-auth.test.js +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/api-health.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/api-health.test.js +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/cli.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/cli.test.js +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/config.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/config.js +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/funnel.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/funnel.test.js +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/mcp-tools.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/mcp-tools.test.js +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/provisioning.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/e2e/provisioning.test.js +0 -0
- /package/dist/{__tests__ → src/__tests__}/handlers-unit.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/namespace.test.d.ts +0 -0
- /package/dist/{__tests__ → src/__tests__}/namespace.test.js +0 -0
- /package/dist/{search.test.d.ts → src/__tests__/rerank.test.d.ts} +0 -0
- /package/dist/{auth.d.ts → src/auth.d.ts} +0 -0
- /package/dist/{auth.js → src/auth.js} +0 -0
- /package/dist/{ckg.d.ts → src/ckg.d.ts} +0 -0
- /package/dist/{ckg.js → src/ckg.js} +0 -0
- /package/dist/{confidence.d.ts → src/confidence.d.ts} +0 -0
- /package/dist/{confidence.js → src/confidence.js} +0 -0
- /package/dist/{embeddings.d.ts → src/embeddings.d.ts} +0 -0
- /package/dist/{embeddings.js → src/embeddings.js} +0 -0
- /package/dist/{handlers → src/handlers}/advanced.d.ts +0 -0
- /package/dist/{handlers → src/handlers}/advanced.js +0 -0
- /package/dist/{handlers → src/handlers}/brain.d.ts +0 -0
- /package/dist/{handlers → src/handlers}/cache.d.ts +0 -0
- /package/dist/{handlers → src/handlers}/cache.js +0 -0
- /package/dist/{handlers → src/handlers}/context.d.ts +0 -0
- /package/dist/{handlers → src/handlers}/instances.d.ts +0 -0
- /package/dist/{handlers → src/handlers}/instances.js +0 -0
- /package/dist/{handlers → src/handlers}/roadmap.d.ts +0 -0
- /package/dist/{handlers → src/handlers}/syndicate.d.ts +0 -0
- /package/dist/{handlers → src/handlers}/syndicate.js +0 -0
- /package/dist/{handlers → src/handlers}/team.d.ts +0 -0
- /package/dist/{index.d.ts → src/index.d.ts} +0 -0
- /package/dist/{namespace.d.ts → src/namespace.d.ts} +0 -0
- /package/dist/{namespace.js → src/namespace.js} +0 -0
- /package/dist/{search.d.ts → src/search.d.ts} +0 -0
- /package/dist/{search.js → src/search.js} +0 -0
- /package/dist/{search.test.js → src/search.test.js} +0 -0
- /package/dist/{utils.d.ts → src/utils.d.ts} +0 -0
- /package/dist/{utils.js → src/utils.js} +0 -0
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for /api/v1/mail/import
|
|
3
|
+
|
|
4
|
+
Covers:
|
|
5
|
+
strip_email_headers – removes From/To/Subject/X-* lines, preserves body
|
|
6
|
+
import_mail – text body → inbox when no trip_id
|
|
7
|
+
import_mail – text body → trip item when trip_id provided
|
|
8
|
+
import_mail – stripped headers don't leak into parsed_data
|
|
9
|
+
|
|
10
|
+
Run: pytest tests/test_mail.py -v
|
|
11
|
+
"""
|
|
12
|
+
import pytest
|
|
13
|
+
from httpx import AsyncClient
|
|
14
|
+
|
|
15
|
+
from app.routers.mail import strip_email_headers
|
|
16
|
+
from tests.conftest import make_trip, mock_ollama_flight
|
|
17
|
+
|
|
18
|
+
RAW_EMAIL = """\
|
|
19
|
+
From: booking@lufthansa.com
|
|
20
|
+
To: max@example.com
|
|
21
|
+
Subject: Ihre Buchungsbestätigung LH 400
|
|
22
|
+
Date: Mon, 12 Aug 2024 09:00:00 +0200
|
|
23
|
+
Content-Type: text/plain; charset=utf-8
|
|
24
|
+
|
|
25
|
+
Sehr geehrter Herr Mustermann,
|
|
26
|
+
|
|
27
|
+
Ihre Buchung wurde bestätigt.
|
|
28
|
+
Flug LH 400 Frankfurt (FRA) → New York (JFK)
|
|
29
|
+
Abflug: 15. August 2024, 10:30 Uhr
|
|
30
|
+
Buchungsreferenz: ABC123
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ── strip_email_headers (pure, synchronous) ───────────────────────────────────
|
|
35
|
+
|
|
36
|
+
def test_strip_removes_from_header():
|
|
37
|
+
result = strip_email_headers(RAW_EMAIL)
|
|
38
|
+
assert "From:" not in result
|
|
39
|
+
assert "To:" not in result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def test_strip_removes_subject():
|
|
43
|
+
result = strip_email_headers(RAW_EMAIL)
|
|
44
|
+
assert "Subject:" not in result
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_strip_preserves_body():
|
|
48
|
+
result = strip_email_headers(RAW_EMAIL)
|
|
49
|
+
assert "LH 400" in result
|
|
50
|
+
assert "ABC123" in result
|
|
51
|
+
assert "Mustermann" in result
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_strip_x_headers():
|
|
55
|
+
raw = "X-Mailer: Outlook\nX-Spam-Status: No\n\nBody content here."
|
|
56
|
+
result = strip_email_headers(raw)
|
|
57
|
+
assert "X-Mailer" not in result
|
|
58
|
+
assert "Body content here" in result
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_strip_collapses_blank_lines():
|
|
62
|
+
raw = "From: a@b.com\n\n\n\n\nBody text."
|
|
63
|
+
result = strip_email_headers(raw)
|
|
64
|
+
assert "\n\n\n" not in result
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ── import_mail endpoint ──────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
@pytest.mark.asyncio
|
|
70
|
+
async def test_import_mail_goes_to_inbox_without_trip(client: AsyncClient, mock_ollama_flight):
|
|
71
|
+
r = await client.post(
|
|
72
|
+
"/api/v1/mail/import",
|
|
73
|
+
content=RAW_EMAIL,
|
|
74
|
+
headers={"Content-Type": "text/plain"},
|
|
75
|
+
)
|
|
76
|
+
assert r.status_code == 200
|
|
77
|
+
body = r.json()
|
|
78
|
+
assert body["parsed"]["type"] == "flight"
|
|
79
|
+
assert body["parsed"]["booking_ref"] == "ABC123"
|
|
80
|
+
|
|
81
|
+
inbox = await client.get("/api/v1/inbox")
|
|
82
|
+
assert inbox.status_code == 200
|
|
83
|
+
assert len(inbox.json()) == 1
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.asyncio
|
|
87
|
+
async def test_import_mail_assigned_to_trip(client: AsyncClient, mock_ollama_flight):
|
|
88
|
+
trip_r = await client.post("/api/v1/trips", json=make_trip())
|
|
89
|
+
trip_id = trip_r.json()["id"]
|
|
90
|
+
|
|
91
|
+
r = await client.post(
|
|
92
|
+
f"/api/v1/mail/import?trip_id={trip_id}",
|
|
93
|
+
content=RAW_EMAIL,
|
|
94
|
+
headers={"Content-Type": "text/plain"},
|
|
95
|
+
)
|
|
96
|
+
assert r.status_code == 200
|
|
97
|
+
|
|
98
|
+
items_r = await client.get(f"/api/v1/trips/{trip_id}/items")
|
|
99
|
+
assert len(items_r.json()) == 1
|
|
100
|
+
|
|
101
|
+
inbox = await client.get("/api/v1/inbox")
|
|
102
|
+
assert inbox.json() == []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@pytest.mark.asyncio
|
|
106
|
+
async def test_import_mail_low_confidence_still_saves(client: AsyncClient, mock_ollama_low_confidence):
|
|
107
|
+
r = await client.post(
|
|
108
|
+
"/api/v1/mail/import",
|
|
109
|
+
content="Some random text that isn't a booking",
|
|
110
|
+
headers={"Content-Type": "text/plain"},
|
|
111
|
+
)
|
|
112
|
+
assert r.status_code == 200
|
|
113
|
+
assert r.json()["parsed"]["confidence"] == 0.1
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for the Ollama service layer.
|
|
3
|
+
|
|
4
|
+
Covers:
|
|
5
|
+
parse_text – valid JSON response, malformed JSON fallback, empty response
|
|
6
|
+
parse_image – base64 encoding, correct payload shape
|
|
7
|
+
_safe_parse – markdown fence stripping, JSON decode errors → fallback dict
|
|
8
|
+
confidence – low-confidence items still return a dict (never raise)
|
|
9
|
+
|
|
10
|
+
Run: pytest tests/test_ollama.py -v
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
from unittest.mock import AsyncMock, MagicMock, patch
|
|
15
|
+
|
|
16
|
+
import pytest
|
|
17
|
+
|
|
18
|
+
from app.services.ollama import _safe_parse, parse_image, parse_text
|
|
19
|
+
|
|
20
|
+
# ── _safe_parse (pure, synchronous) ──────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
def test_safe_parse_valid_json():
|
|
23
|
+
raw = '{"type": "flight", "title": "LH 400", "confidence": 0.9}'
|
|
24
|
+
result = _safe_parse(raw)
|
|
25
|
+
assert result["type"] == "flight"
|
|
26
|
+
assert result["confidence"] == 0.9
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def test_safe_parse_strips_markdown_fences():
|
|
30
|
+
raw = "```json\n{\"type\": \"hotel\", \"title\": \"Marriott\"}\n```"
|
|
31
|
+
result = _safe_parse(raw)
|
|
32
|
+
assert result["type"] == "hotel"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_safe_parse_strips_plain_fences():
|
|
36
|
+
raw = "```\n{\"type\": \"train\"}\n```"
|
|
37
|
+
assert _safe_parse(raw)["type"] == "train"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_safe_parse_malformed_returns_fallback():
|
|
41
|
+
result = _safe_parse("this is not json at all")
|
|
42
|
+
assert result["type"] == "other"
|
|
43
|
+
assert result["confidence"] == 0.0
|
|
44
|
+
assert "Unrecognized" in result["title"]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_safe_parse_empty_string_returns_fallback():
|
|
48
|
+
result = _safe_parse("")
|
|
49
|
+
assert result["type"] == "other"
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_safe_parse_truncates_long_summary():
|
|
53
|
+
long_text = "x" * 1000
|
|
54
|
+
result = _safe_parse(long_text)
|
|
55
|
+
assert len(result.get("raw_summary", "")) <= 300
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ── parse_text ────────────────────────────────────────────────────────────────
|
|
59
|
+
|
|
60
|
+
@pytest.mark.asyncio
|
|
61
|
+
async def test_parse_text_happy_path():
|
|
62
|
+
expected = {"type": "flight", "title": "LH 400 FRA→JFK", "confidence": 0.9}
|
|
63
|
+
mock_response = MagicMock()
|
|
64
|
+
mock_response.json.return_value = {"response": json.dumps(expected)}
|
|
65
|
+
|
|
66
|
+
with patch("httpx.AsyncClient") as mock_cls:
|
|
67
|
+
mock_client = AsyncMock()
|
|
68
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
69
|
+
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
70
|
+
mock_client.post = AsyncMock(return_value=mock_response)
|
|
71
|
+
mock_cls.return_value = mock_client
|
|
72
|
+
|
|
73
|
+
result, was_cached = await parse_text("Flight LH 400 from Frankfurt to New York")
|
|
74
|
+
|
|
75
|
+
assert result["type"] == "flight"
|
|
76
|
+
assert result["confidence"] == 0.9
|
|
77
|
+
assert was_cached is False
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@pytest.mark.asyncio
|
|
81
|
+
async def test_parse_text_ollama_returns_garbage():
|
|
82
|
+
mock_response = MagicMock()
|
|
83
|
+
mock_response.json.return_value = {"response": "I cannot parse this."}
|
|
84
|
+
|
|
85
|
+
with patch("httpx.AsyncClient") as mock_cls:
|
|
86
|
+
mock_client = AsyncMock()
|
|
87
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
88
|
+
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
89
|
+
mock_client.post = AsyncMock(return_value=mock_response)
|
|
90
|
+
mock_cls.return_value = mock_client
|
|
91
|
+
|
|
92
|
+
result, was_cached = await parse_text("some text")
|
|
93
|
+
|
|
94
|
+
assert result["type"] == "other"
|
|
95
|
+
assert result["confidence"] == 0.0
|
|
96
|
+
assert was_cached is False
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
@pytest.mark.asyncio
|
|
100
|
+
async def test_parse_text_ollama_empty_response():
|
|
101
|
+
mock_response = MagicMock()
|
|
102
|
+
mock_response.json.return_value = {}
|
|
103
|
+
|
|
104
|
+
with patch("httpx.AsyncClient") as mock_cls:
|
|
105
|
+
mock_client = AsyncMock()
|
|
106
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
107
|
+
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
108
|
+
mock_client.post = AsyncMock(return_value=mock_response)
|
|
109
|
+
mock_cls.return_value = mock_client
|
|
110
|
+
|
|
111
|
+
result, was_cached = await parse_text("anything")
|
|
112
|
+
|
|
113
|
+
assert isinstance(result, dict)
|
|
114
|
+
assert was_cached is False
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ── parse_image ───────────────────────────────────────────────────────────────
|
|
118
|
+
|
|
119
|
+
@pytest.mark.asyncio
|
|
120
|
+
async def test_parse_image_sends_base64():
|
|
121
|
+
payload_sent = {}
|
|
122
|
+
expected = {"type": "hotel", "title": "Marriott Berlin", "confidence": 0.85}
|
|
123
|
+
|
|
124
|
+
async def fake_post(url, json=None, **kwargs):
|
|
125
|
+
payload_sent.update(json or {})
|
|
126
|
+
mock_resp = MagicMock()
|
|
127
|
+
mock_resp.json.return_value = {"response": json_str(expected)}
|
|
128
|
+
return mock_resp
|
|
129
|
+
|
|
130
|
+
def json_str(d): return __import__("json").dumps(d)
|
|
131
|
+
|
|
132
|
+
with patch("httpx.AsyncClient") as mock_cls:
|
|
133
|
+
mock_client = AsyncMock()
|
|
134
|
+
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
|
135
|
+
mock_client.__aexit__ = AsyncMock(return_value=False)
|
|
136
|
+
mock_client.post = AsyncMock(side_effect=fake_post)
|
|
137
|
+
mock_cls.return_value = mock_client
|
|
138
|
+
|
|
139
|
+
result, raw, was_cached = await parse_image(b"\xff\xd8\xff", "image/jpeg")
|
|
140
|
+
|
|
141
|
+
assert "images" in payload_sent
|
|
142
|
+
assert len(payload_sent["images"]) == 1
|
|
143
|
+
assert isinstance(payload_sent["images"][0], str)
|
|
144
|
+
assert result["type"] == "hotel"
|
|
145
|
+
assert was_cached is False
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Tests for POST /parse/url endpoint.
|
|
3
|
+
httpx is mocked — no real network calls.
|
|
4
|
+
"""
|
|
5
|
+
import json
|
|
6
|
+
import pytest
|
|
7
|
+
from unittest.mock import AsyncMock, patch, MagicMock
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
FAKE_HTML = """
|
|
11
|
+
<html><head><title>Booking Confirmation</title></head>
|
|
12
|
+
<body>
|
|
13
|
+
<h1>Booking Confirmed</h1>
|
|
14
|
+
<p>Flight LH 400 Frankfurt to New York</p>
|
|
15
|
+
<p>Booking Reference: XYZ789</p>
|
|
16
|
+
<p>Date: 2025-08-15</p>
|
|
17
|
+
</body></html>
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@pytest.fixture
|
|
22
|
+
def mock_ollama_parse():
|
|
23
|
+
with patch("app.services.ollama.parse_text", new_callable=AsyncMock) as m:
|
|
24
|
+
m.return_value = (
|
|
25
|
+
{
|
|
26
|
+
"type": "flight",
|
|
27
|
+
"title": "LH 400 Frankfurt → New York",
|
|
28
|
+
"booking_ref": "XYZ789",
|
|
29
|
+
"provider": "Lufthansa",
|
|
30
|
+
"event_at": "2025-08-15T10:30:00",
|
|
31
|
+
"confidence": 0.88,
|
|
32
|
+
},
|
|
33
|
+
False,
|
|
34
|
+
)
|
|
35
|
+
yield m
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@pytest.fixture
|
|
39
|
+
def mock_http_response():
|
|
40
|
+
mock_resp = MagicMock()
|
|
41
|
+
mock_resp.headers = {"content-type": "text/html; charset=utf-8"}
|
|
42
|
+
mock_resp.text = FAKE_HTML
|
|
43
|
+
mock_resp.raise_for_status = MagicMock()
|
|
44
|
+
return mock_resp
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.mark.asyncio
|
|
48
|
+
async def test_parse_url_to_inbox(client, mock_ollama_parse, mock_http_response):
|
|
49
|
+
with patch("httpx.AsyncClient") as mock_client_cls:
|
|
50
|
+
mock_client_instance = AsyncMock()
|
|
51
|
+
mock_client_instance.get = AsyncMock(return_value=mock_http_response)
|
|
52
|
+
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client_instance)
|
|
53
|
+
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
54
|
+
|
|
55
|
+
resp = await client.post("/api/v1/parse/url", json={"url": "https://example.com/booking"})
|
|
56
|
+
|
|
57
|
+
assert resp.status_code == 200
|
|
58
|
+
data = resp.json()
|
|
59
|
+
assert data["parsed"]["type"] == "flight"
|
|
60
|
+
assert data["parsed"]["booking_ref"] == "XYZ789"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
async def test_parse_url_to_trip(client, mock_ollama_parse, mock_http_response):
|
|
65
|
+
# Create a trip first
|
|
66
|
+
trip_resp = await client.post("/api/v1/trips", json={"name": "Test Trip"})
|
|
67
|
+
assert trip_resp.status_code == 201
|
|
68
|
+
trip_id = trip_resp.json()["id"]
|
|
69
|
+
|
|
70
|
+
with patch("httpx.AsyncClient") as mock_client_cls:
|
|
71
|
+
mock_client_instance = AsyncMock()
|
|
72
|
+
mock_client_instance.get = AsyncMock(return_value=mock_http_response)
|
|
73
|
+
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client_instance)
|
|
74
|
+
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
75
|
+
|
|
76
|
+
resp = await client.post(
|
|
77
|
+
"/api/v1/parse/url",
|
|
78
|
+
json={"url": "https://example.com/booking", "trip_id": trip_id},
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
assert resp.status_code == 200
|
|
82
|
+
data = resp.json()
|
|
83
|
+
assert data["parsed"]["type"] == "flight"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
@pytest.mark.asyncio
|
|
87
|
+
async def test_parse_url_unreachable_returns_422(client):
|
|
88
|
+
import httpx
|
|
89
|
+
with patch("httpx.AsyncClient") as mock_client_cls:
|
|
90
|
+
mock_client_instance = AsyncMock()
|
|
91
|
+
mock_client_instance.get = AsyncMock(side_effect=httpx.ConnectError("refused"))
|
|
92
|
+
mock_client_cls.return_value.__aenter__ = AsyncMock(return_value=mock_client_instance)
|
|
93
|
+
mock_client_cls.return_value.__aexit__ = AsyncMock(return_value=False)
|
|
94
|
+
|
|
95
|
+
resp = await client.post("/api/v1/parse/url", json={"url": "https://unreachable.invalid"})
|
|
96
|
+
|
|
97
|
+
assert resp.status_code == 422
|
|
98
|
+
assert "Could not fetch URL" in resp.json()["detail"]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Unit tests for app/services/parser.py
|
|
3
|
+
|
|
4
|
+
Covers:
|
|
5
|
+
extract_text_from_pdf – real minimal PDF bytes, multi-page join
|
|
6
|
+
is_image / is_pdf / is_text – mime type routing guards
|
|
7
|
+
|
|
8
|
+
Run: pytest tests/test_parser.py -v
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from app.services.parser import extract_text_from_pdf, is_image, is_pdf, is_text
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
# ── MIME type guards ───────────────────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
def test_is_pdf():
|
|
18
|
+
assert is_pdf("application/pdf") is True
|
|
19
|
+
assert is_pdf("application/octet-stream") is False
|
|
20
|
+
assert is_pdf("image/png") is False
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_is_image():
|
|
24
|
+
assert is_image("image/jpeg") is True
|
|
25
|
+
assert is_image("image/png") is True
|
|
26
|
+
assert is_image("image/webp") is True
|
|
27
|
+
assert is_image("application/pdf") is False
|
|
28
|
+
assert is_image("text/plain") is False
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def test_is_text():
|
|
32
|
+
assert is_text("text/plain") is True
|
|
33
|
+
assert is_text("text/html") is True
|
|
34
|
+
assert is_text("text/csv") is True
|
|
35
|
+
assert is_text("application/pdf") is False
|
|
36
|
+
assert is_text("image/jpeg") is False
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# ── PDF extraction ─────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
def _minimal_pdf(text: str) -> bytes:
|
|
42
|
+
"""Creates a minimal valid 1-page PDF containing `text`."""
|
|
43
|
+
import fitz
|
|
44
|
+
doc = fitz.open()
|
|
45
|
+
page = doc.new_page()
|
|
46
|
+
page.insert_text((72, 72), text)
|
|
47
|
+
return doc.tobytes()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_extract_text_from_pdf_single_page():
|
|
51
|
+
content = _minimal_pdf("Booking confirmation LH400")
|
|
52
|
+
result = extract_text_from_pdf(content)
|
|
53
|
+
assert "LH400" in result
|
|
54
|
+
assert "Booking" in result
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_extract_text_from_pdf_strips_whitespace():
|
|
58
|
+
content = _minimal_pdf(" some text ")
|
|
59
|
+
result = extract_text_from_pdf(content)
|
|
60
|
+
assert result == result.strip()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_extract_text_from_pdf_multi_page():
|
|
64
|
+
import fitz
|
|
65
|
+
doc = fitz.open()
|
|
66
|
+
doc.new_page().insert_text((72, 72), "Page one content")
|
|
67
|
+
doc.new_page().insert_text((72, 72), "Page two content")
|
|
68
|
+
content = doc.tobytes()
|
|
69
|
+
|
|
70
|
+
result = extract_text_from_pdf(content)
|
|
71
|
+
assert "Page one" in result
|
|
72
|
+
assert "Page two" in result
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Tests for Stripe webhook handling (signature bypassed, event structure tested)."""
|
|
2
|
+
import json
|
|
3
|
+
from unittest.mock import patch, MagicMock
|
|
4
|
+
import pytest
|
|
5
|
+
from httpx import AsyncClient
|
|
6
|
+
|
|
7
|
+
# All webhook tests use client fixture from conftest
|
|
8
|
+
# Stripe signature verification is mocked
|
|
9
|
+
|
|
10
|
+
CHECKOUT_EVENT = {
|
|
11
|
+
"type": "checkout.session.completed",
|
|
12
|
+
"data": {"object": {
|
|
13
|
+
"metadata": {"user_id": "user-test-123"},
|
|
14
|
+
"client_reference_id": "user-test-123",
|
|
15
|
+
"customer": "cus_test123",
|
|
16
|
+
"amount_total": 999,
|
|
17
|
+
"currency": "eur",
|
|
18
|
+
}},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
SUBSCRIPTION_DELETED_EVENT = {
|
|
22
|
+
"type": "customer.subscription.deleted",
|
|
23
|
+
"data": {"object": {"customer": "cus_test123"}},
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
PAYMENT_SUCCEEDED_EVENT = {
|
|
27
|
+
"type": "invoice.payment_succeeded",
|
|
28
|
+
"data": {"object": {"subscription": "sub_123", "customer": "cus_test123"}},
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
PAYMENT_FAILED_EVENT_ATTEMPT1 = {
|
|
32
|
+
"type": "invoice.payment_failed",
|
|
33
|
+
"data": {"object": {"customer": "cus_test123", "attempt_count": 1}},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
PAYMENT_FAILED_EVENT_ATTEMPT3 = {
|
|
37
|
+
"type": "invoice.payment_failed",
|
|
38
|
+
"data": {"object": {"customer": "cus_test123", "attempt_count": 3}},
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def mock_stripe_webhook(event_dict):
|
|
43
|
+
"""Patch stripe to bypass signature verification and return our event dict."""
|
|
44
|
+
mock_event = MagicMock()
|
|
45
|
+
mock_event.__getitem__ = lambda self, key: event_dict[key]
|
|
46
|
+
mock_event.get = lambda key, default=None: event_dict.get(key, default)
|
|
47
|
+
mock_event["type"] = event_dict["type"]
|
|
48
|
+
mock_event["data"] = event_dict["data"]
|
|
49
|
+
return mock_event
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@pytest.mark.asyncio
|
|
53
|
+
async def test_webhook_no_stripe_configured_returns_ok(client: AsyncClient):
|
|
54
|
+
"""When stripe not configured, webhook returns 200 immediately."""
|
|
55
|
+
with patch("app.routers.payments.get_settings") as mock_settings:
|
|
56
|
+
mock_settings.return_value.stripe_secret_key = ""
|
|
57
|
+
r = await client.post("/api/v1/payments/webhook",
|
|
58
|
+
content=b"{}",
|
|
59
|
+
headers={"stripe-signature": "test"})
|
|
60
|
+
assert r.status_code == 200
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.asyncio
|
|
64
|
+
async def test_webhook_missing_signature_returns_400(client: AsyncClient):
|
|
65
|
+
"""Missing Stripe-Signature header returns 400."""
|
|
66
|
+
with patch("app.routers.payments.get_settings") as mock_settings:
|
|
67
|
+
mock_settings.return_value.stripe_secret_key = "sk_test_123"
|
|
68
|
+
mock_settings.return_value.stripe_webhook_secret = "whsec_test"
|
|
69
|
+
r = await client.post("/api/v1/payments/webhook", content=b"{}")
|
|
70
|
+
assert r.status_code == 400
|
|
71
|
+
assert "Stripe-Signature" in r.json()["detail"]
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@pytest.mark.asyncio
|
|
75
|
+
async def test_checkout_completed_upgrades_user(client: AsyncClient):
|
|
76
|
+
"""checkout.session.completed sets user to pro and stores customer_id."""
|
|
77
|
+
from unittest.mock import AsyncMock
|
|
78
|
+
import stripe as stripe_mod
|
|
79
|
+
mock_event = {
|
|
80
|
+
"type": "checkout.session.completed",
|
|
81
|
+
"data": {"object": {
|
|
82
|
+
"metadata": {"user_id": "user-test-123"},
|
|
83
|
+
"client_reference_id": "user-test-123",
|
|
84
|
+
"customer": "cus_test123",
|
|
85
|
+
"amount_total": 999,
|
|
86
|
+
"currency": "eur",
|
|
87
|
+
}},
|
|
88
|
+
}
|
|
89
|
+
with patch("app.config.get_settings") as ms, \
|
|
90
|
+
patch("stripe.Webhook.construct_event") as mock_construct, \
|
|
91
|
+
patch("app.services.telemetry.track", new_callable=AsyncMock), \
|
|
92
|
+
patch("app.services.notifier.notify", new_callable=AsyncMock):
|
|
93
|
+
ms.return_value.stripe_secret_key = "sk_test"
|
|
94
|
+
ms.return_value.stripe_webhook_secret = "whsec_test"
|
|
95
|
+
mock_construct.return_value = mock_event
|
|
96
|
+
r = await client.post("/api/v1/payments/webhook",
|
|
97
|
+
content=b"{}",
|
|
98
|
+
headers={"stripe-signature": "t=1,v1=abc"})
|
|
99
|
+
assert r.status_code == 200
|
|
100
|
+
assert r.json() == {"ok": True}
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@pytest.mark.asyncio
|
|
104
|
+
async def test_subscription_deleted_downgrades_user(client: AsyncClient):
|
|
105
|
+
"""customer.subscription.deleted downgrades user to free."""
|
|
106
|
+
from sqlalchemy import text
|
|
107
|
+
from unittest.mock import AsyncMock
|
|
108
|
+
mock_event = {
|
|
109
|
+
"type": "customer.subscription.deleted",
|
|
110
|
+
"data": {"object": {"customer": "cus_test999"}},
|
|
111
|
+
}
|
|
112
|
+
with patch("app.config.get_settings") as ms, \
|
|
113
|
+
patch("stripe.Webhook.construct_event") as mock_construct, \
|
|
114
|
+
patch("app.services.telemetry.track", new_callable=AsyncMock), \
|
|
115
|
+
patch("app.services.notifier.notify", new_callable=AsyncMock):
|
|
116
|
+
ms.return_value.stripe_secret_key = "sk_test"
|
|
117
|
+
ms.return_value.stripe_webhook_secret = "whsec_test"
|
|
118
|
+
mock_construct.return_value = mock_event
|
|
119
|
+
r = await client.post("/api/v1/payments/webhook",
|
|
120
|
+
content=b"{}",
|
|
121
|
+
headers={"stripe-signature": "t=1,v1=abc"})
|
|
122
|
+
assert r.status_code == 200
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@pytest.mark.asyncio
|
|
126
|
+
async def test_invoice_payment_failed_attempt1_no_downgrade(client: AsyncClient):
|
|
127
|
+
"""invoice.payment_failed attempt 1: grace period, no downgrade."""
|
|
128
|
+
from unittest.mock import AsyncMock
|
|
129
|
+
mock_event = {
|
|
130
|
+
"type": "invoice.payment_failed",
|
|
131
|
+
"data": {"object": {"customer": "cus_test123", "attempt_count": 1}},
|
|
132
|
+
}
|
|
133
|
+
with patch("app.config.get_settings") as ms, \
|
|
134
|
+
patch("stripe.Webhook.construct_event") as mock_construct, \
|
|
135
|
+
patch("app.services.telemetry.track", new_callable=AsyncMock), \
|
|
136
|
+
patch("app.services.notifier.notify", new_callable=AsyncMock):
|
|
137
|
+
ms.return_value.stripe_secret_key = "sk_test"
|
|
138
|
+
ms.return_value.stripe_webhook_secret = "whsec_test"
|
|
139
|
+
mock_construct.return_value = mock_event
|
|
140
|
+
r = await client.post("/api/v1/payments/webhook",
|
|
141
|
+
content=b"{}",
|
|
142
|
+
headers={"stripe-signature": "t=1,v1=abc"})
|
|
143
|
+
assert r.status_code == 200
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
@pytest.mark.asyncio
|
|
147
|
+
async def test_unknown_event_type_returns_ok(client: AsyncClient):
|
|
148
|
+
"""Unknown event type is ignored and returns 200."""
|
|
149
|
+
from unittest.mock import AsyncMock
|
|
150
|
+
mock_event = {
|
|
151
|
+
"type": "customer.created",
|
|
152
|
+
"data": {"object": {}},
|
|
153
|
+
}
|
|
154
|
+
with patch("app.config.get_settings") as ms, \
|
|
155
|
+
patch("stripe.Webhook.construct_event") as mock_construct:
|
|
156
|
+
ms.return_value.stripe_secret_key = "sk_test"
|
|
157
|
+
ms.return_value.stripe_webhook_secret = "whsec_test"
|
|
158
|
+
mock_construct.return_value = mock_event
|
|
159
|
+
r = await client.post("/api/v1/payments/webhook",
|
|
160
|
+
content=b"{}",
|
|
161
|
+
headers={"stripe-signature": "t=1,v1=abc"})
|
|
162
|
+
assert r.status_code == 200
|
|
163
|
+
assert r.json() == {"ok": True}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Tests for freemium quota enforcement."""
|
|
2
|
+
import pytest
|
|
3
|
+
from httpx import AsyncClient
|
|
4
|
+
|
|
5
|
+
pytestmark = pytest.mark.asyncio
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
async def test_free_trip_limit_enforced(client: AsyncClient):
|
|
9
|
+
"""Free plan blocks trip creation after 3 trips."""
|
|
10
|
+
# Create 3 trips (should succeed)
|
|
11
|
+
for i in range(3):
|
|
12
|
+
r = await client.post(
|
|
13
|
+
"/api/v1/trips",
|
|
14
|
+
json={"name": f"Trip {i}", "description": None, "start_date": None, "end_date": None},
|
|
15
|
+
)
|
|
16
|
+
assert r.status_code == 201, f"Trip {i} creation failed: {r.text}"
|
|
17
|
+
|
|
18
|
+
# 4th trip should be blocked (402)
|
|
19
|
+
r = await client.post(
|
|
20
|
+
"/api/v1/trips",
|
|
21
|
+
json={"name": "Trip 4", "description": None, "start_date": None, "end_date": None},
|
|
22
|
+
)
|
|
23
|
+
assert r.status_code == 402
|
|
24
|
+
assert "Free plan" in r.json()["detail"]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
async def test_pro_plan_bypasses_trip_limit(client: AsyncClient, db_session):
|
|
28
|
+
"""Pro users can create more than 3 trips."""
|
|
29
|
+
from sqlalchemy import text
|
|
30
|
+
|
|
31
|
+
# Upgrade user to pro
|
|
32
|
+
await db_session.execute(
|
|
33
|
+
text(
|
|
34
|
+
"INSERT INTO users (id, plan) VALUES (:uid, 'pro')"
|
|
35
|
+
" ON CONFLICT (id) DO UPDATE SET plan='pro'"
|
|
36
|
+
),
|
|
37
|
+
{"uid": "user-test-123"},
|
|
38
|
+
)
|
|
39
|
+
await db_session.commit()
|
|
40
|
+
|
|
41
|
+
for i in range(5):
|
|
42
|
+
r = await client.post(
|
|
43
|
+
"/api/v1/trips",
|
|
44
|
+
json={"name": f"Pro Trip {i}", "description": None, "start_date": None, "end_date": None},
|
|
45
|
+
)
|
|
46
|
+
assert r.status_code == 201
|