@app-vitals/agent-admin 0.0.0-stage → 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 +64 -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,1875 @@
1
+ /**
2
+ * admin/src/chat.smoke.test.ts
3
+ * Smoke tests for the Admin Chat UI routes.
4
+ *
5
+ * Uses app.request() — no real server, no real network calls.
6
+ * ChatClient is injected as an in-memory test double.
7
+ */
8
+
9
+ import { beforeAll, describe, expect, it } from "bun:test";
10
+ import { sign } from "hono/jwt";
11
+ import type {
12
+ AdminUIDeps,
13
+ AdminUIGithubAppClient,
14
+ AdminUISlackClient,
15
+ } from "./admin-ui.ts";
16
+ import { createAdminUIApp } from "./admin-ui.ts";
17
+ import type {
18
+ GoogleAuthClient,
19
+ GoogleTokenResponse,
20
+ GoogleUserInfo,
21
+ } from "./google-auth-client.ts";
22
+ import type {
23
+ ChatClient,
24
+ ChatMessage,
25
+ ChatThread,
26
+ ThreadStats,
27
+ } from "./http-chat-client.ts";
28
+ import type { PushService } from "./push-service.ts";
29
+
30
+ // ─── Constants ────────────────────────────────────────────────────────────────
31
+
32
+ const SESSION_SECRET = "test-admin-session-secret-32-bytes!";
33
+ const GOOGLE_CLIENT_ID = "test-google-client-id";
34
+ const GOOGLE_CLIENT_SECRET = "test-google-client-secret";
35
+ const ADMIN_ALLOWED_EMAILS = ["admin@example.com"];
36
+ const AGENT_ID = "agent-test-chat-123";
37
+ const THREAD_ID = "thread-test-456";
38
+
39
+ // ─── Fixtures ─────────────────────────────────────────────────────────────────
40
+
41
+ const MOCK_THREAD: ChatThread = {
42
+ id: THREAD_ID,
43
+ agentId: AGENT_ID,
44
+ title: "Test Thread",
45
+ memberId: null,
46
+ createdAt: "2024-01-01T00:00:00.000Z",
47
+ updatedAt: "2024-01-01T00:00:00.000Z",
48
+ };
49
+
50
+ const MOCK_MESSAGE: ChatMessage = {
51
+ id: "msg-test-789",
52
+ threadId: THREAD_ID,
53
+ role: "user",
54
+ body: "Hello, agent!",
55
+ createdAt: "2024-01-01T00:00:00.000Z",
56
+ claimedBy: null,
57
+ repliedAt: null,
58
+ tokens: null,
59
+ costUsd: null,
60
+ errorKind: null,
61
+ attachmentFilename: null,
62
+ attachmentSize: null,
63
+ };
64
+
65
+ const MOCK_ASSISTANT_MESSAGE: ChatMessage = {
66
+ id: "msg-test-asst-101",
67
+ threadId: THREAD_ID,
68
+ role: "assistant",
69
+ body: "Here is **bold text** and `inline code`.",
70
+ createdAt: "2024-01-01T00:01:00.000Z",
71
+ claimedBy: null,
72
+ repliedAt: "2024-01-01T00:01:05.000Z",
73
+ tokens: {
74
+ input_tokens: 50,
75
+ output_tokens: 30,
76
+ cache_read_input_tokens: 0,
77
+ cache_creation_input_tokens: 0,
78
+ },
79
+ costUsd: 0.0005,
80
+ errorKind: null,
81
+ attachmentFilename: null,
82
+ attachmentSize: null,
83
+ };
84
+
85
+ const MOCK_ERROR_MESSAGE: ChatMessage = {
86
+ id: "msg-test-err-202",
87
+ threadId: THREAD_ID,
88
+ role: "assistant",
89
+ body: "",
90
+ createdAt: "2024-01-01T00:02:00.000Z",
91
+ claimedBy: null,
92
+ repliedAt: null,
93
+ tokens: null,
94
+ costUsd: null,
95
+ errorKind: "rate-limited",
96
+ attachmentFilename: null,
97
+ attachmentSize: null,
98
+ };
99
+
100
+ // ─── JWT helper ───────────────────────────────────────────────────────────────
101
+
102
+ async function makeSessionCookie(
103
+ secret = SESSION_SECRET,
104
+ userId = "google-sub-123",
105
+ email = "admin@example.com",
106
+ isAdmin = true,
107
+ ): Promise<string> {
108
+ return sign(
109
+ {
110
+ userId,
111
+ email,
112
+ isAdmin,
113
+ iat: Math.floor(Date.now() / 1000),
114
+ exp: Math.floor(Date.now() / 1000) + 3600,
115
+ },
116
+ secret,
117
+ "HS256",
118
+ );
119
+ }
120
+
121
+ // ─── Mock ChatClient ──────────────────────────────────────────────────────────
122
+
123
+ const MOCK_THREAD_STATS: ThreadStats = {
124
+ messageCount: 1,
125
+ totalInputTokens: 0,
126
+ totalOutputTokens: 0,
127
+ totalCostUsd: 0,
128
+ };
129
+
130
+ function makeMockChatClient(overrides?: Partial<ChatClient>): ChatClient {
131
+ return {
132
+ listThreads: async () => ({
133
+ threads: [MOCK_THREAD],
134
+ total: 1,
135
+ limit: 50,
136
+ offset: 0,
137
+ }),
138
+ getThread: async () => MOCK_THREAD,
139
+ createThread: async () => MOCK_THREAD,
140
+ updateThread: async (_id: string, data: { title?: string }) => ({
141
+ ...MOCK_THREAD,
142
+ title: data.title ?? MOCK_THREAD.title,
143
+ }),
144
+ deleteThread: async () => {},
145
+ listMessages: async () => ({
146
+ messages: [MOCK_MESSAGE],
147
+ total: 1,
148
+ limit: 50,
149
+ offset: 0,
150
+ }),
151
+ createMessage: async () => MOCK_MESSAGE,
152
+ getThreadStats: async () => MOCK_THREAD_STATS,
153
+ getAttachment: async () => null,
154
+ ...overrides,
155
+ };
156
+ }
157
+
158
+ // ─── Mock Google client ───────────────────────────────────────────────────────
159
+
160
+ function makeGoogleClient(): GoogleAuthClient {
161
+ return {
162
+ exchangeCode: () =>
163
+ Promise.resolve({
164
+ accessToken: "test-access-token",
165
+ refreshToken: "test-refresh-token",
166
+ expiresIn: 3600,
167
+ } as GoogleTokenResponse),
168
+ getUserInfo: () =>
169
+ Promise.resolve({
170
+ sub: "google-sub-123",
171
+ email: "admin@example.com",
172
+ email_verified: true,
173
+ name: "Admin User",
174
+ } as GoogleUserInfo),
175
+ };
176
+ }
177
+
178
+ const MOCK_CRON = {
179
+ id: "cron-1",
180
+ agentId: AGENT_ID,
181
+ schedule: "0 * * * *",
182
+ prompt: "check",
183
+ channel: null,
184
+ user: null,
185
+ enabled: true,
186
+ name: null,
187
+ system: false,
188
+ parentCronId: null,
189
+ lockdownDisabledAt: null,
190
+ silent: false,
191
+ preCheck: null,
192
+ createdAt: new Date("2024-01-01"),
193
+ updatedAt: new Date("2024-01-01"),
194
+ };
195
+
196
+ const MOCK_TOOL = {
197
+ id: "tool-1",
198
+ agentId: AGENT_ID,
199
+ pattern: "Bash(git:*)",
200
+ enabled: true,
201
+ createdAt: new Date("2024-01-01"),
202
+ updatedAt: new Date("2024-01-01"),
203
+ };
204
+
205
+ const MOCK_TOKEN = {
206
+ id: "token-1",
207
+ agentId: AGENT_ID,
208
+ token: "hashed",
209
+ label: "CI",
210
+ createdAt: new Date("2024-01-01"),
211
+ revokedAt: null,
212
+ };
213
+
214
+ // ─── Shared base deps factory ─────────────────────────────────────────────────
215
+
216
+ const BASE_SLACK_CLIENT: AdminUISlackClient = {
217
+ createAppManifest: async () => ({
218
+ appId: "A123",
219
+ oauthRedirectUrl: "https://slack.com/oauth",
220
+ clientId: "cid",
221
+ clientSecret: "csecret",
222
+ signingSecret: "ssecret",
223
+ }),
224
+ updateAppManifest: async () => {},
225
+ exchangeOAuthCode: async () => ({ botToken: "xoxb-mock" }),
226
+ authTest: async () => ({ userId: "U0AALR8M69X" }),
227
+ };
228
+
229
+ const BASE_GITHUB_APP_CLIENT: AdminUIGithubAppClient = {
230
+ exchangeManifestCode: async () => ({
231
+ appId: "999111",
232
+ slug: "test-shipwright-agent",
233
+ pem: "-----BEGIN RSA PRIVATE KEY-----\nmock\n-----END RSA PRIVATE KEY-----",
234
+ clientId: "gh-app-client-id",
235
+ clientSecret: "gh-app-client-secret",
236
+ }),
237
+ };
238
+
239
+ function makeBaseDeps(overrides?: Partial<AdminUIDeps>): AdminUIDeps {
240
+ return {
241
+ prisma: {
242
+ agent: {
243
+ findMany: async () => [
244
+ {
245
+ id: AGENT_ID,
246
+ name: "Test Agent",
247
+ slackId: null,
248
+ createdAt: new Date("2024-01-01"),
249
+ },
250
+ ],
251
+ findUnique: async () => ({
252
+ id: AGENT_ID,
253
+ name: "Test Agent",
254
+ slackId: null,
255
+ selfHosted: false,
256
+ createdAt: new Date("2024-01-01"),
257
+ updatedAt: new Date("2024-01-01"),
258
+ repos: [],
259
+ }),
260
+ create: async () => ({
261
+ id: AGENT_ID,
262
+ name: "Test Agent",
263
+ slackId: null,
264
+ selfHosted: false,
265
+ createdAt: new Date("2024-01-01"),
266
+ updatedAt: new Date("2024-01-01"),
267
+ repos: [],
268
+ }),
269
+ update: async () => ({
270
+ id: AGENT_ID,
271
+ name: "Test Agent",
272
+ slackId: null,
273
+ selfHosted: false,
274
+ createdAt: new Date("2024-01-01"),
275
+ updatedAt: new Date("2024-01-01"),
276
+ repos: [],
277
+ }),
278
+ delete: async () => ({
279
+ id: AGENT_ID,
280
+ name: "Test Agent",
281
+ slackId: null,
282
+ selfHosted: false,
283
+ createdAt: new Date("2024-01-01"),
284
+ updatedAt: new Date("2024-01-01"),
285
+ repos: [],
286
+ }),
287
+ },
288
+ agentEnv: { findMany: async () => [] },
289
+ agentPlugin: { findMany: async () => [] },
290
+ agentMember: {
291
+ findMany: async () => [],
292
+ findUnique: async () => null,
293
+ create: async () => ({ id: "m1", agentId: AGENT_ID, email: "x@x.com" }),
294
+ deleteMany: async () => ({ count: 0 }),
295
+ },
296
+ },
297
+ agentEnvService: {
298
+ getByAgentId: async () => ({ env: {}, secretKeys: [] }),
299
+ upsert: async () => {},
300
+ patch: async () => {},
301
+ deleteKey: async () => {},
302
+ getConfigBundle: async () => null,
303
+ },
304
+ agentCronJobService: {
305
+ list: async () => [MOCK_CRON],
306
+ listWithRunSummary: async () => [
307
+ { ...MOCK_CRON, lastRun: null, runCountToday: 0 },
308
+ ],
309
+ get: async () => MOCK_CRON,
310
+ create: async () => MOCK_CRON,
311
+ setEnabled: async () => MOCK_CRON,
312
+ update: async () => MOCK_CRON,
313
+ delete: async () => {},
314
+ reconcileSystemCrons: async () => ({
315
+ created: 0,
316
+ updated: 0,
317
+ deleted: 0,
318
+ }),
319
+ },
320
+ agentToolService: {
321
+ list: async () => [MOCK_TOOL],
322
+ add: async () => MOCK_TOOL,
323
+ toggle: async () => MOCK_TOOL,
324
+ remove: async () => {},
325
+ },
326
+ agentTokenService: {
327
+ listForAgent: async () => [MOCK_TOKEN],
328
+ create: async () => ({ token: MOCK_TOKEN, rawToken: "sw_raw123" }),
329
+ revoke: async () => MOCK_TOKEN,
330
+ },
331
+ agentPluginService: {
332
+ list: async () => [],
333
+ add: async () => {
334
+ throw new Error("not implemented");
335
+ },
336
+ },
337
+ agentMemberService: {
338
+ listByEmail: async () => [],
339
+ exists: async () => false,
340
+ add: async (agentId: string, email: string) => ({
341
+ id: "m1",
342
+ agentId,
343
+ email,
344
+ createdAt: new Date(),
345
+ }),
346
+ remove: async () => {},
347
+ listByAgentId: async () => [],
348
+ },
349
+ agentService: {
350
+ listAll: async () => [],
351
+ listByIds: async () => [],
352
+ searchByName: async () => [],
353
+ listOptions: async () => [{ id: AGENT_ID, name: "Test Agent" }],
354
+ create: async () => {
355
+ throw new Error("not implemented");
356
+ },
357
+ delete: async () => {},
358
+ getDetail: async () => null,
359
+ updateFields: async () => {
360
+ throw new Error("not implemented");
361
+ },
362
+ },
363
+ provisioner: {
364
+ canProvision: false,
365
+ provision: async () => ({
366
+ resourceName: "r",
367
+ secretName: "s",
368
+ deploymentName: "d",
369
+ }),
370
+ deprovision: async () => {},
371
+ reconcile: async () => ({
372
+ recreated: [],
373
+ migrated: [],
374
+ noOwner: [],
375
+ dryRun: false,
376
+ orphans: [],
377
+ failed: [],
378
+ updated: [],
379
+ }),
380
+ },
381
+ serviceIntegrations: [
382
+ {
383
+ name: "task-store",
384
+ listTokensForAgent: async () => [],
385
+ revokeToken: async () => {},
386
+ },
387
+ ],
388
+ chatService: {
389
+ listTokensForAgent: async () => [],
390
+ revokeToken: async () => {},
391
+ deleteThreadsForAgent: async () => ({ deleted: 0 }),
392
+ },
393
+ slack: {
394
+ deleteApp: async () => {},
395
+ },
396
+ decrypt: (value: string) => value,
397
+ sessionSecret: SESSION_SECRET,
398
+ googleClientId: GOOGLE_CLIENT_ID,
399
+ googleClientSecret: GOOGLE_CLIENT_SECRET,
400
+ adminAllowedEmails: ADMIN_ALLOWED_EMAILS,
401
+ googleClient: makeGoogleClient(),
402
+ slackClient: BASE_SLACK_CLIENT,
403
+ githubAppClient: BASE_GITHUB_APP_CLIENT,
404
+ appBaseUrl: "http://localhost:3001",
405
+ ...overrides,
406
+ };
407
+ }
408
+
409
+ // ─── Tests ────────────────────────────────────────────────────────────────────
410
+
411
+ describe("GET /admin/chat — auth gate", () => {
412
+ it("returns 302 redirect to login without session cookie", async () => {
413
+ const app = createAdminUIApp(makeBaseDeps());
414
+ const res = await app.request("/admin/chat");
415
+ expect(res.status).toBe(302);
416
+ expect(res.headers.get("location")).toContain("/admin/login");
417
+ });
418
+ });
419
+
420
+ describe("GET /admin/chat — degraded mode (no chatClient)", () => {
421
+ let sessionCookie: string;
422
+
423
+ beforeAll(async () => {
424
+ sessionCookie = await makeSessionCookie();
425
+ });
426
+
427
+ it("returns 200 with agent selector and degraded notice when chatClient absent", async () => {
428
+ const app = createAdminUIApp(makeBaseDeps()); // no chatClient
429
+ const res = await app.request("/admin/chat", {
430
+ headers: { Cookie: `admin_session=${sessionCookie}` },
431
+ });
432
+ expect(res.status).toBe(200);
433
+ const html = await res.text();
434
+ // Should show agent selector / chat page shell
435
+ expect(html).toContain("Chat");
436
+ // Should show a degraded notice
437
+ expect(html.toLowerCase()).toMatch(/not configured|unavailable|degraded/);
438
+ });
439
+ });
440
+
441
+ // ─── Thread stats: token/cost display ────────────────────────────────────────
442
+
443
+ describe("GET /admin/chat/:agentId/threads/:threadId — token/cost display", () => {
444
+ let sessionCookie: string;
445
+
446
+ beforeAll(async () => {
447
+ sessionCookie = await makeSessionCookie();
448
+ });
449
+
450
+ it("shows per-message token/cost info on assistant messages that have token data", async () => {
451
+ const tokenRichMessage: ChatMessage = {
452
+ id: "msg-token-rich",
453
+ threadId: THREAD_ID,
454
+ role: "assistant",
455
+ body: "Here is my response.",
456
+ createdAt: "2024-01-01T00:01:00.000Z",
457
+ claimedBy: null,
458
+ repliedAt: "2024-01-01T00:01:05.000Z",
459
+ tokens: {
460
+ input_tokens: 512,
461
+ output_tokens: 234,
462
+ cache_read_input_tokens: 0,
463
+ cache_creation_input_tokens: 0,
464
+ },
465
+ costUsd: 0.0005,
466
+ errorKind: null,
467
+ attachmentFilename: null,
468
+ attachmentSize: null,
469
+ };
470
+ const chatClient = makeMockChatClient({
471
+ listMessages: async () => ({
472
+ messages: [tokenRichMessage],
473
+ total: 1,
474
+ limit: 50,
475
+ offset: 0,
476
+ }),
477
+ getThreadStats: async () => ({
478
+ messageCount: 1,
479
+ totalInputTokens: 512,
480
+ totalOutputTokens: 234,
481
+ totalCostUsd: 0.0005,
482
+ }),
483
+ });
484
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
485
+ const res = await app.request(
486
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
487
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
488
+ );
489
+ expect(res.status).toBe(200);
490
+ const html = await res.text();
491
+ // Per-message token info: "512 in / 234 out"
492
+ expect(html).toContain("512");
493
+ expect(html).toContain("234");
494
+ // Cost info
495
+ expect(html).toContain("0.0005");
496
+ });
497
+
498
+ it("shows nothing extra on messages with null tokens/costUsd", async () => {
499
+ const noTokenMessage: ChatMessage = {
500
+ id: "msg-no-token",
501
+ threadId: THREAD_ID,
502
+ role: "assistant",
503
+ body: "No token data.",
504
+ createdAt: "2024-01-01T00:01:00.000Z",
505
+ claimedBy: null,
506
+ repliedAt: "2024-01-01T00:01:05.000Z",
507
+ tokens: null,
508
+ costUsd: null,
509
+ errorKind: null,
510
+ attachmentFilename: null,
511
+ attachmentSize: null,
512
+ };
513
+ const chatClient = makeMockChatClient({
514
+ listMessages: async () => ({
515
+ messages: [noTokenMessage],
516
+ total: 1,
517
+ limit: 50,
518
+ offset: 0,
519
+ }),
520
+ getThreadStats: async () => ({
521
+ messageCount: 1,
522
+ totalInputTokens: 0,
523
+ totalOutputTokens: 0,
524
+ totalCostUsd: 0,
525
+ }),
526
+ });
527
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
528
+ const res = await app.request(
529
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
530
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
531
+ );
532
+ expect(res.status).toBe(200);
533
+ const html = await res.text();
534
+ // Should not show token badge content for null token messages
535
+ expect(html).not.toContain("in / ");
536
+ });
537
+
538
+ it("shows thread-level total token/cost stats in the header", async () => {
539
+ const chatClient = makeMockChatClient({
540
+ listMessages: async () => ({
541
+ messages: [MOCK_ASSISTANT_MESSAGE],
542
+ total: 1,
543
+ limit: 50,
544
+ offset: 0,
545
+ }),
546
+ getThreadStats: async () => ({
547
+ messageCount: 1,
548
+ totalInputTokens: 1200,
549
+ totalOutputTokens: 567,
550
+ totalCostUsd: 0.0032,
551
+ }),
552
+ });
553
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
554
+ const res = await app.request(
555
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
556
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
557
+ );
558
+ expect(res.status).toBe(200);
559
+ const html = await res.text();
560
+ // Thread-level stats should appear in the header
561
+ expect(html).toContain("1.2k");
562
+ expect(html).toContain("0.0032");
563
+ });
564
+ });
565
+
566
+ describe("GET /admin/chat?agentId=X — with chatClient", () => {
567
+ let sessionCookie: string;
568
+
569
+ beforeAll(async () => {
570
+ sessionCookie = await makeSessionCookie();
571
+ });
572
+
573
+ it("returns 200 with thread list for selected agent", async () => {
574
+ const chatClient = makeMockChatClient();
575
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
576
+ const res = await app.request(`/admin/chat?agentId=${AGENT_ID}`, {
577
+ headers: { Cookie: `admin_session=${sessionCookie}` },
578
+ });
579
+ expect(res.status).toBe(200);
580
+ const html = await res.text();
581
+ expect(html).toContain(THREAD_ID);
582
+ expect(html).toContain("Test Thread");
583
+ });
584
+
585
+ it("shows agent selector with the agent pre-selected", async () => {
586
+ const chatClient = makeMockChatClient();
587
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
588
+ const res = await app.request(`/admin/chat?agentId=${AGENT_ID}`, {
589
+ headers: { Cookie: `admin_session=${sessionCookie}` },
590
+ });
591
+ expect(res.status).toBe(200);
592
+ const html = await res.text();
593
+ expect(html).toContain(AGENT_ID);
594
+ });
595
+
596
+ it("shows empty state when no agentId selected", async () => {
597
+ const chatClient = makeMockChatClient();
598
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
599
+ const res = await app.request("/admin/chat", {
600
+ headers: { Cookie: `admin_session=${sessionCookie}` },
601
+ });
602
+ expect(res.status).toBe(200);
603
+ const html = await res.text();
604
+ expect(html).toContain("Chat");
605
+ // No thread table when no agent selected
606
+ expect(html).not.toContain(THREAD_ID);
607
+ });
608
+ });
609
+
610
+ describe("GET /admin/chat/:agentId/threads/:threadId — thread detail", () => {
611
+ let sessionCookie: string;
612
+
613
+ beforeAll(async () => {
614
+ sessionCookie = await makeSessionCookie();
615
+ });
616
+
617
+ it("returns 401 without session cookie", async () => {
618
+ const chatClient = makeMockChatClient();
619
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
620
+ const res = await app.request(
621
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
622
+ );
623
+ expect(res.status).toBe(302);
624
+ expect(res.headers.get("location")).toContain("/admin/login");
625
+ });
626
+
627
+ it("returns 200 with messages when chatClient present", async () => {
628
+ const chatClient = makeMockChatClient();
629
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
630
+ const res = await app.request(
631
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
632
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
633
+ );
634
+ expect(res.status).toBe(200);
635
+ const html = await res.text();
636
+ expect(html).toContain("Hello, agent!");
637
+ expect(html).toContain("user");
638
+ });
639
+
640
+ it("returns degraded mode when chatClient absent", async () => {
641
+ const app = createAdminUIApp(makeBaseDeps()); // no chatClient
642
+ const res = await app.request(
643
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
644
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
645
+ );
646
+ expect(res.status).toBe(200);
647
+ const html = await res.text();
648
+ expect(html.toLowerCase()).toMatch(/not configured|unavailable|degraded/);
649
+ });
650
+ });
651
+
652
+ describe("POST /admin/chat/:agentId/threads — create thread", () => {
653
+ let sessionCookie: string;
654
+
655
+ beforeAll(async () => {
656
+ sessionCookie = await makeSessionCookie();
657
+ });
658
+
659
+ it("returns 302 to thread detail on success", async () => {
660
+ const chatClient = makeMockChatClient();
661
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
662
+ const res = await app.request(`/admin/chat/${AGENT_ID}/threads`, {
663
+ method: "POST",
664
+ headers: {
665
+ Cookie: `admin_session=${sessionCookie}`,
666
+ "Content-Type": "application/x-www-form-urlencoded",
667
+ },
668
+ body: "title=My+New+Thread",
669
+ });
670
+ expect(res.status).toBe(302);
671
+ expect(res.headers.get("location")).toContain(THREAD_ID);
672
+ });
673
+ });
674
+
675
+ describe("POST /admin/chat/:agentId/threads/:threadId/messages — create message", () => {
676
+ let sessionCookie: string;
677
+
678
+ beforeAll(async () => {
679
+ sessionCookie = await makeSessionCookie();
680
+ });
681
+
682
+ it("returns 302 back to thread detail on success", async () => {
683
+ const chatClient = makeMockChatClient();
684
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
685
+ const res = await app.request(
686
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages`,
687
+ {
688
+ method: "POST",
689
+ headers: {
690
+ Cookie: `admin_session=${sessionCookie}`,
691
+ "Content-Type": "application/x-www-form-urlencoded",
692
+ },
693
+ body: "body=Hello+from+admin",
694
+ },
695
+ );
696
+ expect(res.status).toBe(302);
697
+ expect(res.headers.get("location")).toContain(THREAD_ID);
698
+ });
699
+ });
700
+
701
+ describe("Toolbar", () => {
702
+ let sessionCookie: string;
703
+
704
+ beforeAll(async () => {
705
+ sessionCookie = await makeSessionCookie();
706
+ });
707
+
708
+ it("includes Chat nav link in the toolbar", async () => {
709
+ const app = createAdminUIApp(makeBaseDeps());
710
+ const res = await app.request("/admin/agents", {
711
+ headers: { Cookie: `admin_session=${sessionCookie}` },
712
+ });
713
+ expect(res.status).toBe(200);
714
+ const html = await res.text();
715
+ expect(html).toContain("/admin/chat");
716
+ expect(html).toContain(">Chat<");
717
+ });
718
+ });
719
+
720
+ describe("POST /admin/chat/:agentId/threads/:threadId/rename — rename thread", () => {
721
+ let sessionCookie: string;
722
+
723
+ beforeAll(async () => {
724
+ sessionCookie = await makeSessionCookie();
725
+ });
726
+
727
+ it("returns 302 redirect back to the thread page on success", async () => {
728
+ let updatedTitle: string | undefined;
729
+ const chatClient = makeMockChatClient({
730
+ updateThread: async (_id: string, data: { title?: string }) => {
731
+ updatedTitle = data.title;
732
+ return { ...MOCK_THREAD, title: data.title ?? MOCK_THREAD.title };
733
+ },
734
+ });
735
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
736
+ const res = await app.request(
737
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/rename`,
738
+ {
739
+ method: "POST",
740
+ headers: {
741
+ Cookie: `admin_session=${sessionCookie}`,
742
+ "Content-Type": "application/x-www-form-urlencoded",
743
+ },
744
+ body: "title=Renamed+Thread",
745
+ },
746
+ );
747
+ expect(res.status).toBe(302);
748
+ expect(res.headers.get("location")).toContain(THREAD_ID);
749
+ expect(updatedTitle).toBe("Renamed Thread");
750
+ });
751
+
752
+ it("returns 302 to chat list when chatClient absent", async () => {
753
+ const app = createAdminUIApp(makeBaseDeps()); // no chatClient
754
+ const res = await app.request(
755
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/rename`,
756
+ {
757
+ method: "POST",
758
+ headers: {
759
+ Cookie: `admin_session=${sessionCookie}`,
760
+ "Content-Type": "application/x-www-form-urlencoded",
761
+ },
762
+ body: "title=Whatever",
763
+ },
764
+ );
765
+ expect(res.status).toBe(302);
766
+ });
767
+
768
+ it("skips updateThread and redirects back when title is empty", async () => {
769
+ let updateCalled = false;
770
+ const chatClient = makeMockChatClient({
771
+ updateThread: async (_id: string, data: { title?: string }) => {
772
+ updateCalled = true;
773
+ return { ...MOCK_THREAD, title: data.title ?? MOCK_THREAD.title };
774
+ },
775
+ });
776
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
777
+ const res = await app.request(
778
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/rename`,
779
+ {
780
+ method: "POST",
781
+ headers: {
782
+ Cookie: `admin_session=${sessionCookie}`,
783
+ "Content-Type": "application/x-www-form-urlencoded",
784
+ },
785
+ body: "title=",
786
+ },
787
+ );
788
+ expect(res.status).toBe(302);
789
+ expect(updateCalled).toBe(false);
790
+ });
791
+ });
792
+
793
+ describe("POST /admin/chat/:agentId/threads/:threadId/delete — delete thread", () => {
794
+ let sessionCookie: string;
795
+
796
+ beforeAll(async () => {
797
+ sessionCookie = await makeSessionCookie();
798
+ });
799
+
800
+ it("returns 302 redirect to /admin/chat?agentId=X on success", async () => {
801
+ let deletedId: string | undefined;
802
+ const chatClient = makeMockChatClient({
803
+ deleteThread: async (id: string) => {
804
+ deletedId = id;
805
+ },
806
+ });
807
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
808
+ const res = await app.request(
809
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/delete`,
810
+ {
811
+ method: "POST",
812
+ headers: {
813
+ Cookie: `admin_session=${sessionCookie}`,
814
+ "Content-Type": "application/x-www-form-urlencoded",
815
+ },
816
+ body: "",
817
+ },
818
+ );
819
+ expect(res.status).toBe(302);
820
+ const location = res.headers.get("location") ?? "";
821
+ expect(location).toContain("/admin/chat");
822
+ expect(location).toContain(AGENT_ID);
823
+ expect(deletedId).toBe(THREAD_ID);
824
+ });
825
+
826
+ it("returns 302 when chatClient absent", async () => {
827
+ const app = createAdminUIApp(makeBaseDeps()); // no chatClient
828
+ const res = await app.request(
829
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/delete`,
830
+ {
831
+ method: "POST",
832
+ headers: {
833
+ Cookie: `admin_session=${sessionCookie}`,
834
+ "Content-Type": "application/x-www-form-urlencoded",
835
+ },
836
+ body: "",
837
+ },
838
+ );
839
+ expect(res.status).toBe(302);
840
+ });
841
+
842
+ it("swallows deleteThread errors and still redirects to the agent thread list", async () => {
843
+ const chatClient = makeMockChatClient({
844
+ deleteThread: async () => {
845
+ throw new Error("chat service unavailable");
846
+ },
847
+ });
848
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
849
+ const res = await app.request(
850
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/delete`,
851
+ {
852
+ method: "POST",
853
+ headers: {
854
+ Cookie: `admin_session=${sessionCookie}`,
855
+ "Content-Type": "application/x-www-form-urlencoded",
856
+ },
857
+ body: "",
858
+ },
859
+ );
860
+ expect(res.status).toBe(302);
861
+ const location = res.headers.get("location") ?? "";
862
+ expect(location).toContain("/admin/chat");
863
+ expect(location).toContain(AGENT_ID);
864
+ // Must NOT redirect to the thread detail page
865
+ expect(location).not.toContain(THREAD_ID);
866
+ });
867
+ });
868
+
869
+ describe("GET /admin/chat?agentId=X&q=foo — search/filter threads", () => {
870
+ let sessionCookie: string;
871
+
872
+ beforeAll(async () => {
873
+ sessionCookie = await makeSessionCookie();
874
+ });
875
+
876
+ it("returns only threads matching the search query", async () => {
877
+ const SECOND_THREAD: ChatThread = {
878
+ id: "thread-other-999",
879
+ agentId: AGENT_ID,
880
+ title: "Unrelated topic",
881
+ memberId: null,
882
+ createdAt: "2024-01-02T00:00:00.000Z",
883
+ updatedAt: "2024-01-02T00:00:00.000Z",
884
+ };
885
+ const chatClient = makeMockChatClient({
886
+ listThreads: async () => ({
887
+ threads: [MOCK_THREAD, SECOND_THREAD],
888
+ total: 2,
889
+ limit: 50,
890
+ offset: 0,
891
+ }),
892
+ });
893
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
894
+ const res = await app.request(`/admin/chat?agentId=${AGENT_ID}&q=Test`, {
895
+ headers: { Cookie: `admin_session=${sessionCookie}` },
896
+ });
897
+ expect(res.status).toBe(200);
898
+ const html = await res.text();
899
+ // Should show matching thread
900
+ expect(html).toContain("Test Thread");
901
+ // Should not show non-matching thread
902
+ expect(html).not.toContain("Unrelated topic");
903
+ });
904
+
905
+ it("shows search input on the chat page", async () => {
906
+ const chatClient = makeMockChatClient();
907
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
908
+ const res = await app.request(`/admin/chat?agentId=${AGENT_ID}`, {
909
+ headers: { Cookie: `admin_session=${sessionCookie}` },
910
+ });
911
+ expect(res.status).toBe(200);
912
+ const html = await res.text();
913
+ // Should have a search input with name "q"
914
+ expect(html).toContain('name="q"');
915
+ });
916
+
917
+ it("returns all threads when q is empty", async () => {
918
+ const SECOND_THREAD: ChatThread = {
919
+ id: "thread-other-999",
920
+ agentId: AGENT_ID,
921
+ title: "Unrelated topic",
922
+ memberId: null,
923
+ createdAt: "2024-01-02T00:00:00.000Z",
924
+ updatedAt: "2024-01-02T00:00:00.000Z",
925
+ };
926
+ const chatClient = makeMockChatClient({
927
+ listThreads: async () => ({
928
+ threads: [MOCK_THREAD, SECOND_THREAD],
929
+ total: 2,
930
+ limit: 50,
931
+ offset: 0,
932
+ }),
933
+ });
934
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
935
+ const res = await app.request(`/admin/chat?agentId=${AGENT_ID}&q=`, {
936
+ headers: { Cookie: `admin_session=${sessionCookie}` },
937
+ });
938
+ expect(res.status).toBe(200);
939
+ const html = await res.text();
940
+ expect(html).toContain("Test Thread");
941
+ expect(html).toContain("Unrelated topic");
942
+ });
943
+ });
944
+
945
+ describe("GET /admin/chat/:agentId/threads/:threadId — thread list pane on detail page", () => {
946
+ let sessionCookie: string;
947
+
948
+ beforeAll(async () => {
949
+ sessionCookie = await makeSessionCookie();
950
+ });
951
+
952
+ it("renders thread list links in the sidebar pane", async () => {
953
+ const SECOND_THREAD: ChatThread = {
954
+ id: "thread-other-999",
955
+ agentId: AGENT_ID,
956
+ title: "Second Thread",
957
+ memberId: null,
958
+ createdAt: "2024-01-02T00:00:00.000Z",
959
+ updatedAt: "2024-01-02T00:00:00.000Z",
960
+ };
961
+ const chatClient = makeMockChatClient({
962
+ listThreads: async () => ({
963
+ threads: [MOCK_THREAD, SECOND_THREAD],
964
+ total: 2,
965
+ limit: 50,
966
+ offset: 0,
967
+ }),
968
+ });
969
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
970
+ const res = await app.request(
971
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
972
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
973
+ );
974
+ expect(res.status).toBe(200);
975
+ const html = await res.text();
976
+ // The thread detail page should include links to other threads (sidebar pane)
977
+ expect(html).toContain("thread-other-999");
978
+ expect(html).toContain("Second Thread");
979
+ });
980
+
981
+ it("renders rename form on the thread detail page", async () => {
982
+ const chatClient = makeMockChatClient({
983
+ updateThread: async (_id: string, data: { title?: string }) => ({
984
+ ...MOCK_THREAD,
985
+ title: data.title ?? null,
986
+ }),
987
+ });
988
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
989
+ const res = await app.request(
990
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
991
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
992
+ );
993
+ expect(res.status).toBe(200);
994
+ const html = await res.text();
995
+ // Should have a rename form pointing to the rename endpoint
996
+ expect(html).toContain("/rename");
997
+ // rename input must have required so empty submissions are blocked client-side
998
+ expect(html).toContain('name="title"');
999
+ expect(html).toMatch(/name="title"[^>]*required|required[^>]*name="title"/);
1000
+ });
1001
+
1002
+ it("renders delete button on the thread detail page", async () => {
1003
+ const chatClient = makeMockChatClient({
1004
+ deleteThread: async () => {},
1005
+ });
1006
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1007
+ const res = await app.request(
1008
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
1009
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
1010
+ );
1011
+ expect(res.status).toBe(200);
1012
+ const html = await res.text();
1013
+ // Should have a delete form pointing to the delete endpoint
1014
+ expect(html).toContain("/delete");
1015
+ });
1016
+ });
1017
+
1018
+ // ─── JSON API: GET messages.json ──────────────────────────────────────────────
1019
+
1020
+ describe("GET /admin/chat/:agentId/threads/:threadId/messages.json", () => {
1021
+ let sessionCookie: string;
1022
+
1023
+ beforeAll(async () => {
1024
+ sessionCookie = await makeSessionCookie();
1025
+ });
1026
+
1027
+ it("returns 302 to login without session cookie", async () => {
1028
+ const chatClient = makeMockChatClient();
1029
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1030
+ const res = await app.request(
1031
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages.json`,
1032
+ );
1033
+ expect(res.status).toBe(302);
1034
+ expect(res.headers.get("location")).toContain("/admin/login");
1035
+ });
1036
+
1037
+ it("returns 200 JSON with messages array when chatClient present", async () => {
1038
+ const chatClient = makeMockChatClient();
1039
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1040
+ const res = await app.request(
1041
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages.json`,
1042
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
1043
+ );
1044
+ expect(res.status).toBe(200);
1045
+ expect(res.headers.get("content-type")).toContain("application/json");
1046
+ const body = (await res.json()) as { messages: ChatMessage[] };
1047
+ expect(Array.isArray(body.messages)).toBe(true);
1048
+ expect(body.messages[0].id).toBe(MOCK_MESSAGE.id);
1049
+ });
1050
+
1051
+ it("returns 200 with empty messages array when chatClient absent", async () => {
1052
+ const app = createAdminUIApp(makeBaseDeps()); // no chatClient
1053
+ const res = await app.request(
1054
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages.json`,
1055
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
1056
+ );
1057
+ expect(res.status).toBe(200);
1058
+ const body = (await res.json()) as { messages: ChatMessage[] };
1059
+ expect(body.messages).toEqual([]);
1060
+ });
1061
+
1062
+ // CFB-2.3: each message carries server-rendered bubbleHtml so polled bubbles
1063
+ // are byte-identical to a full page reload's server-rendered bubble.
1064
+ it("attaches bubbleHtml to each message", async () => {
1065
+ const chatClient = makeMockChatClient();
1066
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1067
+ const res = await app.request(
1068
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages.json`,
1069
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
1070
+ );
1071
+ const body = (await res.json()) as {
1072
+ messages: (ChatMessage & { bubbleHtml: string })[];
1073
+ };
1074
+ expect(typeof body.messages[0].bubbleHtml).toBe("string");
1075
+ expect(body.messages[0].bubbleHtml).toContain(
1076
+ `data-message-id="${MOCK_MESSAGE.id}"`,
1077
+ );
1078
+ });
1079
+
1080
+ // The chat service once leaked the raw `attachmentBytes` column into its
1081
+ // message list; passing that straight through turned every poll into a
1082
+ // ~36 MB response and OOM-killed this service. messages.json must emit an
1083
+ // explicit field set, so an upstream leak can never reach the browser.
1084
+ it("emits only the known message fields, never an upstream attachmentBytes", async () => {
1085
+ const leaked = {
1086
+ ...MOCK_ASSISTANT_MESSAGE,
1087
+ attachmentFilename: "response.wav",
1088
+ attachmentSize: 3,
1089
+ attachmentBytes: { 0: 7, 1: 8, 2: 9 },
1090
+ } as ChatMessage;
1091
+ const chatClient = makeMockChatClient({
1092
+ listMessages: async () => ({
1093
+ messages: [leaked],
1094
+ total: 1,
1095
+ limit: 50,
1096
+ offset: 0,
1097
+ }),
1098
+ });
1099
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1100
+ const res = await app.request(
1101
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages.json`,
1102
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
1103
+ );
1104
+ expect(res.status).toBe(200);
1105
+ const body = (await res.json()) as { messages: Record<string, unknown>[] };
1106
+ expect(body.messages).toHaveLength(1);
1107
+ const m = body.messages[0];
1108
+ expect(m).not.toHaveProperty("attachmentBytes");
1109
+ expect(m.id).toBe(MOCK_ASSISTANT_MESSAGE.id);
1110
+ expect(m.role).toBe("assistant");
1111
+ expect(m.createdAt).toBe(MOCK_ASSISTANT_MESSAGE.createdAt);
1112
+ expect(m.repliedAt).toBe(MOCK_ASSISTANT_MESSAGE.repliedAt);
1113
+ expect(m.attachmentFilename).toBe("response.wav");
1114
+ expect(m.attachmentSize).toBe(3);
1115
+ expect(typeof m.bubbleHtml).toBe("string");
1116
+ });
1117
+
1118
+ // CFB-2.3: ?since=<messageId> returns only messages ordered after that id.
1119
+ it("filters messages with ?since=<messageId>", async () => {
1120
+ const older: ChatMessage = { ...MOCK_MESSAGE, id: "older-1" };
1121
+ const newer: ChatMessage = { ...MOCK_MESSAGE, id: "newer-1" };
1122
+ const chatClient = makeMockChatClient({
1123
+ listMessages: async (_threadId, opts) => {
1124
+ // The admin layer applies the since filter itself over the full list,
1125
+ // so the mock ignores opts.since and returns everything ordered.
1126
+ void opts;
1127
+ return { messages: [older, newer], total: 2, limit: 50, offset: 0 };
1128
+ },
1129
+ });
1130
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1131
+ const res = await app.request(
1132
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages.json?since=older-1`,
1133
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
1134
+ );
1135
+ const body = (await res.json()) as { messages: ChatMessage[] };
1136
+ expect(body.messages.map((m) => m.id)).toEqual(["newer-1"]);
1137
+ });
1138
+ });
1139
+
1140
+ // ─── JSON API: POST messages.json ─────────────────────────────────────────────
1141
+
1142
+ describe("POST /admin/chat/:agentId/threads/:threadId/messages.json", () => {
1143
+ let sessionCookie: string;
1144
+
1145
+ beforeAll(async () => {
1146
+ sessionCookie = await makeSessionCookie();
1147
+ });
1148
+
1149
+ it("returns 302 to login without session cookie", async () => {
1150
+ const chatClient = makeMockChatClient();
1151
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1152
+ const res = await app.request(
1153
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages.json`,
1154
+ {
1155
+ method: "POST",
1156
+ headers: { "Content-Type": "application/json" },
1157
+ body: JSON.stringify({ body: "test message" }),
1158
+ },
1159
+ );
1160
+ expect(res.status).toBe(302);
1161
+ expect(res.headers.get("location")).toContain("/admin/login");
1162
+ });
1163
+
1164
+ it("returns 200 JSON with message when chatClient present", async () => {
1165
+ const chatClient = makeMockChatClient();
1166
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1167
+ const res = await app.request(
1168
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages.json`,
1169
+ {
1170
+ method: "POST",
1171
+ headers: {
1172
+ Cookie: `admin_session=${sessionCookie}`,
1173
+ "Content-Type": "application/json",
1174
+ },
1175
+ body: JSON.stringify({ body: "Hello, agent!" }),
1176
+ },
1177
+ );
1178
+ expect(res.status).toBe(200);
1179
+ expect(res.headers.get("content-type")).toContain("application/json");
1180
+ const body = (await res.json()) as { message: ChatMessage };
1181
+ expect(body.message).not.toBeNull();
1182
+ expect(body.message.id).toBe(MOCK_MESSAGE.id);
1183
+ });
1184
+
1185
+ it("returns 200 with null message when chatClient absent", async () => {
1186
+ const app = createAdminUIApp(makeBaseDeps()); // no chatClient
1187
+ const res = await app.request(
1188
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages.json`,
1189
+ {
1190
+ method: "POST",
1191
+ headers: {
1192
+ Cookie: `admin_session=${sessionCookie}`,
1193
+ "Content-Type": "application/json",
1194
+ },
1195
+ body: JSON.stringify({ body: "Hello!" }),
1196
+ },
1197
+ );
1198
+ expect(res.status).toBe(200);
1199
+ const body = (await res.json()) as { message: null };
1200
+ expect(body.message).toBeNull();
1201
+ });
1202
+ });
1203
+
1204
+ // ─── Upload API: POST messages/upload ─────────────────────────────────────────
1205
+ // This is the route the live chat UI's send box actually posts to
1206
+ // (admin-ui-pages.ts's uploadUrl) — distinct from the form POST /messages and
1207
+ // JSON POST /messages.json routes above. It was missing the watchThread()
1208
+ // call the other two have (CFB-4.4 follow-up bug: real push subscriptions
1209
+ // existed but ChatThreadWatch stayed empty, so notifyThreadReply always found
1210
+ // zero recipients).
1211
+
1212
+ function makeFakePushService(
1213
+ notifyThreadReply: (thread: {
1214
+ threadId: string;
1215
+ agentId: string;
1216
+ title: string | null;
1217
+ preview: string | null;
1218
+ }) => Promise<{ delivered: number; pruned: number }> = async () => ({
1219
+ delivered: 0,
1220
+ pruned: 0,
1221
+ }),
1222
+ recordWatch: (
1223
+ userEmail: string,
1224
+ agentId: string,
1225
+ threadId: string,
1226
+ ) => Promise<void> = async () => {},
1227
+ ): PushService {
1228
+ return { notifyThreadReply, recordWatch } as unknown as PushService;
1229
+ }
1230
+
1231
+ describe("POST /admin/chat/:agentId/threads/:threadId/messages/upload", () => {
1232
+ let sessionCookie: string;
1233
+
1234
+ beforeAll(async () => {
1235
+ sessionCookie = await makeSessionCookie();
1236
+ });
1237
+
1238
+ it("returns 302 to login without session cookie", async () => {
1239
+ const chatClient = makeMockChatClient();
1240
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1241
+ const res = await app.request(
1242
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages/upload`,
1243
+ {
1244
+ method: "POST",
1245
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1246
+ body: "body=test+message",
1247
+ },
1248
+ );
1249
+ expect(res.status).toBe(302);
1250
+ expect(res.headers.get("location")).toContain("/admin/login");
1251
+ });
1252
+
1253
+ it("returns 201 JSON with message when chatClient present", async () => {
1254
+ const chatClient = makeMockChatClient();
1255
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1256
+ const res = await app.request(
1257
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages/upload`,
1258
+ {
1259
+ method: "POST",
1260
+ headers: {
1261
+ Cookie: `admin_session=${sessionCookie}`,
1262
+ "Content-Type": "application/x-www-form-urlencoded",
1263
+ },
1264
+ body: "body=Hello%2C+agent!",
1265
+ },
1266
+ );
1267
+ expect(res.status).toBe(201);
1268
+ const body = (await res.json()) as { message: ChatMessage };
1269
+ expect(body.message.id).toBe(MOCK_MESSAGE.id);
1270
+ });
1271
+
1272
+ it("returns 400 when neither body nor file is present", async () => {
1273
+ const chatClient = makeMockChatClient();
1274
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1275
+ const res = await app.request(
1276
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages/upload`,
1277
+ {
1278
+ method: "POST",
1279
+ headers: {
1280
+ Cookie: `admin_session=${sessionCookie}`,
1281
+ "Content-Type": "application/x-www-form-urlencoded",
1282
+ },
1283
+ body: "",
1284
+ },
1285
+ );
1286
+ expect(res.status).toBe(400);
1287
+ });
1288
+
1289
+ it("registers a ChatThreadWatch row when push is enabled (CFB-4.4 regression)", async () => {
1290
+ const chatClient = makeMockChatClient();
1291
+ const base = makeBaseDeps();
1292
+ const watchUpserts: Array<{
1293
+ userEmail: string;
1294
+ agentId: string;
1295
+ threadId: string;
1296
+ }> = [];
1297
+ const app = createAdminUIApp({
1298
+ ...base,
1299
+ chatClient,
1300
+ pushService: makeFakePushService(
1301
+ undefined,
1302
+ async (userEmail, agentId, threadId) => {
1303
+ watchUpserts.push({ userEmail, agentId, threadId });
1304
+ },
1305
+ ),
1306
+ vapidPublicKey: "test-vapid-public-key",
1307
+ });
1308
+ const res = await app.request(
1309
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages/upload`,
1310
+ {
1311
+ method: "POST",
1312
+ headers: {
1313
+ Cookie: `admin_session=${sessionCookie}`,
1314
+ "Content-Type": "application/x-www-form-urlencoded",
1315
+ },
1316
+ body: "body=testing+notifications",
1317
+ },
1318
+ );
1319
+ expect(res.status).toBe(201);
1320
+ expect(watchUpserts).toHaveLength(1);
1321
+ expect(watchUpserts[0]).toEqual({
1322
+ userEmail: "admin@example.com",
1323
+ threadId: THREAD_ID,
1324
+ agentId: AGENT_ID,
1325
+ });
1326
+ });
1327
+
1328
+ it("does not touch ChatThreadWatch when push is disabled", async () => {
1329
+ const chatClient = makeMockChatClient();
1330
+ const base = makeBaseDeps();
1331
+ const watchUpserts: unknown[] = [];
1332
+ const app = createAdminUIApp({
1333
+ ...base,
1334
+ chatClient,
1335
+ // No pushService/vapidPublicKey — pushEnabled stays false, so
1336
+ // recordWatch (spied below) must never be invoked.
1337
+ pushService: makeFakePushService(undefined, async () => {
1338
+ watchUpserts.push(true);
1339
+ }),
1340
+ });
1341
+ const res = await app.request(
1342
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages/upload`,
1343
+ {
1344
+ method: "POST",
1345
+ headers: {
1346
+ Cookie: `admin_session=${sessionCookie}`,
1347
+ "Content-Type": "application/x-www-form-urlencoded",
1348
+ },
1349
+ body: "body=push+disabled+case",
1350
+ },
1351
+ );
1352
+ expect(res.status).toBe(201);
1353
+ expect(watchUpserts).toHaveLength(0);
1354
+ });
1355
+ });
1356
+
1357
+ // ─── Attachment proxy: GET messages/:messageId/attachment (VM-3.1) ────────────
1358
+ // Streams a message's attachment bytes back to the browser for inline <audio>
1359
+ // playback — the browser never holds the chat service's admin bearer token,
1360
+ // so this route fetches on its behalf via chatClient.getAttachment().
1361
+
1362
+ describe("GET /admin/chat/:agentId/threads/:threadId/messages/:messageId/attachment", () => {
1363
+ let sessionCookie: string;
1364
+
1365
+ beforeAll(async () => {
1366
+ sessionCookie = await makeSessionCookie();
1367
+ });
1368
+
1369
+ it("returns 302 to login without session cookie", async () => {
1370
+ const chatClient = makeMockChatClient();
1371
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1372
+ const res = await app.request(
1373
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages/msg-1/attachment`,
1374
+ );
1375
+ expect(res.status).toBe(302);
1376
+ expect(res.headers.get("location")).toContain("/admin/login");
1377
+ });
1378
+
1379
+ it("streams bytes with an audio/* Content-Type for a .webm attachment", async () => {
1380
+ const bytes = new Uint8Array([1, 2, 3, 4]);
1381
+ const chatClient = makeMockChatClient({
1382
+ getAttachment: async () => ({
1383
+ bytes,
1384
+ filename: "recording-123.webm",
1385
+ }),
1386
+ });
1387
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1388
+ const res = await app.request(
1389
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages/msg-1/attachment`,
1390
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
1391
+ );
1392
+ expect(res.status).toBe(200);
1393
+ expect(res.headers.get("content-type")).toBe("audio/webm");
1394
+ const body = new Uint8Array(await res.arrayBuffer());
1395
+ expect(Array.from(body)).toEqual(Array.from(bytes));
1396
+ });
1397
+
1398
+ it("returns 404 when the chat client reports no attachment", async () => {
1399
+ const chatClient = makeMockChatClient({ getAttachment: async () => null });
1400
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1401
+ const res = await app.request(
1402
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages/msg-1/attachment`,
1403
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
1404
+ );
1405
+ expect(res.status).toBe(404);
1406
+ });
1407
+
1408
+ it("returns 503 when the chat service is not configured", async () => {
1409
+ const app = createAdminUIApp(makeBaseDeps()); // no chatClient
1410
+ const res = await app.request(
1411
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages/msg-1/attachment`,
1412
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
1413
+ );
1414
+ expect(res.status).toBe(503);
1415
+ });
1416
+ });
1417
+
1418
+ // ─── Thread detail page: conversation window ──────────────────────────────────
1419
+
1420
+ describe("GET /admin/chat/:agentId/threads/:threadId — conversation window", () => {
1421
+ let sessionCookie: string;
1422
+
1423
+ beforeAll(async () => {
1424
+ sessionCookie = await makeSessionCookie();
1425
+ });
1426
+
1427
+ it("renders message bubbles (role labels) when messages present", async () => {
1428
+ const chatClient = makeMockChatClient({
1429
+ listMessages: async () => ({
1430
+ messages: [MOCK_MESSAGE, MOCK_ASSISTANT_MESSAGE],
1431
+ total: 2,
1432
+ limit: 50,
1433
+ offset: 0,
1434
+ }),
1435
+ });
1436
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1437
+ const res = await app.request(
1438
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
1439
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
1440
+ );
1441
+ expect(res.status).toBe(200);
1442
+ const html = await res.text();
1443
+ expect(html).toContain("Hello, agent!");
1444
+ expect(html).toContain("Here is");
1445
+ });
1446
+
1447
+ it("renders markdown for assistant messages (bold → <strong>)", async () => {
1448
+ const chatClient = makeMockChatClient({
1449
+ listMessages: async () => ({
1450
+ messages: [MOCK_ASSISTANT_MESSAGE],
1451
+ total: 1,
1452
+ limit: 50,
1453
+ offset: 0,
1454
+ }),
1455
+ });
1456
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1457
+ const res = await app.request(
1458
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
1459
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
1460
+ );
1461
+ const html = await res.text();
1462
+ expect(html).toContain("<strong>bold text</strong>");
1463
+ });
1464
+
1465
+ it("renders markdown for assistant messages (inline code → <code>)", async () => {
1466
+ const chatClient = makeMockChatClient({
1467
+ listMessages: async () => ({
1468
+ messages: [MOCK_ASSISTANT_MESSAGE],
1469
+ total: 1,
1470
+ limit: 50,
1471
+ offset: 0,
1472
+ }),
1473
+ });
1474
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1475
+ const res = await app.request(
1476
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
1477
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
1478
+ );
1479
+ const html = await res.text();
1480
+ expect(html).toContain("<code>inline code</code>");
1481
+ });
1482
+
1483
+ it("shows error state for messages with errorKind", async () => {
1484
+ const chatClient = makeMockChatClient({
1485
+ listMessages: async () => ({
1486
+ messages: [MOCK_ERROR_MESSAGE],
1487
+ total: 1,
1488
+ limit: 50,
1489
+ offset: 0,
1490
+ }),
1491
+ });
1492
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1493
+ const res = await app.request(
1494
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
1495
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
1496
+ );
1497
+ const html = await res.text();
1498
+ expect(html).toContain("Rate limited");
1499
+ });
1500
+
1501
+ it("includes the live status bubble id in inline JS (CFB-2.3)", async () => {
1502
+ const chatClient = makeMockChatClient();
1503
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1504
+ const res = await app.request(
1505
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
1506
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
1507
+ );
1508
+ const html = await res.text();
1509
+ expect(html).toContain("live-status-bubble");
1510
+ });
1511
+
1512
+ it("includes messages-container and send-btn in the page", async () => {
1513
+ const chatClient = makeMockChatClient();
1514
+ const app = createAdminUIApp(makeBaseDeps({ chatClient }));
1515
+ const res = await app.request(
1516
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
1517
+ { headers: { Cookie: `admin_session=${sessionCookie}` } },
1518
+ );
1519
+ const html = await res.text();
1520
+ expect(html).toContain("messages-container");
1521
+ expect(html).toContain("send-btn");
1522
+ });
1523
+ });
1524
+
1525
+ // ─── Agent-member access ──────────────────────────────────────────────────────
1526
+
1527
+ const MEMBER_EMAIL = "member@example.com";
1528
+ const OTHER_AGENT_ID = "agent-other-789";
1529
+
1530
+ function makeMemberDeps(
1531
+ overrides?: Partial<AdminUIDeps>,
1532
+ memberOf: string[] = [AGENT_ID],
1533
+ ): AdminUIDeps {
1534
+ const base = makeBaseDeps();
1535
+ return makeBaseDeps({
1536
+ agentMemberService: {
1537
+ ...base.agentMemberService,
1538
+ exists: async (agentId: string, email: string) =>
1539
+ email === MEMBER_EMAIL && memberOf.includes(agentId),
1540
+ listByEmail: async (email: string) =>
1541
+ email === MEMBER_EMAIL
1542
+ ? memberOf.map((agentId) => ({
1543
+ id: `m-${agentId}`,
1544
+ agentId,
1545
+ email,
1546
+ createdAt: new Date(),
1547
+ }))
1548
+ : [],
1549
+ },
1550
+ agentService: {
1551
+ ...base.agentService,
1552
+ listByIds: async (ids: string[]) =>
1553
+ ids.map((id) => ({
1554
+ id,
1555
+ name: `Agent ${id}`,
1556
+ slackId: null,
1557
+ createdAt: new Date("2024-01-01"),
1558
+ })) as never,
1559
+ },
1560
+ ...overrides,
1561
+ });
1562
+ }
1563
+
1564
+ describe("chat routes — agent member access", () => {
1565
+ let memberCookie: string;
1566
+ let strangerCookie: string;
1567
+
1568
+ beforeAll(async () => {
1569
+ memberCookie = await makeSessionCookie(
1570
+ SESSION_SECRET,
1571
+ "sub-member",
1572
+ MEMBER_EMAIL,
1573
+ false,
1574
+ );
1575
+ strangerCookie = await makeSessionCookie(
1576
+ SESSION_SECRET,
1577
+ "sub-stranger",
1578
+ "stranger@example.com",
1579
+ false,
1580
+ );
1581
+ });
1582
+
1583
+ const get = (
1584
+ app: ReturnType<typeof createAdminUIApp>,
1585
+ path: string,
1586
+ cookie: string,
1587
+ ) => app.request(path, { headers: { Cookie: `admin_session=${cookie}` } });
1588
+
1589
+ it("member can open the chat page for their own agent", async () => {
1590
+ const app = createAdminUIApp(
1591
+ makeMemberDeps({ chatClient: makeMockChatClient() }),
1592
+ );
1593
+ const res = await get(app, `/admin/chat?agentId=${AGENT_ID}`, memberCookie);
1594
+ expect(res.status).toBe(200);
1595
+ });
1596
+
1597
+ it("chat page lists only the agents the member belongs to", async () => {
1598
+ const app = createAdminUIApp(
1599
+ makeMemberDeps({ chatClient: makeMockChatClient() }),
1600
+ );
1601
+ const res = await get(app, "/admin/chat", memberCookie);
1602
+ expect(res.status).toBe(200);
1603
+ const html = await res.text();
1604
+ expect(html).toContain(`Agent ${AGENT_ID}`);
1605
+ expect(html).not.toContain("Test Agent");
1606
+ });
1607
+
1608
+ it("member is forbidden from the chat page of an agent they don't belong to", async () => {
1609
+ const app = createAdminUIApp(
1610
+ makeMemberDeps({ chatClient: makeMockChatClient() }),
1611
+ );
1612
+ const res = await get(
1613
+ app,
1614
+ `/admin/chat?agentId=${OTHER_AGENT_ID}`,
1615
+ memberCookie,
1616
+ );
1617
+ expect(res.status).toBe(403);
1618
+ });
1619
+
1620
+ it("non-member is forbidden from an agent's thread", async () => {
1621
+ const app = createAdminUIApp(
1622
+ makeMemberDeps({ chatClient: makeMockChatClient() }),
1623
+ );
1624
+ const res = await get(
1625
+ app,
1626
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
1627
+ strangerCookie,
1628
+ );
1629
+ expect(res.status).toBe(403);
1630
+ });
1631
+
1632
+ it("member can open a thread owned by their agent", async () => {
1633
+ const app = createAdminUIApp(
1634
+ makeMemberDeps({ chatClient: makeMockChatClient() }),
1635
+ );
1636
+ const res = await get(
1637
+ app,
1638
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
1639
+ memberCookie,
1640
+ );
1641
+ expect(res.status).toBe(200);
1642
+ });
1643
+
1644
+ it("member cannot read another agent's thread by pairing it with their own agentId", async () => {
1645
+ const chatClient = makeMockChatClient({
1646
+ getThread: async () => ({ ...MOCK_THREAD, agentId: OTHER_AGENT_ID }),
1647
+ });
1648
+ const app = createAdminUIApp(makeMemberDeps({ chatClient }));
1649
+ for (const suffix of ["", "/messages.json"]) {
1650
+ const res = await get(
1651
+ app,
1652
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}${suffix}`,
1653
+ memberCookie,
1654
+ );
1655
+ expect(res.status).toBe(403);
1656
+ }
1657
+ });
1658
+
1659
+ it("member cannot send to, rename, or delete another agent's thread", async () => {
1660
+ let writes = 0;
1661
+ const chatClient = makeMockChatClient({
1662
+ getThread: async () => ({ ...MOCK_THREAD, agentId: OTHER_AGENT_ID }),
1663
+ createMessage: async () => {
1664
+ writes++;
1665
+ return MOCK_MESSAGE;
1666
+ },
1667
+ updateThread: async () => {
1668
+ writes++;
1669
+ return MOCK_THREAD;
1670
+ },
1671
+ deleteThread: async () => {
1672
+ writes++;
1673
+ },
1674
+ });
1675
+ const app = createAdminUIApp(makeMemberDeps({ chatClient }));
1676
+ const base = `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`;
1677
+ for (const path of [
1678
+ `${base}/messages`,
1679
+ `${base}/messages/upload`,
1680
+ `${base}/messages.json`,
1681
+ `${base}/rename`,
1682
+ `${base}/delete`,
1683
+ ]) {
1684
+ const res = await app.request(path, {
1685
+ method: "POST",
1686
+ headers: {
1687
+ Cookie: `admin_session=${memberCookie}`,
1688
+ "Content-Type": "application/json",
1689
+ },
1690
+ body: JSON.stringify({ body: "hi", title: "x" }),
1691
+ });
1692
+ expect(res.status).toBe(403);
1693
+ }
1694
+ expect(writes).toBe(0);
1695
+ });
1696
+
1697
+ it("member can create a thread and post a message on their own agent", async () => {
1698
+ const app = createAdminUIApp(
1699
+ makeMemberDeps({ chatClient: makeMockChatClient() }),
1700
+ );
1701
+ const created = await app.request(`/admin/chat/${AGENT_ID}/threads`, {
1702
+ method: "POST",
1703
+ headers: { Cookie: `admin_session=${memberCookie}` },
1704
+ body: new FormData(),
1705
+ });
1706
+ expect(created.status).toBe(302);
1707
+ const posted = await app.request(
1708
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages.json`,
1709
+ {
1710
+ method: "POST",
1711
+ headers: {
1712
+ Cookie: `admin_session=${memberCookie}`,
1713
+ "Content-Type": "application/json",
1714
+ },
1715
+ body: JSON.stringify({ body: "hello" }),
1716
+ },
1717
+ );
1718
+ expect(posted.status).toBe(200);
1719
+ });
1720
+
1721
+ it("member cannot create a thread on an agent they don't belong to", async () => {
1722
+ const app = createAdminUIApp(
1723
+ makeMemberDeps({ chatClient: makeMockChatClient() }),
1724
+ );
1725
+ const res = await app.request(`/admin/chat/${OTHER_AGENT_ID}/threads`, {
1726
+ method: "POST",
1727
+ headers: { Cookie: `admin_session=${memberCookie}` },
1728
+ body: new FormData(),
1729
+ });
1730
+ expect(res.status).toBe(403);
1731
+ });
1732
+ });
1733
+
1734
+ // ─── SSP-8.4: account-status send gate ────────────────────────────────────────
1735
+
1736
+ describe("account status gates chat message-send (SSP-8.4)", () => {
1737
+ const ACCOUNT_ID = "acct-gate";
1738
+ const SEND_PATHS: {
1739
+ suffix: string;
1740
+ init: { headers: Record<string, string>; body: BodyInit };
1741
+ }[] = [
1742
+ {
1743
+ suffix: "/messages",
1744
+ init: {
1745
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1746
+ body: "body=hi",
1747
+ },
1748
+ },
1749
+ {
1750
+ suffix: "/messages.json",
1751
+ init: {
1752
+ headers: { "Content-Type": "application/json" },
1753
+ body: JSON.stringify({ body: "hi" }),
1754
+ },
1755
+ },
1756
+ {
1757
+ suffix: "/messages/upload",
1758
+ init: {
1759
+ headers: {},
1760
+ body: (() => {
1761
+ const fd = new FormData();
1762
+ fd.set("file", new File(["x"], "x.txt"));
1763
+ return fd;
1764
+ })(),
1765
+ },
1766
+ },
1767
+ ];
1768
+
1769
+ function gateDeps(status: string | null, writes: { n: number }) {
1770
+ const base = makeBaseDeps();
1771
+ return makeBaseDeps({
1772
+ chatClient: makeMockChatClient({
1773
+ createMessage: async () => {
1774
+ writes.n++;
1775
+ return MOCK_MESSAGE;
1776
+ },
1777
+ }),
1778
+ callerScopeResolver: async () => ({
1779
+ kind: "scoped",
1780
+ accountId: ACCOUNT_ID,
1781
+ agentIds: [AGENT_ID],
1782
+ }),
1783
+ accountService: {
1784
+ getById: async () =>
1785
+ status ? ({ id: ACCOUNT_ID, status } as never) : null,
1786
+ countAgents: async () => 0,
1787
+ listAgentIds: async () => [AGENT_ID],
1788
+ },
1789
+ agentService: {
1790
+ ...base.agentService,
1791
+ getDetail: async () =>
1792
+ ({
1793
+ id: AGENT_ID,
1794
+ accountId: status === "none" ? null : ACCOUNT_ID,
1795
+ }) as never,
1796
+ },
1797
+ });
1798
+ }
1799
+
1800
+ async function send(
1801
+ app: ReturnType<typeof createAdminUIApp>,
1802
+ p: (typeof SEND_PATHS)[number],
1803
+ cookie: string,
1804
+ ) {
1805
+ return app.request(
1806
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}${p.suffix}`,
1807
+ {
1808
+ method: "POST",
1809
+ ...p.init,
1810
+ headers: { ...p.init.headers, Cookie: `admin_session=${cookie}` },
1811
+ },
1812
+ );
1813
+ }
1814
+
1815
+ let userCookie: string;
1816
+ let adminCookie: string;
1817
+ beforeAll(async () => {
1818
+ userCookie = await makeSessionCookie(
1819
+ SESSION_SECRET,
1820
+ "sub-acct-user",
1821
+ "acct-user@example.com",
1822
+ false,
1823
+ );
1824
+ adminCookie = await makeSessionCookie();
1825
+ });
1826
+
1827
+ for (const status of ["suspended", "trial_expired"]) {
1828
+ it(`rejects sends from account users when ${status}, but history still reads`, async () => {
1829
+ const writes = { n: 0 };
1830
+ const app = createAdminUIApp(gateDeps(status, writes));
1831
+ for (const p of SEND_PATHS) {
1832
+ const res = await send(app, p, userCookie);
1833
+ expect(res.status).toBe(403);
1834
+ expect(await res.text()).toContain(
1835
+ status === "suspended" ? "suspended" : "trial has expired",
1836
+ );
1837
+ }
1838
+ expect(writes.n).toBe(0);
1839
+ const history = await app.request(
1840
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}/messages.json`,
1841
+ { headers: { Cookie: `admin_session=${userCookie}` } },
1842
+ );
1843
+ expect(history.status).toBe(200);
1844
+ const thread = await app.request(
1845
+ `/admin/chat/${AGENT_ID}/threads/${THREAD_ID}`,
1846
+ { headers: { Cookie: `admin_session=${userCookie}` } },
1847
+ );
1848
+ expect(thread.status).toBe(200);
1849
+ });
1850
+
1851
+ it(`allows platform admins to send when ${status}`, async () => {
1852
+ const writes = { n: 0 };
1853
+ const app = createAdminUIApp(gateDeps(status, writes));
1854
+ const res = await send(app, SEND_PATHS[1], adminCookie);
1855
+ expect(res.status).toBe(200);
1856
+ expect(writes.n).toBe(1);
1857
+ });
1858
+ }
1859
+
1860
+ it("allows sends when the account is active", async () => {
1861
+ const writes = { n: 0 };
1862
+ const app = createAdminUIApp(gateDeps("active", writes));
1863
+ const res = await send(app, SEND_PATHS[1], userCookie);
1864
+ expect(res.status).toBe(200);
1865
+ expect(writes.n).toBe(1);
1866
+ });
1867
+
1868
+ it("is unaffected for agents with no account", async () => {
1869
+ const writes = { n: 0 };
1870
+ const app = createAdminUIApp(gateDeps("none", writes));
1871
+ const res = await send(app, SEND_PATHS[1], userCookie);
1872
+ expect(res.status).toBe(200);
1873
+ expect(writes.n).toBe(1);
1874
+ });
1875
+ });