@cachly-dev/mcp-server 0.10.47 → 0.10.49

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.
Files changed (309) hide show
  1. package/.github/workflows/eas-build-android.yml +58 -0
  2. package/.github/workflows/eas-update.yml +55 -0
  3. package/STRATEGY.md +209 -0
  4. package/apps/travel-chaos-organizer/.env.example +49 -0
  5. package/apps/travel-chaos-organizer/DEPLOY.md +328 -0
  6. package/apps/travel-chaos-organizer/Makefile +57 -0
  7. package/apps/travel-chaos-organizer/README.md +159 -0
  8. package/apps/travel-chaos-organizer/backend/.env.example +10 -0
  9. package/apps/travel-chaos-organizer/backend/Dockerfile +25 -0
  10. package/apps/travel-chaos-organizer/backend/alembic/env.py +56 -0
  11. package/apps/travel-chaos-organizer/backend/alembic/script.py.mako +24 -0
  12. package/apps/travel-chaos-organizer/backend/alembic/versions/.gitkeep +0 -0
  13. package/apps/travel-chaos-organizer/backend/alembic/versions/0001_stripe_customer_id.py +24 -0
  14. package/apps/travel-chaos-organizer/backend/alembic/versions/0002_telegram_chat_id.py +24 -0
  15. package/apps/travel-chaos-organizer/backend/alembic.ini +39 -0
  16. package/apps/travel-chaos-organizer/backend/app/__init__.py +0 -0
  17. package/apps/travel-chaos-organizer/backend/app/auth/__init__.py +0 -0
  18. package/apps/travel-chaos-organizer/backend/app/auth/keycloak.py +81 -0
  19. package/apps/travel-chaos-organizer/backend/app/config.py +66 -0
  20. package/apps/travel-chaos-organizer/backend/app/db/__init__.py +0 -0
  21. package/apps/travel-chaos-organizer/backend/app/db/database.py +20 -0
  22. package/apps/travel-chaos-organizer/backend/app/db/migrations/001_init.sql +71 -0
  23. package/apps/travel-chaos-organizer/backend/app/db/migrations/002_telemetry_freemium.sql +31 -0
  24. package/apps/travel-chaos-organizer/backend/app/db/migrations/003_telegram_chat_id.sql +4 -0
  25. package/apps/travel-chaos-organizer/backend/app/db/migrations/004_stripe_and_email.sql +5 -0
  26. package/apps/travel-chaos-organizer/backend/app/limiter.py +4 -0
  27. package/apps/travel-chaos-organizer/backend/app/logging_config.py +25 -0
  28. package/apps/travel-chaos-organizer/backend/app/main.py +77 -0
  29. package/apps/travel-chaos-organizer/backend/app/models/__init__.py +0 -0
  30. package/apps/travel-chaos-organizer/backend/app/models/schemas.py +142 -0
  31. package/apps/travel-chaos-organizer/backend/app/routers/__init__.py +0 -0
  32. package/apps/travel-chaos-organizer/backend/app/routers/admin.py +237 -0
  33. package/apps/travel-chaos-organizer/backend/app/routers/cache.py +13 -0
  34. package/apps/travel-chaos-organizer/backend/app/routers/events.py +29 -0
  35. package/apps/travel-chaos-organizer/backend/app/routers/export.py +122 -0
  36. package/apps/travel-chaos-organizer/backend/app/routers/inbox.py +122 -0
  37. package/apps/travel-chaos-organizer/backend/app/routers/items.py +130 -0
  38. package/apps/travel-chaos-organizer/backend/app/routers/mail.py +108 -0
  39. package/apps/travel-chaos-organizer/backend/app/routers/parse.py +303 -0
  40. package/apps/travel-chaos-organizer/backend/app/routers/payments.py +179 -0
  41. package/apps/travel-chaos-organizer/backend/app/routers/trips.py +134 -0
  42. package/apps/travel-chaos-organizer/backend/app/routers/users.py +169 -0
  43. package/apps/travel-chaos-organizer/backend/app/routers/waitlist.py +37 -0
  44. package/apps/travel-chaos-organizer/backend/app/services/__init__.py +0 -0
  45. package/apps/travel-chaos-organizer/backend/app/services/cache.py +131 -0
  46. package/apps/travel-chaos-organizer/backend/app/services/drip.py +128 -0
  47. package/apps/travel-chaos-organizer/backend/app/services/email.py +61 -0
  48. package/apps/travel-chaos-organizer/backend/app/services/errors.py +44 -0
  49. package/apps/travel-chaos-organizer/backend/app/services/notifier.py +10 -0
  50. package/apps/travel-chaos-organizer/backend/app/services/ollama.py +101 -0
  51. package/apps/travel-chaos-organizer/backend/app/services/parser.py +22 -0
  52. package/apps/travel-chaos-organizer/backend/app/services/quota.py +85 -0
  53. package/apps/travel-chaos-organizer/backend/app/services/sentry.py +11 -0
  54. package/apps/travel-chaos-organizer/backend/app/services/telemetry.py +35 -0
  55. package/apps/travel-chaos-organizer/backend/pytest.ini +4 -0
  56. package/apps/travel-chaos-organizer/backend/requirements-dev.txt +6 -0
  57. package/apps/travel-chaos-organizer/backend/requirements.txt +25 -0
  58. package/apps/travel-chaos-organizer/backend/tests/__init__.py +0 -0
  59. package/apps/travel-chaos-organizer/backend/tests/conftest.py +223 -0
  60. package/apps/travel-chaos-organizer/backend/tests/test_admin.py +99 -0
  61. package/apps/travel-chaos-organizer/backend/tests/test_cache.py +142 -0
  62. package/apps/travel-chaos-organizer/backend/tests/test_drip.py +32 -0
  63. package/apps/travel-chaos-organizer/backend/tests/test_e2e_chain.py +121 -0
  64. package/apps/travel-chaos-organizer/backend/tests/test_export.py +48 -0
  65. package/apps/travel-chaos-organizer/backend/tests/test_inbox.py +107 -0
  66. package/apps/travel-chaos-organizer/backend/tests/test_items.py +138 -0
  67. package/apps/travel-chaos-organizer/backend/tests/test_mail.py +113 -0
  68. package/apps/travel-chaos-organizer/backend/tests/test_ollama.py +145 -0
  69. package/apps/travel-chaos-organizer/backend/tests/test_parse_url.py +98 -0
  70. package/apps/travel-chaos-organizer/backend/tests/test_parser.py +72 -0
  71. package/apps/travel-chaos-organizer/backend/tests/test_payments.py +163 -0
  72. package/apps/travel-chaos-organizer/backend/tests/test_quota.py +46 -0
  73. package/apps/travel-chaos-organizer/backend/tests/test_search.py +67 -0
  74. package/apps/travel-chaos-organizer/backend/tests/test_trips.py +123 -0
  75. package/apps/travel-chaos-organizer/backend/tests/test_users.py +52 -0
  76. package/apps/travel-chaos-organizer/backups/.gitkeep +0 -0
  77. package/apps/travel-chaos-organizer/caddy/Caddyfile +27 -0
  78. package/apps/travel-chaos-organizer/docker-compose.node1.yml +31 -0
  79. package/apps/travel-chaos-organizer/docker-compose.override.yml +14 -0
  80. package/apps/travel-chaos-organizer/docker-compose.yml +128 -0
  81. package/apps/travel-chaos-organizer/frontend/.env.example +13 -0
  82. package/apps/travel-chaos-organizer/frontend/app/(app)/_layout.tsx +80 -0
  83. package/apps/travel-chaos-organizer/frontend/app/(app)/inbox.tsx +166 -0
  84. package/apps/travel-chaos-organizer/frontend/app/(app)/search.tsx +130 -0
  85. package/apps/travel-chaos-organizer/frontend/app/(app)/settings.tsx +381 -0
  86. package/apps/travel-chaos-organizer/frontend/app/(app)/trips/[id].tsx +346 -0
  87. package/apps/travel-chaos-organizer/frontend/app/(app)/trips.tsx +363 -0
  88. package/apps/travel-chaos-organizer/frontend/app/(app)/upgrade/cancel.tsx +34 -0
  89. package/apps/travel-chaos-organizer/frontend/app/(app)/upgrade/success.tsx +35 -0
  90. package/apps/travel-chaos-organizer/frontend/app/(auth)/login.tsx +91 -0
  91. package/apps/travel-chaos-organizer/frontend/app/_layout.tsx +71 -0
  92. package/apps/travel-chaos-organizer/frontend/app/auth.tsx +29 -0
  93. package/apps/travel-chaos-organizer/frontend/app/index.tsx +16 -0
  94. package/apps/travel-chaos-organizer/frontend/app/onboarding.tsx +119 -0
  95. package/apps/travel-chaos-organizer/frontend/app.config.ts +54 -0
  96. package/apps/travel-chaos-organizer/frontend/assets/adaptive-icon.svg +66 -0
  97. package/apps/travel-chaos-organizer/frontend/assets/icon.svg +79 -0
  98. package/apps/travel-chaos-organizer/frontend/assets/splash.svg +15 -0
  99. package/apps/travel-chaos-organizer/frontend/components/BottomSheet.tsx +102 -0
  100. package/apps/travel-chaos-organizer/frontend/components/ConfettiCannon.tsx +107 -0
  101. package/apps/travel-chaos-organizer/frontend/components/ConfirmDialog.tsx +109 -0
  102. package/apps/travel-chaos-organizer/frontend/components/ErrorBoundary.tsx +45 -0
  103. package/apps/travel-chaos-organizer/frontend/components/FileUploadButton.tsx +19 -0
  104. package/apps/travel-chaos-organizer/frontend/components/ImportSheet.tsx +155 -0
  105. package/apps/travel-chaos-organizer/frontend/components/OfflineBadge.tsx +55 -0
  106. package/apps/travel-chaos-organizer/frontend/components/QRCodeViewer.tsx +80 -0
  107. package/apps/travel-chaos-organizer/frontend/components/ScoreRing.tsx +39 -0
  108. package/apps/travel-chaos-organizer/frontend/components/Screen.tsx +39 -0
  109. package/apps/travel-chaos-organizer/frontend/components/Skeleton.tsx +70 -0
  110. package/apps/travel-chaos-organizer/frontend/components/SwipeableInboxItem.tsx +121 -0
  111. package/apps/travel-chaos-organizer/frontend/components/SwipeableTimelineItem.tsx +70 -0
  112. package/apps/travel-chaos-organizer/frontend/components/TimelineItem.tsx +73 -0
  113. package/apps/travel-chaos-organizer/frontend/components/TimelineItemDetail.tsx +130 -0
  114. package/apps/travel-chaos-organizer/frontend/components/Toast.tsx +60 -0
  115. package/apps/travel-chaos-organizer/frontend/components/ToastContext.tsx +42 -0
  116. package/apps/travel-chaos-organizer/frontend/components/TripCard.tsx +146 -0
  117. package/apps/travel-chaos-organizer/frontend/components/TripQRModal.tsx +70 -0
  118. package/apps/travel-chaos-organizer/frontend/components/UpgradeBanner.tsx +44 -0
  119. package/apps/travel-chaos-organizer/frontend/eas.json +22 -0
  120. package/apps/travel-chaos-organizer/frontend/hooks/useInbox.ts +24 -0
  121. package/apps/travel-chaos-organizer/frontend/hooks/useNetworkSync.ts +60 -0
  122. package/apps/travel-chaos-organizer/frontend/hooks/useTrips.ts +112 -0
  123. package/apps/travel-chaos-organizer/frontend/lib/api.ts +223 -0
  124. package/apps/travel-chaos-organizer/frontend/lib/auth.ts +93 -0
  125. package/apps/travel-chaos-organizer/frontend/lib/db.ts +86 -0
  126. package/apps/travel-chaos-organizer/frontend/lib/fileCache.ts +54 -0
  127. package/apps/travel-chaos-organizer/frontend/lib/haptics.ts +13 -0
  128. package/apps/travel-chaos-organizer/frontend/lib/notifications.ts +112 -0
  129. package/apps/travel-chaos-organizer/frontend/lib/offlineQueue.ts +145 -0
  130. package/apps/travel-chaos-organizer/frontend/lib/quota.tsx +43 -0
  131. package/apps/travel-chaos-organizer/frontend/lib/sentry.ts +28 -0
  132. package/apps/travel-chaos-organizer/frontend/lib/shareIntent.ts +44 -0
  133. package/apps/travel-chaos-organizer/frontend/package-lock.json +13037 -0
  134. package/apps/travel-chaos-organizer/frontend/package.json +44 -0
  135. package/apps/travel-chaos-organizer/frontend/tsconfig.json +23 -0
  136. package/apps/travel-chaos-organizer/keycloak/realm-export.json +59 -0
  137. package/apps/travel-chaos-organizer/landing/index.html +773 -0
  138. package/apps/travel-chaos-organizer/scripts/backup.sh +14 -0
  139. package/apps/travel-chaos-organizer/scripts/configure-env.sh +82 -0
  140. package/apps/travel-chaos-organizer/scripts/deploy.sh +54 -0
  141. package/apps/travel-chaos-organizer/scripts/health-check.sh +36 -0
  142. package/apps/travel-chaos-organizer/scripts/migrate.sh +7 -0
  143. package/apps/travel-chaos-organizer/scripts/server-setup.sh +85 -0
  144. package/apps/travel-chaos-organizer/scripts/smoke-test.sh +29 -0
  145. package/apps/travel-chaos-organizer/scripts/verify-tracking.sh +71 -0
  146. package/apps/travel-chaos-organizer/telegram-bot/.env.example +3 -0
  147. package/apps/travel-chaos-organizer/telegram-bot/Dockerfile +6 -0
  148. package/apps/travel-chaos-organizer/telegram-bot/bot.py +504 -0
  149. package/apps/travel-chaos-organizer/telegram-bot/requirements.txt +3 -0
  150. package/dist/packages/telegram-notify/client.d.ts +19 -0
  151. package/dist/packages/telegram-notify/client.js +75 -0
  152. package/dist/packages/telegram-notify/client.js.map +1 -0
  153. package/dist/src/__tests__/auth.test.js.map +1 -0
  154. package/dist/{__tests__ → src/__tests__}/brain-flow.test.js +143 -2
  155. package/dist/src/__tests__/brain-flow.test.js.map +1 -0
  156. package/dist/src/__tests__/confidence.test.js.map +1 -0
  157. package/dist/src/__tests__/e2e/api-auth.test.js.map +1 -0
  158. package/dist/src/__tests__/e2e/api-health.test.js.map +1 -0
  159. package/dist/src/__tests__/e2e/cli.test.js.map +1 -0
  160. package/dist/src/__tests__/e2e/config.js.map +1 -0
  161. package/dist/src/__tests__/e2e/funnel.test.js.map +1 -0
  162. package/dist/src/__tests__/e2e/mcp-tools.test.js.map +1 -0
  163. package/dist/src/__tests__/e2e/provisioning.test.js.map +1 -0
  164. package/dist/{__tests__ → src/__tests__}/handlers-unit.test.js +1 -1
  165. package/dist/src/__tests__/handlers-unit.test.js.map +1 -0
  166. package/dist/src/__tests__/namespace.test.js.map +1 -0
  167. package/dist/src/auth.js.map +1 -0
  168. package/dist/src/ckg.js.map +1 -0
  169. package/dist/src/confidence.js.map +1 -0
  170. package/dist/src/embeddings.js.map +1 -0
  171. package/dist/src/handlers/advanced.js.map +1 -0
  172. package/dist/{handlers → src/handlers}/brain.js +135 -31
  173. package/dist/src/handlers/brain.js.map +1 -0
  174. package/dist/src/handlers/cache.js.map +1 -0
  175. package/dist/{handlers → src/handlers}/context.js +9 -4
  176. package/dist/src/handlers/context.js.map +1 -0
  177. package/dist/{handlers → src/handlers}/fedbrain.d.ts +6 -0
  178. package/dist/{handlers → src/handlers}/fedbrain.js +15 -2
  179. package/dist/src/handlers/fedbrain.js.map +1 -0
  180. package/dist/src/handlers/instances.js.map +1 -0
  181. package/dist/{handlers → src/handlers}/roadmap.js +8 -4
  182. package/dist/src/handlers/roadmap.js.map +1 -0
  183. package/dist/src/handlers/syndicate.js.map +1 -0
  184. package/dist/src/handlers/tco.d.ts +16 -0
  185. package/dist/src/handlers/tco.js +303 -0
  186. package/dist/src/handlers/tco.js.map +1 -0
  187. package/dist/{handlers → src/handlers}/team.js +1 -1
  188. package/dist/src/handlers/team.js.map +1 -0
  189. package/dist/{index.js → src/index.js} +145 -97
  190. package/dist/src/index.js.map +1 -0
  191. package/dist/src/namespace.js.map +1 -0
  192. package/dist/src/notifier.d.ts +5 -0
  193. package/dist/src/notifier.js +6 -0
  194. package/dist/src/notifier.js.map +1 -0
  195. package/dist/{search.js → src/search.js} +3 -3
  196. package/dist/src/search.js.map +1 -0
  197. package/dist/src/search.test.js.map +1 -0
  198. package/dist/{tools.d.ts → src/tools.d.ts} +196 -0
  199. package/dist/{tools.js → src/tools.js} +178 -0
  200. package/dist/src/tools.js.map +1 -0
  201. package/dist/src/utils.js.map +1 -0
  202. package/package.json +3 -2
  203. package/packages/telegram-notify/SPLIT_PLAN.md +106 -0
  204. package/packages/telegram-notify/client.ts +88 -0
  205. package/packages/telegram-notify/emojis.json +22 -0
  206. package/packages/telegram-notify/pyproject.toml +17 -0
  207. package/packages/telegram-notify/telegram_notify/__init__.py +3 -0
  208. package/packages/telegram-notify/telegram_notify/client.py +87 -0
  209. package/packages/telegram-notify/telegram_notify/emojis.json +22 -0
  210. package/scripts/generate-icons.js +28 -0
  211. package/smithery.yaml +21 -10
  212. package/src/__tests__/brain-flow.test.ts +176 -2
  213. package/src/__tests__/handlers-unit.test.ts +1 -1
  214. package/src/handlers/brain.ts +139 -32
  215. package/src/handlers/context.ts +9 -4
  216. package/src/handlers/fedbrain.ts +17 -2
  217. package/src/handlers/roadmap.ts +8 -4
  218. package/src/handlers/tco.ts +347 -0
  219. package/src/handlers/team.ts +1 -1
  220. package/src/index.ts +137 -147
  221. package/src/notifier.ts +5 -0
  222. package/src/search.ts +3 -3
  223. package/src/tools.ts +191 -0
  224. package/tsconfig.json +2 -3
  225. package/dist/__tests__/auth.test.js.map +0 -1
  226. package/dist/__tests__/brain-flow.test.js.map +0 -1
  227. package/dist/__tests__/brain-predict-failures.test.d.ts +0 -17
  228. package/dist/__tests__/brain-predict-failures.test.js +0 -339
  229. package/dist/__tests__/brain-predict-failures.test.js.map +0 -1
  230. package/dist/__tests__/confidence.test.js.map +0 -1
  231. package/dist/__tests__/e2e/api-auth.test.js.map +0 -1
  232. package/dist/__tests__/e2e/api-health.test.js.map +0 -1
  233. package/dist/__tests__/e2e/cli.test.js.map +0 -1
  234. package/dist/__tests__/e2e/config.js.map +0 -1
  235. package/dist/__tests__/e2e/funnel.test.js.map +0 -1
  236. package/dist/__tests__/e2e/mcp-tools.test.js.map +0 -1
  237. package/dist/__tests__/e2e/provisioning.test.js.map +0 -1
  238. package/dist/__tests__/handlers-unit.test.js.map +0 -1
  239. package/dist/__tests__/namespace.test.js.map +0 -1
  240. package/dist/auth.js.map +0 -1
  241. package/dist/ckg.js.map +0 -1
  242. package/dist/confidence.js.map +0 -1
  243. package/dist/embeddings.js.map +0 -1
  244. package/dist/handlers/advanced.js.map +0 -1
  245. package/dist/handlers/brain.js.map +0 -1
  246. package/dist/handlers/cache.js.map +0 -1
  247. package/dist/handlers/context.js.map +0 -1
  248. package/dist/handlers/fedbrain.js.map +0 -1
  249. package/dist/handlers/instances.js.map +0 -1
  250. package/dist/handlers/roadmap.js.map +0 -1
  251. package/dist/handlers/syndicate.js.map +0 -1
  252. package/dist/handlers/team.js.map +0 -1
  253. package/dist/index.js.map +0 -1
  254. package/dist/namespace.js.map +0 -1
  255. package/dist/search.js.map +0 -1
  256. package/dist/search.test.js.map +0 -1
  257. package/dist/tools.js.map +0 -1
  258. package/dist/utils.js.map +0 -1
  259. package/src/__tests__/brain-predict-failures.test.ts +0 -430
  260. /package/dist/{__tests__ → src/__tests__}/auth.test.d.ts +0 -0
  261. /package/dist/{__tests__ → src/__tests__}/auth.test.js +0 -0
  262. /package/dist/{__tests__ → src/__tests__}/brain-flow.test.d.ts +0 -0
  263. /package/dist/{__tests__ → src/__tests__}/confidence.test.d.ts +0 -0
  264. /package/dist/{__tests__ → src/__tests__}/confidence.test.js +0 -0
  265. /package/dist/{__tests__ → src/__tests__}/e2e/api-auth.test.d.ts +0 -0
  266. /package/dist/{__tests__ → src/__tests__}/e2e/api-auth.test.js +0 -0
  267. /package/dist/{__tests__ → src/__tests__}/e2e/api-health.test.d.ts +0 -0
  268. /package/dist/{__tests__ → src/__tests__}/e2e/api-health.test.js +0 -0
  269. /package/dist/{__tests__ → src/__tests__}/e2e/cli.test.d.ts +0 -0
  270. /package/dist/{__tests__ → src/__tests__}/e2e/cli.test.js +0 -0
  271. /package/dist/{__tests__ → src/__tests__}/e2e/config.d.ts +0 -0
  272. /package/dist/{__tests__ → src/__tests__}/e2e/config.js +0 -0
  273. /package/dist/{__tests__ → src/__tests__}/e2e/funnel.test.d.ts +0 -0
  274. /package/dist/{__tests__ → src/__tests__}/e2e/funnel.test.js +0 -0
  275. /package/dist/{__tests__ → src/__tests__}/e2e/mcp-tools.test.d.ts +0 -0
  276. /package/dist/{__tests__ → src/__tests__}/e2e/mcp-tools.test.js +0 -0
  277. /package/dist/{__tests__ → src/__tests__}/e2e/provisioning.test.d.ts +0 -0
  278. /package/dist/{__tests__ → src/__tests__}/e2e/provisioning.test.js +0 -0
  279. /package/dist/{__tests__ → src/__tests__}/handlers-unit.test.d.ts +0 -0
  280. /package/dist/{__tests__ → src/__tests__}/namespace.test.d.ts +0 -0
  281. /package/dist/{__tests__ → src/__tests__}/namespace.test.js +0 -0
  282. /package/dist/{auth.d.ts → src/auth.d.ts} +0 -0
  283. /package/dist/{auth.js → src/auth.js} +0 -0
  284. /package/dist/{ckg.d.ts → src/ckg.d.ts} +0 -0
  285. /package/dist/{ckg.js → src/ckg.js} +0 -0
  286. /package/dist/{confidence.d.ts → src/confidence.d.ts} +0 -0
  287. /package/dist/{confidence.js → src/confidence.js} +0 -0
  288. /package/dist/{embeddings.d.ts → src/embeddings.d.ts} +0 -0
  289. /package/dist/{embeddings.js → src/embeddings.js} +0 -0
  290. /package/dist/{handlers → src/handlers}/advanced.d.ts +0 -0
  291. /package/dist/{handlers → src/handlers}/advanced.js +0 -0
  292. /package/dist/{handlers → src/handlers}/brain.d.ts +0 -0
  293. /package/dist/{handlers → src/handlers}/cache.d.ts +0 -0
  294. /package/dist/{handlers → src/handlers}/cache.js +0 -0
  295. /package/dist/{handlers → src/handlers}/context.d.ts +0 -0
  296. /package/dist/{handlers → src/handlers}/instances.d.ts +0 -0
  297. /package/dist/{handlers → src/handlers}/instances.js +0 -0
  298. /package/dist/{handlers → src/handlers}/roadmap.d.ts +0 -0
  299. /package/dist/{handlers → src/handlers}/syndicate.d.ts +0 -0
  300. /package/dist/{handlers → src/handlers}/syndicate.js +0 -0
  301. /package/dist/{handlers → src/handlers}/team.d.ts +0 -0
  302. /package/dist/{index.d.ts → src/index.d.ts} +0 -0
  303. /package/dist/{namespace.d.ts → src/namespace.d.ts} +0 -0
  304. /package/dist/{namespace.js → src/namespace.js} +0 -0
  305. /package/dist/{search.d.ts → src/search.d.ts} +0 -0
  306. /package/dist/{search.test.d.ts → src/search.test.d.ts} +0 -0
  307. /package/dist/{search.test.js → src/search.test.js} +0 -0
  308. /package/dist/{utils.d.ts → src/utils.d.ts} +0 -0
  309. /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