@app-vitals/agent-admin 0.0.1 → 0.1.0

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 (325) hide show
  1. package/Dockerfile +43 -0
  2. package/README.md +72 -2
  3. package/e2e/agents-page.e2e.ts +338 -0
  4. package/e2e/chat-degraded.e2e.ts +153 -0
  5. package/e2e/chat-page.e2e.ts +169 -0
  6. package/e2e/chat-progress.e2e.ts +206 -0
  7. package/e2e/chat-thread-page.e2e.ts +444 -0
  8. package/e2e/login-page.e2e.ts +148 -0
  9. package/e2e/test-server.ts +427 -0
  10. package/openapi.json +6098 -0
  11. package/openapi.platform.json +5553 -0
  12. package/package.json +62 -4
  13. package/playwright.config.ts +25 -0
  14. package/prisma/client/browser.ts +134 -0
  15. package/prisma/client/client.ts +158 -0
  16. package/prisma/client/commonInputTypes.ts +578 -0
  17. package/prisma/client/enums.ts +15 -0
  18. package/prisma/client/internal/class.ts +424 -0
  19. package/prisma/client/internal/prismaNamespace.ts +2898 -0
  20. package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
  21. package/prisma/client/models/Account.ts +1774 -0
  22. package/prisma/client/models/AccountInvite.ts +1386 -0
  23. package/prisma/client/models/AccountMember.ts +1351 -0
  24. package/prisma/client/models/Agent.ts +4554 -0
  25. package/prisma/client/models/AgentAsk.ts +1373 -0
  26. package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
  27. package/prisma/client/models/AgentCronJob.ts +2416 -0
  28. package/prisma/client/models/AgentCronRun.ts +2858 -0
  29. package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
  30. package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
  31. package/prisma/client/models/AgentEnv.ts +1421 -0
  32. package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
  33. package/prisma/client/models/AgentMember.ts +1316 -0
  34. package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
  35. package/prisma/client/models/AgentPlugin.ts +1421 -0
  36. package/prisma/client/models/AgentToken.ts +1380 -0
  37. package/prisma/client/models/AgentTool.ts +1351 -0
  38. package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
  39. package/prisma/client/models/ChatThreadWatch.ts +1183 -0
  40. package/prisma/client/models/PushSubscription.ts +1233 -0
  41. package/prisma/client/models/SessionAlertState.ts +1187 -0
  42. package/prisma/client/models/SessionFollow.ts +1187 -0
  43. package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
  44. package/prisma/client/models.ts +34 -0
  45. package/prisma/migrations/20260608000000_init/migration.sql +112 -0
  46. package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
  47. package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
  48. package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
  49. package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
  50. package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
  51. package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
  52. package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
  53. package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
  54. package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
  55. package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
  56. package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
  57. package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
  58. package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
  59. package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
  60. package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
  61. package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
  62. package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
  63. package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
  64. package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
  65. package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
  66. package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
  67. package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
  68. package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
  69. package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
  70. package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
  71. package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
  72. package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
  73. package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
  74. package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
  75. package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
  76. package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
  77. package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
  78. package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
  79. package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
  80. package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
  81. package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
  82. package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
  83. package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
  84. package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
  85. package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
  86. package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
  87. package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
  88. package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
  89. package/prisma/migrations/migration_lock.toml +3 -0
  90. package/prisma/schema/coding.prisma +85 -0
  91. package/prisma/schema/schema.prisma +506 -0
  92. package/prisma.config.ts +34 -0
  93. package/pwa-assets/icons/apple-touch-icon.png +0 -0
  94. package/pwa-assets/icons/favicon-32.png +0 -0
  95. package/pwa-assets/icons/icon-192.png +0 -0
  96. package/pwa-assets/icons/icon-512.png +0 -0
  97. package/pwa-assets/icons/icon-maskable-192.png +0 -0
  98. package/pwa-assets/icons/icon-maskable-512.png +0 -0
  99. package/src/account-agent-create.ts +42 -0
  100. package/src/account-agent-create.unit.test.ts +28 -0
  101. package/src/account-created-notifier.ts +71 -0
  102. package/src/account-created-notifier.unit.test.ts +70 -0
  103. package/src/account-email.ts +9 -0
  104. package/src/account-email.unit.test.ts +21 -0
  105. package/src/account-invites.ts +70 -0
  106. package/src/account-lifecycle.integration.test.ts +329 -0
  107. package/src/account-lifecycle.ts +206 -0
  108. package/src/account-lifecycle.unit.test.ts +108 -0
  109. package/src/account-members.ts +128 -0
  110. package/src/account-onboarding.integration.test.ts +101 -0
  111. package/src/account-onboarding.ts +95 -0
  112. package/src/account-trial-expiry-sweeper.ts +85 -0
  113. package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
  114. package/src/account-trial-warning-sweeper.ts +216 -0
  115. package/src/accounts-api.integration.test.ts +196 -0
  116. package/src/accounts-api.smoke.test.ts +292 -0
  117. package/src/accounts-api.ts +289 -0
  118. package/src/accounts-api.unit.test.ts +93 -0
  119. package/src/accounts.integration.test.ts +178 -0
  120. package/src/accounts.ts +208 -0
  121. package/src/admin-boot-config.ts +574 -0
  122. package/src/admin-boot-config.unit.test.ts +28 -0
  123. package/src/admin-boot.boot-order.unit.test.ts +91 -0
  124. package/src/admin-boot.ts +690 -0
  125. package/src/admin-dev-login.smoke.test.ts +317 -0
  126. package/src/admin-extension.ts +432 -0
  127. package/src/admin-extension.unit.test.ts +532 -0
  128. package/src/admin-local-agent.smoke.test.ts +1643 -0
  129. package/src/admin-spec.smoke.test.ts +96 -0
  130. package/src/admin-spec.ts +186 -0
  131. package/src/admin-ui-account-nav.ts +20 -0
  132. package/src/admin-ui-account-pages.ts +131 -0
  133. package/src/admin-ui-account-pages.unit.test.ts +112 -0
  134. package/src/admin-ui-account.smoke.test.ts +286 -0
  135. package/src/admin-ui-account.ts +185 -0
  136. package/src/admin-ui-accounts-pages.ts +202 -0
  137. package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
  138. package/src/admin-ui-accounts.smoke.test.ts +434 -0
  139. package/src/admin-ui-accounts.ts +266 -0
  140. package/src/admin-ui-layout.ts +60 -0
  141. package/src/admin-ui-layout.unit.test.ts +140 -0
  142. package/src/admin-ui-pages.ts +3489 -0
  143. package/src/admin-ui-pages.unit.test.ts +4355 -0
  144. package/src/admin-ui-push-test.smoke.test.ts +125 -0
  145. package/src/admin-ui-push-test.ts +102 -0
  146. package/src/admin-ui-styles.ts +656 -0
  147. package/src/admin-ui-styles.unit.test.ts +103 -0
  148. package/src/admin-ui.fixtures.ts +457 -0
  149. package/src/admin-ui.smoke.test.ts +7227 -0
  150. package/src/admin-ui.ts +3824 -0
  151. package/src/agent-account-assignment.ts +49 -0
  152. package/src/agent-account-assignment.unit.test.ts +99 -0
  153. package/src/agent-anthropic-identity.integration.test.ts +99 -0
  154. package/src/agent-anthropic-identity.ts +78 -0
  155. package/src/agent-anthropic-identity.unit.test.ts +77 -0
  156. package/src/agent-ask-config.ts +59 -0
  157. package/src/agent-ask-config.unit.test.ts +70 -0
  158. package/src/agent-ask.integration.test.ts +175 -0
  159. package/src/agent-ask.smoke.test.ts +457 -0
  160. package/src/agent-ask.ts +378 -0
  161. package/src/agent-chat-tokens.integration.test.ts +420 -0
  162. package/src/agent-chat-tokens.smoke.test.ts +551 -0
  163. package/src/agent-chat-tokens.ts +259 -0
  164. package/src/agent-cron-jobs.integration.test.ts +582 -0
  165. package/src/agent-cron-jobs.ts +615 -0
  166. package/src/agent-cron-jobs.unit.test.ts +420 -0
  167. package/src/agent-cron-run-stats.integration.test.ts +1551 -0
  168. package/src/agent-cron-run-stats.smoke.test.ts +953 -0
  169. package/src/agent-cron-run-stats.ts +752 -0
  170. package/src/agent-cron-runs.integration.test.ts +1491 -0
  171. package/src/agent-cron-runs.ts +489 -0
  172. package/src/agent-deletion-checklist.ts +128 -0
  173. package/src/agent-deletion-checklist.unit.test.ts +201 -0
  174. package/src/agent-deletion-per-agent.integration.test.ts +323 -0
  175. package/src/agent-deletion.integration.test.ts +323 -0
  176. package/src/agent-deletion.ts +329 -0
  177. package/src/agent-deletion.unit.test.ts +412 -0
  178. package/src/agent-envs.integration.test.ts +323 -0
  179. package/src/agent-envs.ts +244 -0
  180. package/src/agent-github-installations.integration.test.ts +115 -0
  181. package/src/agent-github-installations.ts +52 -0
  182. package/src/agent-manifest.ts +685 -0
  183. package/src/agent-manifest.unit.test.ts +870 -0
  184. package/src/agent-members.ts +69 -0
  185. package/src/agent-members.unit.test.ts +250 -0
  186. package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
  187. package/src/agent-plugins.integration.test.ts +157 -0
  188. package/src/agent-plugins.ts +95 -0
  189. package/src/agent-plugins.unit.test.ts +43 -0
  190. package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
  191. package/src/agent-provisioner.integration.test.ts +1245 -0
  192. package/src/agent-provisioner.ts +1650 -0
  193. package/src/agent-provisioner.unit.test.ts +1852 -0
  194. package/src/agent-tokens.integration.test.ts +150 -0
  195. package/src/agent-tokens.ts +112 -0
  196. package/src/agent-tools.integration.test.ts +129 -0
  197. package/src/agent-tools.ts +111 -0
  198. package/src/agents-api.integration.test.ts +705 -0
  199. package/src/agents-api.sentry.smoke.test.ts +478 -0
  200. package/src/agents-api.smoke.test.ts +5986 -0
  201. package/src/agents-api.ts +2826 -0
  202. package/src/agents.integration.test.ts +512 -0
  203. package/src/agents.ts +1097 -0
  204. package/src/agents.unit.test.ts +1736 -0
  205. package/src/anthropic-credential-guard.ts +44 -0
  206. package/src/anthropic-credential-guard.unit.test.ts +88 -0
  207. package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
  208. package/src/anthropic-federated-token-provider.ts +249 -0
  209. package/src/anthropic-identity-client.fixtures.ts +237 -0
  210. package/src/anthropic-identity-client.integration.test.ts +342 -0
  211. package/src/anthropic-identity-client.ts +473 -0
  212. package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
  213. package/src/anthropic-identity-name.integration.test.ts +83 -0
  214. package/src/anthropic-identity-name.ts +163 -0
  215. package/src/anthropic-identity-name.unit.test.ts +345 -0
  216. package/src/anthropic-openapi-contract.fixtures.ts +192 -0
  217. package/src/api-auth.ts +383 -0
  218. package/src/api-auth.unit.test.ts +837 -0
  219. package/src/api.smoke.test.ts +1225 -0
  220. package/src/api.ts +376 -0
  221. package/src/attachment-validation.ts +124 -0
  222. package/src/attachment-validation.unit.test.ts +218 -0
  223. package/src/caller-scope.ts +131 -0
  224. package/src/caller-scope.unit.test.ts +116 -0
  225. package/src/chat-credential-config.ts +22 -0
  226. package/src/chat-credentials-logging.unit.test.ts +92 -0
  227. package/src/chat-markers.ts +114 -0
  228. package/src/chat-markers.unit.test.ts +116 -0
  229. package/src/chat-service-provisioning-client.integration.test.ts +412 -0
  230. package/src/chat-service-provisioning-client.ts +221 -0
  231. package/src/chat.smoke.test.ts +1875 -0
  232. package/src/clock.ts +47 -0
  233. package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
  234. package/src/coding-task-store-provisioning-client.ts +156 -0
  235. package/src/crypto.ts +83 -0
  236. package/src/dev-auth-guard.ts +29 -0
  237. package/src/dev-auth-guard.unit.test.ts +51 -0
  238. package/src/errors.ts +77 -0
  239. package/src/errors.unit.test.ts +52 -0
  240. package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
  241. package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
  242. package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
  243. package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
  244. package/src/fixtures/github-app-manifest-cassette.json +44 -0
  245. package/src/fixtures/google-auth-cassette.json +36 -0
  246. package/src/fixtures/http-chat-client-cassette.json +226 -0
  247. package/src/fixtures/k8s-cassette.json +470 -0
  248. package/src/fixtures/okta-auth-cassette.json +36 -0
  249. package/src/fixtures/slack-provision-cassette.json +12 -0
  250. package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
  251. package/src/fixtures/task-store-provisioning-cassette.json +85 -0
  252. package/src/github-app-provisioning-client.integration.test.ts +149 -0
  253. package/src/github-app-provisioning-client.ts +161 -0
  254. package/src/github-app-provisioning-client.unit.test.ts +114 -0
  255. package/src/github-provisioning-service.ts +569 -0
  256. package/src/github-provisioning-service.unit.test.ts +123 -0
  257. package/src/google-auth-client.integration.test.ts +195 -0
  258. package/src/google-auth-client.ts +121 -0
  259. package/src/http-chat-client.integration.test.ts +554 -0
  260. package/src/http-chat-client.ts +533 -0
  261. package/src/http-chat-client.unit.test.ts +119 -0
  262. package/src/import-boundary.unit.test.ts +240 -0
  263. package/src/kubernetes-client.integration.test.ts +697 -0
  264. package/src/kubernetes-client.ts +1027 -0
  265. package/src/kubernetes-client.unit.test.ts +726 -0
  266. package/src/main.smoke.test.ts +91 -0
  267. package/src/main.ts +21 -0
  268. package/src/main.unit.test.ts +801 -0
  269. package/src/okta-auth-client.integration.test.ts +263 -0
  270. package/src/okta-auth-client.ts +152 -0
  271. package/src/openapi-schemas.ts +1310 -0
  272. package/src/openapi-schemas.unit.test.ts +348 -0
  273. package/src/per-agent-identity.fixtures.ts +305 -0
  274. package/src/platform-doc-route.ts +40 -0
  275. package/src/prisma-client.integration.test.ts +57 -0
  276. package/src/prisma-client.ts +187 -0
  277. package/src/prisma-client.unit.test.ts +178 -0
  278. package/src/prisma-tx.ts +23 -0
  279. package/src/push-content.ts +177 -0
  280. package/src/push-content.unit.test.ts +213 -0
  281. package/src/push-crypto.ts +135 -0
  282. package/src/push-crypto.unit.test.ts +66 -0
  283. package/src/push-sender.ts +169 -0
  284. package/src/push-sender.unit.test.ts +107 -0
  285. package/src/push-service.ts +332 -0
  286. package/src/push-service.unit.test.ts +679 -0
  287. package/src/push-toggle.ts +135 -0
  288. package/src/push-toggle.unit.test.ts +78 -0
  289. package/src/pwa-https-gating.smoke.test.ts +229 -0
  290. package/src/pwa-icons.unit.test.ts +41 -0
  291. package/src/pwa-routes.smoke.test.ts +276 -0
  292. package/src/pwa.ts +380 -0
  293. package/src/pwa.unit.test.ts +321 -0
  294. package/src/schema.unit.test.ts +122 -0
  295. package/src/self-serve-config.ts +52 -0
  296. package/src/self-serve-config.unit.test.ts +79 -0
  297. package/src/server.smoke.test.ts +273 -0
  298. package/src/server.ts +50 -0
  299. package/src/service-integration.ts +67 -0
  300. package/src/service-integration.unit.test.ts +129 -0
  301. package/src/slack-provisioning-client.integration.test.ts +308 -0
  302. package/src/slack-provisioning-client.ts +411 -0
  303. package/src/slack-provisioning-client.unit.test.ts +80 -0
  304. package/src/slack-provisioning-service.integration.test.ts +637 -0
  305. package/src/slack-provisioning-service.ts +451 -0
  306. package/src/slack-provisioning.integration.test.ts +395 -0
  307. package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
  308. package/src/specialty-manifest-loader.ts +255 -0
  309. package/src/specialty-manifest-loader.unit.test.ts +301 -0
  310. package/src/specialty-registry.schema.unit.test.ts +35 -0
  311. package/src/specialty-registry.ts +236 -0
  312. package/src/specialty-registry.unit.test.ts +272 -0
  313. package/src/startup-policy-reconcile.ts +77 -0
  314. package/src/startup-policy-reconcile.unit.test.ts +251 -0
  315. package/src/theme-tokens.json +12 -0
  316. package/src/token-crypto.ts +60 -0
  317. package/src/token-crypto.unit.test.ts +182 -0
  318. package/src/trial-expiry-sweeper.integration.test.ts +318 -0
  319. package/src/trial-expiry-sweeper.ts +132 -0
  320. package/src/trial-expiry-sweeper.unit.test.ts +391 -0
  321. package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
  322. package/src/trial-expiry-warning-sweeper.ts +447 -0
  323. package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
  324. package/src/voice-contract.unit.test.ts +102 -0
  325. package/tsconfig.json +9 -0
@@ -0,0 +1,679 @@
1
+ /**
2
+ * admin/src/push-service.unit.test.ts
3
+ * Unit tests for PushService orchestration: watcher → subscription lookup,
4
+ * per-subscription detail resolution, fan-out, and immediate pruning of the
5
+ * subscriptions the sender reports gone. All doubles are injected (no globals,
6
+ * no real Prisma, no real network).
7
+ */
8
+
9
+ import { describe, expect, it } from "bun:test";
10
+ import { buildSessionNotificationPayload } from "./push-content.ts";
11
+ import type { PushSubscriptionLike } from "./push-sender.ts";
12
+ import { PushService } from "./push-service.ts";
13
+
14
+ const vapid = {
15
+ publicKey:
16
+ "BAl-hYOXgpZwx-qqRoAapu5iSeHrlcJdy89wjv1NyctfsYSyGKCVO97vvlOVR4h61MROU8CZLAxzQEj8vIFsTaI",
17
+ privateKey: "Sl9AT1gAl_yFgHlTToZkarQRWNYHVTAviDf5osjxXN0",
18
+ subject: "mailto:ops@example.com",
19
+ };
20
+
21
+ const P256DH =
22
+ "BCVxsr7N_eNgVRqvHtD0zTZsEc6-VV-JvLexhqUzORcxaOzi6-AYWXvTBHm4bjyPjs7Vd8pZGH6SRpkNtoIAiw4";
23
+ const AUTH = "BTBZMqHH6r4Tts7J_aSIgg";
24
+
25
+ interface Row {
26
+ id: string;
27
+ userEmail: string;
28
+ endpoint: string;
29
+ p256dh: string;
30
+ auth: string;
31
+ detailOptIn: string;
32
+ }
33
+
34
+ function fakePrisma(watchers: string[], subs: Row[]) {
35
+ const deleted: string[] = [];
36
+ const prisma = {
37
+ chatThreadWatch: {
38
+ findMany: async () => watchers.map((email) => ({ userEmail: email })),
39
+ },
40
+ pushSubscription: {
41
+ findMany: async ({ where }: { where: { userEmail: { in: string[] } } }) =>
42
+ subs.filter((s) => where.userEmail.in.includes(s.userEmail)),
43
+ deleteMany: async ({
44
+ where,
45
+ }: { where: { endpoint: { in: string[] } } }) => {
46
+ deleted.push(...where.endpoint.in);
47
+ return { count: where.endpoint.in.length };
48
+ },
49
+ },
50
+ };
51
+ return { prisma, deleted };
52
+ }
53
+
54
+ describe("PushService.notifyThreadReply", () => {
55
+ it("fans out to every watcher's subscription and prunes the gone ones", async () => {
56
+ const { prisma, deleted } = fakePrisma(
57
+ ["a@x.com", "b@x.com"],
58
+ [
59
+ {
60
+ id: "1",
61
+ userEmail: "a@x.com",
62
+ endpoint: "https://push/ok",
63
+ p256dh: P256DH,
64
+ auth: AUTH,
65
+ detailOptIn: "title",
66
+ },
67
+ {
68
+ id: "2",
69
+ userEmail: "b@x.com",
70
+ endpoint: "https://push/gone",
71
+ p256dh: P256DH,
72
+ auth: AUTH,
73
+ detailOptIn: "generic",
74
+ },
75
+ ],
76
+ );
77
+ const sent: Array<{ sub: PushSubscriptionLike; payload: string }> = [];
78
+ const fetchImpl = (async (url: string) => {
79
+ sent.push({
80
+ sub: { endpoint: url } as PushSubscriptionLike,
81
+ payload: "",
82
+ });
83
+ return new Response(null, {
84
+ status: url.endsWith("/gone") ? 410 : 201,
85
+ });
86
+ }) as unknown as typeof fetch;
87
+
88
+ const service = new PushService(prisma as never, vapid, fetchImpl, "title");
89
+ const result = await service.notifyThreadReply({
90
+ threadId: "thr_1",
91
+ agentId: "agt_1",
92
+ title: "Fix the thing",
93
+ preview: "the reply text",
94
+ });
95
+
96
+ expect(sent.length).toBe(2);
97
+ expect(result.delivered).toBe(1);
98
+ expect(deleted).toEqual(["https://push/gone"]);
99
+ });
100
+
101
+ it("is a no-op (no send) when the thread has no watchers", async () => {
102
+ const { prisma, deleted } = fakePrisma([], []);
103
+ let called = 0;
104
+ const fetchImpl = (async () => {
105
+ called += 1;
106
+ return new Response(null, { status: 201 });
107
+ }) as unknown as typeof fetch;
108
+ const service = new PushService(prisma as never, vapid, fetchImpl, "title");
109
+ const result = await service.notifyThreadReply({
110
+ threadId: "thr_x",
111
+ agentId: "agt_x",
112
+ title: "t",
113
+ preview: "p",
114
+ });
115
+ expect(called).toBe(0);
116
+ expect(result.delivered).toBe(0);
117
+ expect(deleted).toEqual([]);
118
+ });
119
+
120
+ it("caps each subscription's detail at the operator ceiling", async () => {
121
+ // Ceiling = generic; a subscription opted into preview still gets generic.
122
+ const { prisma } = fakePrisma(
123
+ ["a@x.com"],
124
+ [
125
+ {
126
+ id: "1",
127
+ userEmail: "a@x.com",
128
+ endpoint: "https://push/ok",
129
+ p256dh: P256DH,
130
+ auth: AUTH,
131
+ detailOptIn: "preview",
132
+ },
133
+ ],
134
+ );
135
+ const bodies: string[] = [];
136
+ const fetchImpl = (async (_url: string, init: RequestInit) => {
137
+ // Decrypt isn't feasible here; assert instead via the effective level the
138
+ // service exposes for auditing.
139
+ bodies.push(String(init.method));
140
+ return new Response(null, { status: 201 });
141
+ }) as unknown as typeof fetch;
142
+ const service = new PushService(
143
+ prisma as never,
144
+ vapid,
145
+ fetchImpl,
146
+ "generic",
147
+ );
148
+ const levels = await service.resolveLevelsForThread("thr_1");
149
+ expect(levels).toEqual(["generic"]);
150
+ });
151
+ });
152
+
153
+ describe("PushService.sendToUsers", () => {
154
+ it("fans out to given emails' subscriptions, groups by detail level, sends, and prunes gone endpoints", async () => {
155
+ const { prisma, deleted } = fakePrisma(
156
+ [],
157
+ [
158
+ {
159
+ id: "1",
160
+ userEmail: "a@x.com",
161
+ endpoint: "https://push/ok",
162
+ p256dh: P256DH,
163
+ auth: AUTH,
164
+ detailOptIn: "title",
165
+ },
166
+ {
167
+ id: "2",
168
+ userEmail: "b@x.com",
169
+ endpoint: "https://push/gone",
170
+ p256dh: P256DH,
171
+ auth: AUTH,
172
+ detailOptIn: "generic",
173
+ },
174
+ ],
175
+ );
176
+ const sent: Array<{ url: string; payload: unknown }> = [];
177
+ const fetchImpl = (async (url: string) => {
178
+ sent.push({ url, payload: "" });
179
+ return new Response(null, {
180
+ status: url.endsWith("/gone") ? 410 : 201,
181
+ });
182
+ }) as unknown as typeof fetch;
183
+
184
+ const service = new PushService(prisma as never, vapid, fetchImpl, "title");
185
+ const result = await service.sendToUsers(["a@x.com", "b@x.com"], (level) =>
186
+ JSON.stringify({ level }),
187
+ );
188
+
189
+ expect(sent.length).toBe(2);
190
+ expect(result.delivered).toBe(1);
191
+ expect(result.pruned).toBe(1);
192
+ expect(deleted).toEqual(["https://push/gone"]);
193
+ });
194
+
195
+ it("returns { delivered: 0, pruned: 0 } when no subscriptions match", async () => {
196
+ const { prisma } = fakePrisma([], []);
197
+ let called = 0;
198
+ const fetchImpl = (async () => {
199
+ called += 1;
200
+ return new Response(null, { status: 201 });
201
+ }) as unknown as typeof fetch;
202
+ const service = new PushService(prisma as never, vapid, fetchImpl, "title");
203
+ const result = await service.sendToUsers(["nobody@x.com"], () => "{}");
204
+
205
+ expect(called).toBe(0);
206
+ expect(result).toEqual({ delivered: 0, pruned: 0 });
207
+ });
208
+ });
209
+
210
+ describe("PushService.notifySession", () => {
211
+ it("resolves subscriptions for the emails on the session object and delivers via the extracted core", async () => {
212
+ const { prisma, deleted } = fakePrisma(
213
+ [],
214
+ [
215
+ {
216
+ id: "1",
217
+ userEmail: "a@x.com",
218
+ endpoint: "https://push/ok",
219
+ p256dh: P256DH,
220
+ auth: AUTH,
221
+ detailOptIn: "title",
222
+ },
223
+ ],
224
+ );
225
+ const sent: string[] = [];
226
+ const fetchImpl = (async (url: string) => {
227
+ sent.push(url);
228
+ return new Response(null, { status: 201 });
229
+ }) as unknown as typeof fetch;
230
+ const service = new PushService(prisma as never, vapid, fetchImpl, "title");
231
+
232
+ const result = await service.notifySession(
233
+ { slug: "sesh_1", emails: ["a@x.com"] },
234
+ "generic",
235
+ "immediate",
236
+ );
237
+
238
+ expect(sent).toEqual(["https://push/ok"]);
239
+ expect(result).toEqual({ delivered: 1, pruned: 0 });
240
+ expect(deleted).toEqual([]);
241
+ });
242
+
243
+ it("includes the session deep-link url in the payload it sends", async () => {
244
+ const { prisma } = fakePrisma(
245
+ [],
246
+ [
247
+ {
248
+ id: "1",
249
+ userEmail: "a@x.com",
250
+ endpoint: "https://push/ok",
251
+ p256dh: P256DH,
252
+ auth: AUTH,
253
+ detailOptIn: "title",
254
+ },
255
+ ],
256
+ );
257
+ const fetchImpl = (async () =>
258
+ new Response(null, { status: 201 })) as unknown as typeof fetch;
259
+ const service = new PushService(prisma as never, vapid, fetchImpl, "title");
260
+
261
+ // The encrypted push body isn't inspectable end-to-end, so capture the
262
+ // payload builder notifySession hands to the extracted core — same
263
+ // workaround the level-resolution tests below use.
264
+ const built: string[] = [];
265
+ const realSendToUsers = service.sendToUsers.bind(service);
266
+ service.sendToUsers = (emails, buildPayload) =>
267
+ realSendToUsers(emails, (subLevel) => {
268
+ const payload = buildPayload(subLevel);
269
+ built.push(payload);
270
+ return payload;
271
+ });
272
+
273
+ await service.notifySession(
274
+ { slug: "sesh_1", emails: ["a@x.com"] },
275
+ "generic",
276
+ "immediate",
277
+ );
278
+
279
+ expect(built).toHaveLength(1);
280
+ expect(JSON.parse(built[0] ?? "{}").url).toBe("/admin/sessions/sesh_1");
281
+ });
282
+
283
+ it("returns { delivered: 0, pruned: 0 } when the session has no emails", async () => {
284
+ const { prisma } = fakePrisma([], []);
285
+ let called = 0;
286
+ const fetchImpl = (async () => {
287
+ called += 1;
288
+ return new Response(null, { status: 201 });
289
+ }) as unknown as typeof fetch;
290
+ const service = new PushService(prisma as never, vapid, fetchImpl, "title");
291
+
292
+ const result = await service.notifySession(
293
+ { slug: "sesh_1", emails: [] },
294
+ "generic",
295
+ "reminder",
296
+ );
297
+
298
+ expect(called).toBe(0);
299
+ expect(result).toEqual({ delivered: 0, pruned: 0 });
300
+ });
301
+
302
+ it("builds each payload at min(caller level, operator ceiling, subscription opt-in)", async () => {
303
+ const { prisma } = fakePrisma(
304
+ [],
305
+ [
306
+ {
307
+ id: "1",
308
+ userEmail: "a@x.com",
309
+ endpoint: "https://push/ok",
310
+ p256dh: P256DH,
311
+ auth: AUTH,
312
+ detailOptIn: "preview",
313
+ },
314
+ ],
315
+ );
316
+ const fetchImpl = (async () =>
317
+ new Response(null, { status: 201 })) as unknown as typeof fetch;
318
+ // Operator ceiling = title, so every subscription resolves to at most
319
+ // "title" before the caller's own ceiling is applied.
320
+ const service = new PushService(prisma as never, vapid, fetchImpl, "title");
321
+
322
+ // The encrypted push body isn't inspectable, so capture the payload
323
+ // builder notifySession hands to the extracted core and exercise it with
324
+ // the per-subscription levels sendToUsers would resolve.
325
+ const built: string[] = [];
326
+ const realSendToUsers = service.sendToUsers.bind(service);
327
+ service.sendToUsers = (emails, buildPayload) =>
328
+ realSendToUsers(emails, (subLevel) => {
329
+ const payload = buildPayload(subLevel);
330
+ built.push(payload);
331
+ return payload;
332
+ });
333
+
334
+ await service.notifySession(
335
+ { slug: "sesh_1", title: "Something happened", emails: ["a@x.com"] },
336
+ "generic",
337
+ "immediate",
338
+ );
339
+
340
+ // Subscription opted into "preview", operator ceiling is "title", caller
341
+ // asked for "generic" — the lowest of the three wins. Comparing against
342
+ // buildSessionNotificationPayload's own output (rather than hand-rolling
343
+ // the expected JSON) proves the resolved level, not just some payload,
344
+ // reached the content builder — a title-level `body` would be non-empty here.
345
+ expect(built).toEqual([
346
+ JSON.stringify(
347
+ buildSessionNotificationPayload("generic", "immediate", {
348
+ slug: "sesh_1",
349
+ title: "Something happened",
350
+ }),
351
+ ),
352
+ ]);
353
+ });
354
+
355
+ it("never exceeds the per-subscription level even when the caller asks for more detail", async () => {
356
+ const { prisma } = fakePrisma(
357
+ [],
358
+ [
359
+ {
360
+ id: "1",
361
+ userEmail: "a@x.com",
362
+ endpoint: "https://push/ok",
363
+ p256dh: P256DH,
364
+ auth: AUTH,
365
+ detailOptIn: "generic",
366
+ },
367
+ {
368
+ id: "2",
369
+ userEmail: "b@x.com",
370
+ endpoint: "https://push/ok2",
371
+ p256dh: P256DH,
372
+ auth: AUTH,
373
+ detailOptIn: "preview",
374
+ },
375
+ ],
376
+ );
377
+ const fetchImpl = (async () =>
378
+ new Response(null, { status: 201 })) as unknown as typeof fetch;
379
+ const service = new PushService(prisma as never, vapid, fetchImpl, "title");
380
+
381
+ const built: string[] = [];
382
+ const realSendToUsers = service.sendToUsers.bind(service);
383
+ service.sendToUsers = (emails, buildPayload) =>
384
+ realSendToUsers(emails, (subLevel) => {
385
+ const payload = buildPayload(subLevel);
386
+ built.push(payload);
387
+ return payload;
388
+ });
389
+
390
+ await service.notifySession(
391
+ {
392
+ slug: "sesh_1",
393
+ title: "Something happened",
394
+ emails: ["a@x.com", "b@x.com"],
395
+ },
396
+ "preview",
397
+ "reminder",
398
+ );
399
+
400
+ // Per-subscriber, not one payload for everyone: the opted-out subscriber
401
+ // stays at "generic" while the opted-in one is still capped at the
402
+ // operator ceiling ("title"), never the requested "preview". Comparing
403
+ // against buildSessionNotificationPayload's own output at each expected
404
+ // level (rather than a `level` field the new payload shape doesn't have)
405
+ // proves the resolution — the two levels produce visibly different
406
+ // `body` values given a non-empty title.
407
+ expect(built).toEqual([
408
+ JSON.stringify(
409
+ buildSessionNotificationPayload("generic", "reminder", {
410
+ slug: "sesh_1",
411
+ title: "Something happened",
412
+ }),
413
+ ),
414
+ JSON.stringify(
415
+ buildSessionNotificationPayload("title", "reminder", {
416
+ slug: "sesh_1",
417
+ title: "Something happened",
418
+ }),
419
+ ),
420
+ ]);
421
+ });
422
+
423
+ it("swallows a downstream failure and never throws into the caller", async () => {
424
+ const prisma = {
425
+ chatThreadWatch: { findMany: async () => [] },
426
+ pushSubscription: {
427
+ findMany: async () => {
428
+ throw new Error("db down");
429
+ },
430
+ deleteMany: async () => ({ count: 0 }),
431
+ },
432
+ };
433
+ const fetchImpl = (async () =>
434
+ new Response(null, { status: 201 })) as unknown as typeof fetch;
435
+ const service = new PushService(prisma as never, vapid, fetchImpl, "title");
436
+
437
+ await expect(
438
+ service.notifySession(
439
+ { slug: "sesh_1", emails: ["a@x.com"] },
440
+ "generic",
441
+ "completed",
442
+ ),
443
+ ).resolves.toEqual({ delivered: 0, pruned: 0 });
444
+ });
445
+ });
446
+
447
+ // A fetchImpl is required by the PushService constructor but unused by the
448
+ // write-path methods under test below (recordWatch/subscribe/unsubscribe
449
+ // never touch PushSender).
450
+ const unusedFetch = (async () =>
451
+ new Response(null, { status: 201 })) as unknown as typeof fetch;
452
+
453
+ describe("PushService.recordWatch", () => {
454
+ it("upserts a ChatThreadWatch row scoped to userEmail+threadId", async () => {
455
+ const calls: unknown[] = [];
456
+ const prisma = {
457
+ chatThreadWatch: {
458
+ findMany: async () => [],
459
+ upsert: async (args: unknown) => {
460
+ calls.push(args);
461
+ return { id: "w1" };
462
+ },
463
+ },
464
+ pushSubscription: {
465
+ findMany: async () => [],
466
+ deleteMany: async () => ({ count: 0 }),
467
+ },
468
+ };
469
+ const service = new PushService(
470
+ prisma as never,
471
+ vapid,
472
+ unusedFetch,
473
+ "title",
474
+ );
475
+ await service.recordWatch("a@x.com", "agt_1", "thr_1");
476
+
477
+ expect(calls).toEqual([
478
+ {
479
+ where: {
480
+ userEmail_threadId: { userEmail: "a@x.com", threadId: "thr_1" },
481
+ },
482
+ create: { userEmail: "a@x.com", threadId: "thr_1", agentId: "agt_1" },
483
+ update: { agentId: "agt_1" },
484
+ },
485
+ ]);
486
+ });
487
+
488
+ it("is a no-op when the chatThreadWatch model is absent (migration not applied)", async () => {
489
+ const prisma = {
490
+ pushSubscription: {
491
+ findMany: async () => [],
492
+ deleteMany: async () => ({ count: 0 }),
493
+ },
494
+ };
495
+ const service = new PushService(
496
+ prisma as never,
497
+ vapid,
498
+ unusedFetch,
499
+ "title",
500
+ );
501
+ await expect(
502
+ service.recordWatch("a@x.com", "agt_1", "thr_1"),
503
+ ).resolves.toBeUndefined();
504
+ });
505
+
506
+ it("swallows an upsert failure and never throws into the caller", async () => {
507
+ const prisma = {
508
+ chatThreadWatch: {
509
+ findMany: async () => [],
510
+ upsert: async () => {
511
+ throw new Error("db down");
512
+ },
513
+ },
514
+ pushSubscription: {
515
+ findMany: async () => [],
516
+ deleteMany: async () => ({ count: 0 }),
517
+ },
518
+ };
519
+ const service = new PushService(
520
+ prisma as never,
521
+ vapid,
522
+ unusedFetch,
523
+ "title",
524
+ );
525
+ await expect(
526
+ service.recordWatch("a@x.com", "agt_1", "thr_1"),
527
+ ).resolves.toBeUndefined();
528
+ });
529
+ });
530
+
531
+ describe("PushService.subscribe", () => {
532
+ it("upserts the subscription and returns ok:true", async () => {
533
+ const calls: unknown[] = [];
534
+ const prisma = {
535
+ chatThreadWatch: { findMany: async () => [] },
536
+ pushSubscription: {
537
+ findMany: async () => [],
538
+ deleteMany: async () => ({ count: 0 }),
539
+ upsert: async (args: unknown) => {
540
+ calls.push(args);
541
+ return { id: "sub_1" };
542
+ },
543
+ },
544
+ };
545
+ const service = new PushService(
546
+ prisma as never,
547
+ vapid,
548
+ unusedFetch,
549
+ "title",
550
+ );
551
+ const result = await service.subscribe(
552
+ "a@x.com",
553
+ "https://push/ok",
554
+ P256DH,
555
+ AUTH,
556
+ );
557
+
558
+ expect(result).toEqual({ ok: true });
559
+ expect(calls).toEqual([
560
+ {
561
+ where: { endpoint: "https://push/ok" },
562
+ create: {
563
+ userEmail: "a@x.com",
564
+ endpoint: "https://push/ok",
565
+ p256dh: P256DH,
566
+ auth: AUTH,
567
+ },
568
+ update: { userEmail: "a@x.com", p256dh: P256DH, auth: AUTH },
569
+ },
570
+ ]);
571
+ });
572
+
573
+ it("returns reason:unavailable when the pushSubscription model is absent", async () => {
574
+ const prisma = {
575
+ chatThreadWatch: { findMany: async () => [] },
576
+ };
577
+ const service = new PushService(
578
+ prisma as never,
579
+ vapid,
580
+ unusedFetch,
581
+ "title",
582
+ );
583
+ const result = await service.subscribe(
584
+ "a@x.com",
585
+ "https://push/ok",
586
+ P256DH,
587
+ AUTH,
588
+ );
589
+ expect(result).toEqual({ ok: false, reason: "unavailable" });
590
+ });
591
+
592
+ it("returns reason:store_failed when the upsert throws", async () => {
593
+ const prisma = {
594
+ chatThreadWatch: { findMany: async () => [] },
595
+ pushSubscription: {
596
+ findMany: async () => [],
597
+ deleteMany: async () => ({ count: 0 }),
598
+ upsert: async () => {
599
+ throw new Error("db down");
600
+ },
601
+ },
602
+ };
603
+ const service = new PushService(
604
+ prisma as never,
605
+ vapid,
606
+ unusedFetch,
607
+ "title",
608
+ );
609
+ const result = await service.subscribe(
610
+ "a@x.com",
611
+ "https://push/ok",
612
+ P256DH,
613
+ AUTH,
614
+ );
615
+ expect(result).toEqual({ ok: false, reason: "store_failed" });
616
+ });
617
+ });
618
+
619
+ describe("PushService.unsubscribe", () => {
620
+ it("deletes the subscription scoped to endpoint+userEmail and returns ok:true", async () => {
621
+ const calls: unknown[] = [];
622
+ const prisma = {
623
+ chatThreadWatch: { findMany: async () => [] },
624
+ pushSubscription: {
625
+ findMany: async () => [],
626
+ deleteMany: async (args: unknown) => {
627
+ calls.push(args);
628
+ return { count: 1 };
629
+ },
630
+ },
631
+ };
632
+ const service = new PushService(
633
+ prisma as never,
634
+ vapid,
635
+ unusedFetch,
636
+ "title",
637
+ );
638
+ const result = await service.unsubscribe("a@x.com", "https://push/ok");
639
+
640
+ expect(result).toEqual({ ok: true });
641
+ expect(calls).toEqual([
642
+ { where: { endpoint: "https://push/ok", userEmail: "a@x.com" } },
643
+ ]);
644
+ });
645
+
646
+ it("returns reason:unavailable when the pushSubscription model is absent", async () => {
647
+ const prisma = {
648
+ chatThreadWatch: { findMany: async () => [] },
649
+ };
650
+ const service = new PushService(
651
+ prisma as never,
652
+ vapid,
653
+ unusedFetch,
654
+ "title",
655
+ );
656
+ const result = await service.unsubscribe("a@x.com", "https://push/ok");
657
+ expect(result).toEqual({ ok: false, reason: "unavailable" });
658
+ });
659
+
660
+ it("swallows a deleteMany failure and still returns ok:true", async () => {
661
+ const prisma = {
662
+ chatThreadWatch: { findMany: async () => [] },
663
+ pushSubscription: {
664
+ findMany: async () => [],
665
+ deleteMany: async () => {
666
+ throw new Error("db down");
667
+ },
668
+ },
669
+ };
670
+ const service = new PushService(
671
+ prisma as never,
672
+ vapid,
673
+ unusedFetch,
674
+ "title",
675
+ );
676
+ const result = await service.unsubscribe("a@x.com", "https://push/ok");
677
+ expect(result).toEqual({ ok: true });
678
+ });
679
+ });