@app-vitals/agent-admin 0.0.1 → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Dockerfile +43 -0
- package/README.md +72 -2
- package/e2e/agents-page.e2e.ts +338 -0
- package/e2e/chat-degraded.e2e.ts +153 -0
- package/e2e/chat-page.e2e.ts +169 -0
- package/e2e/chat-progress.e2e.ts +206 -0
- package/e2e/chat-thread-page.e2e.ts +444 -0
- package/e2e/login-page.e2e.ts +148 -0
- package/e2e/test-server.ts +427 -0
- package/openapi.json +6098 -0
- package/openapi.platform.json +5553 -0
- package/package.json +62 -4
- package/playwright.config.ts +25 -0
- package/prisma/client/browser.ts +134 -0
- package/prisma/client/client.ts +158 -0
- package/prisma/client/commonInputTypes.ts +578 -0
- package/prisma/client/enums.ts +15 -0
- package/prisma/client/internal/class.ts +424 -0
- package/prisma/client/internal/prismaNamespace.ts +2898 -0
- package/prisma/client/internal/prismaNamespaceBrowser.ts +476 -0
- package/prisma/client/models/Account.ts +1774 -0
- package/prisma/client/models/AccountInvite.ts +1386 -0
- package/prisma/client/models/AccountMember.ts +1351 -0
- package/prisma/client/models/Agent.ts +4554 -0
- package/prisma/client/models/AgentAsk.ts +1373 -0
- package/prisma/client/models/AgentChatTokenUsageDailyByModel.ts +1638 -0
- package/prisma/client/models/AgentCronJob.ts +2416 -0
- package/prisma/client/models/AgentCronRun.ts +2858 -0
- package/prisma/client/models/AgentCronRunModelBreakdown.ts +1532 -0
- package/prisma/client/models/AgentCronRunSkillUsage.ts +1642 -0
- package/prisma/client/models/AgentEnv.ts +1421 -0
- package/prisma/client/models/AgentGitHubInstallationsSnapshot.ts +1289 -0
- package/prisma/client/models/AgentMember.ts +1316 -0
- package/prisma/client/models/AgentPhaseMethodology.ts +1355 -0
- package/prisma/client/models/AgentPlugin.ts +1421 -0
- package/prisma/client/models/AgentToken.ts +1380 -0
- package/prisma/client/models/AgentTool.ts +1351 -0
- package/prisma/client/models/AgentWorkQueueSnapshot.ts +1297 -0
- package/prisma/client/models/ChatThreadWatch.ts +1183 -0
- package/prisma/client/models/PushSubscription.ts +1233 -0
- package/prisma/client/models/SessionAlertState.ts +1187 -0
- package/prisma/client/models/SessionFollow.ts +1187 -0
- package/prisma/client/models/UserNotificationPrefs.ts +1221 -0
- package/prisma/client/models.ts +34 -0
- package/prisma/migrations/20260608000000_init/migration.sql +112 -0
- package/prisma/migrations/20260616000000_agent_members/migration.sql +18 -0
- package/prisma/migrations/20260624000000_add_agent_self_hosted/migration.sql +2 -0
- package/prisma/migrations/20260624120000_add_agent_repos/migration.sql +3 -0
- package/prisma/migrations/20260625000000_add_agent_cron_run/migration.sql +26 -0
- package/prisma/migrations/20260626000000_add_cron_run_token_fields/migration.sql +10 -0
- package/prisma/migrations/20260627000000_add_agent_chat_token_usage_daily/migration.sql +18 -0
- package/prisma/migrations/20260627010000_add_agent_chat_token_usage_fk/migration.sql +2 -0
- package/prisma/migrations/20260701000000_add_chat_threads_messages/migration.sql +37 -0
- package/prisma/migrations/20260701010000_drop_chat_threads_messages/migration.sql +17 -0
- package/prisma/migrations/20260701020000_add_cron_run_model_breakdown/migration.sql +23 -0
- package/prisma/migrations/20260701020000_chat_token_by_model/migration.sql +25 -0
- package/prisma/migrations/20260701030000_add_secret_flag_to_agent_env/migration.sql +2 -0
- package/prisma/migrations/20260701030000_drop_cron_run_legacy_columns/migration.sql +5 -0
- package/prisma/migrations/20260706000000_consolidate_cron_run_token_accounting/migration.sql +54 -0
- package/prisma/migrations/20260709000000_add_phase_to_agent_cron_run/migration.sql +8 -0
- package/prisma/migrations/20260717000000_add_item_tracking_to_agent_cron_run/migration.sql +10 -0
- package/prisma/migrations/20260717010000_add_agent_work_queue_snapshot/migration.sql +20 -0
- package/prisma/migrations/20260718000000_add_cron_phase_columns/migration.sql +25 -0
- package/prisma/migrations/20260718100000_drop_agent_cron_run_phase_column/migration.sql +8 -0
- package/prisma/migrations/20260724000000_add_agent_type_name/migration.sql +3 -0
- package/prisma/migrations/20260725232433_add_agent_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260727120000_add_not_null_to_agent_author_allowlist/migration.sql +3 -0
- package/prisma/migrations/20260731000000_add_session_id_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260821000000_add_agent_restrict_slack_to_members/migration.sql +3 -0
- package/prisma/migrations/20260901000000_add_push_subscription_and_chat_thread_watch/migration.sql +40 -0
- package/prisma/migrations/20260903000000_add_agent_patch_author_allowlist/migration.sql +2 -0
- package/prisma/migrations/20260903120000_add_agent_review_author_allowlist/migration.sql +18 -0
- package/prisma/migrations/20260903150000_drop_agent_author_allowlist/migration.sql +14 -0
- package/prisma/migrations/20260905000000_add_started_at_index_to_agent_cron_run/migration.sql +11 -0
- package/prisma/migrations/20260906000000_add_session_follow_notification_prefs_alert_state/migration.sql +47 -0
- package/prisma/migrations/20260911000000_backfill_auto_follow_since/migration.sql +16 -0
- package/prisma/migrations/20260923115814_add_last_heartbeat_at_to_agent_cron_run/migration.sql +2 -0
- package/prisma/migrations/20260924120000_add_trial_expiry_fields_to_agent/migration.sql +6 -0
- package/prisma/migrations/20260929000000_add_agent_phase_methodology/migration.sql +21 -0
- package/prisma/migrations/20260929120000_add_agent_policy_fields/migration.sql +15 -0
- package/prisma/migrations/20261005000000_add_agent_github_installations_snapshot/migration.sql +21 -0
- package/prisma/migrations/20261008000000_add_cron_run_telemetry/migration.sql +50 -0
- package/prisma/migrations/20261008010000_add_account_models/migration.sql +48 -0
- package/prisma/migrations/20261008020000_add_cron_lockdown_disabled_at/migration.sql +3 -0
- package/prisma/migrations/20261009000000_chat_token_usage_bigint/migration.sql +7 -0
- package/prisma/migrations/20261010000000_add_cron_run_command_variant/migration.sql +2 -0
- package/prisma/migrations/20261019000000_add_agent_anthropic_identity/migration.sql +9 -0
- package/prisma/migrations/20261025000000_add_agent_ask/migration.sql +23 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema/coding.prisma +85 -0
- package/prisma/schema/schema.prisma +506 -0
- package/prisma.config.ts +34 -0
- package/pwa-assets/icons/apple-touch-icon.png +0 -0
- package/pwa-assets/icons/favicon-32.png +0 -0
- package/pwa-assets/icons/icon-192.png +0 -0
- package/pwa-assets/icons/icon-512.png +0 -0
- package/pwa-assets/icons/icon-maskable-192.png +0 -0
- package/pwa-assets/icons/icon-maskable-512.png +0 -0
- package/src/account-agent-create.ts +42 -0
- package/src/account-agent-create.unit.test.ts +28 -0
- package/src/account-created-notifier.ts +71 -0
- package/src/account-created-notifier.unit.test.ts +70 -0
- package/src/account-email.ts +9 -0
- package/src/account-email.unit.test.ts +21 -0
- package/src/account-invites.ts +70 -0
- package/src/account-lifecycle.integration.test.ts +329 -0
- package/src/account-lifecycle.ts +206 -0
- package/src/account-lifecycle.unit.test.ts +108 -0
- package/src/account-members.ts +128 -0
- package/src/account-onboarding.integration.test.ts +101 -0
- package/src/account-onboarding.ts +95 -0
- package/src/account-trial-expiry-sweeper.ts +85 -0
- package/src/account-trial-warning-sweeper.integration.test.ts +228 -0
- package/src/account-trial-warning-sweeper.ts +216 -0
- package/src/accounts-api.integration.test.ts +196 -0
- package/src/accounts-api.smoke.test.ts +292 -0
- package/src/accounts-api.ts +289 -0
- package/src/accounts-api.unit.test.ts +93 -0
- package/src/accounts.integration.test.ts +178 -0
- package/src/accounts.ts +208 -0
- package/src/admin-boot-config.ts +574 -0
- package/src/admin-boot-config.unit.test.ts +28 -0
- package/src/admin-boot.boot-order.unit.test.ts +91 -0
- package/src/admin-boot.ts +690 -0
- package/src/admin-dev-login.smoke.test.ts +317 -0
- package/src/admin-extension.ts +432 -0
- package/src/admin-extension.unit.test.ts +532 -0
- package/src/admin-local-agent.smoke.test.ts +1643 -0
- package/src/admin-spec.smoke.test.ts +96 -0
- package/src/admin-spec.ts +186 -0
- package/src/admin-ui-account-nav.ts +20 -0
- package/src/admin-ui-account-pages.ts +131 -0
- package/src/admin-ui-account-pages.unit.test.ts +112 -0
- package/src/admin-ui-account.smoke.test.ts +286 -0
- package/src/admin-ui-account.ts +185 -0
- package/src/admin-ui-accounts-pages.ts +202 -0
- package/src/admin-ui-accounts-pages.unit.test.ts +138 -0
- package/src/admin-ui-accounts.smoke.test.ts +434 -0
- package/src/admin-ui-accounts.ts +266 -0
- package/src/admin-ui-layout.ts +60 -0
- package/src/admin-ui-layout.unit.test.ts +140 -0
- package/src/admin-ui-pages.ts +3489 -0
- package/src/admin-ui-pages.unit.test.ts +4355 -0
- package/src/admin-ui-push-test.smoke.test.ts +125 -0
- package/src/admin-ui-push-test.ts +102 -0
- package/src/admin-ui-styles.ts +656 -0
- package/src/admin-ui-styles.unit.test.ts +103 -0
- package/src/admin-ui.fixtures.ts +457 -0
- package/src/admin-ui.smoke.test.ts +7227 -0
- package/src/admin-ui.ts +3824 -0
- package/src/agent-account-assignment.ts +49 -0
- package/src/agent-account-assignment.unit.test.ts +99 -0
- package/src/agent-anthropic-identity.integration.test.ts +99 -0
- package/src/agent-anthropic-identity.ts +78 -0
- package/src/agent-anthropic-identity.unit.test.ts +77 -0
- package/src/agent-ask-config.ts +59 -0
- package/src/agent-ask-config.unit.test.ts +70 -0
- package/src/agent-ask.integration.test.ts +175 -0
- package/src/agent-ask.smoke.test.ts +457 -0
- package/src/agent-ask.ts +378 -0
- package/src/agent-chat-tokens.integration.test.ts +420 -0
- package/src/agent-chat-tokens.smoke.test.ts +551 -0
- package/src/agent-chat-tokens.ts +259 -0
- package/src/agent-cron-jobs.integration.test.ts +582 -0
- package/src/agent-cron-jobs.ts +615 -0
- package/src/agent-cron-jobs.unit.test.ts +420 -0
- package/src/agent-cron-run-stats.integration.test.ts +1551 -0
- package/src/agent-cron-run-stats.smoke.test.ts +953 -0
- package/src/agent-cron-run-stats.ts +752 -0
- package/src/agent-cron-runs.integration.test.ts +1491 -0
- package/src/agent-cron-runs.ts +489 -0
- package/src/agent-deletion-checklist.ts +128 -0
- package/src/agent-deletion-checklist.unit.test.ts +201 -0
- package/src/agent-deletion-per-agent.integration.test.ts +323 -0
- package/src/agent-deletion.integration.test.ts +323 -0
- package/src/agent-deletion.ts +329 -0
- package/src/agent-deletion.unit.test.ts +412 -0
- package/src/agent-envs.integration.test.ts +323 -0
- package/src/agent-envs.ts +244 -0
- package/src/agent-github-installations.integration.test.ts +115 -0
- package/src/agent-github-installations.ts +52 -0
- package/src/agent-manifest.ts +685 -0
- package/src/agent-manifest.unit.test.ts +870 -0
- package/src/agent-members.ts +69 -0
- package/src/agent-members.unit.test.ts +250 -0
- package/src/agent-patch-author-allowlist.integration.test.ts +218 -0
- package/src/agent-plugins.integration.test.ts +157 -0
- package/src/agent-plugins.ts +95 -0
- package/src/agent-plugins.unit.test.ts +43 -0
- package/src/agent-provisioner-per-agent.integration.test.ts +949 -0
- package/src/agent-provisioner.integration.test.ts +1245 -0
- package/src/agent-provisioner.ts +1650 -0
- package/src/agent-provisioner.unit.test.ts +1852 -0
- package/src/agent-tokens.integration.test.ts +150 -0
- package/src/agent-tokens.ts +112 -0
- package/src/agent-tools.integration.test.ts +129 -0
- package/src/agent-tools.ts +111 -0
- package/src/agents-api.integration.test.ts +705 -0
- package/src/agents-api.sentry.smoke.test.ts +478 -0
- package/src/agents-api.smoke.test.ts +5986 -0
- package/src/agents-api.ts +2826 -0
- package/src/agents.integration.test.ts +512 -0
- package/src/agents.ts +1097 -0
- package/src/agents.unit.test.ts +1736 -0
- package/src/anthropic-credential-guard.ts +44 -0
- package/src/anthropic-credential-guard.unit.test.ts +88 -0
- package/src/anthropic-federated-token-provider.integration.test.ts +248 -0
- package/src/anthropic-federated-token-provider.ts +249 -0
- package/src/anthropic-identity-client.fixtures.ts +237 -0
- package/src/anthropic-identity-client.integration.test.ts +342 -0
- package/src/anthropic-identity-client.ts +473 -0
- package/src/anthropic-identity-fixture-spec.integration.test.ts +169 -0
- package/src/anthropic-identity-name.integration.test.ts +83 -0
- package/src/anthropic-identity-name.ts +163 -0
- package/src/anthropic-identity-name.unit.test.ts +345 -0
- package/src/anthropic-openapi-contract.fixtures.ts +192 -0
- package/src/api-auth.ts +383 -0
- package/src/api-auth.unit.test.ts +837 -0
- package/src/api.smoke.test.ts +1225 -0
- package/src/api.ts +376 -0
- package/src/attachment-validation.ts +124 -0
- package/src/attachment-validation.unit.test.ts +218 -0
- package/src/caller-scope.ts +131 -0
- package/src/caller-scope.unit.test.ts +116 -0
- package/src/chat-credential-config.ts +22 -0
- package/src/chat-credentials-logging.unit.test.ts +92 -0
- package/src/chat-markers.ts +114 -0
- package/src/chat-markers.unit.test.ts +116 -0
- package/src/chat-service-provisioning-client.integration.test.ts +412 -0
- package/src/chat-service-provisioning-client.ts +221 -0
- package/src/chat.smoke.test.ts +1875 -0
- package/src/clock.ts +47 -0
- package/src/coding-task-store-provisioning-client.integration.test.ts +372 -0
- package/src/coding-task-store-provisioning-client.ts +156 -0
- package/src/crypto.ts +83 -0
- package/src/dev-auth-guard.ts +29 -0
- package/src/dev-auth-guard.unit.test.ts +51 -0
- package/src/errors.ts +77 -0
- package/src/errors.unit.test.ts +52 -0
- package/src/fixtures/anthropic-identity-recorded-fixtures.json +850 -0
- package/src/fixtures/anthropic-openapi/SDK_VERSION +1 -0
- package/src/fixtures/anthropic-openapi/mock-spec.json.gz +0 -0
- package/src/fixtures/chat-service-provisioning-cassette.json +156 -0
- package/src/fixtures/github-app-manifest-cassette.json +44 -0
- package/src/fixtures/google-auth-cassette.json +36 -0
- package/src/fixtures/http-chat-client-cassette.json +226 -0
- package/src/fixtures/k8s-cassette.json +470 -0
- package/src/fixtures/okta-auth-cassette.json +36 -0
- package/src/fixtures/slack-provision-cassette.json +12 -0
- package/src/fixtures/slack-provisioning-http-cassette.json +98 -0
- package/src/fixtures/task-store-provisioning-cassette.json +85 -0
- package/src/github-app-provisioning-client.integration.test.ts +149 -0
- package/src/github-app-provisioning-client.ts +161 -0
- package/src/github-app-provisioning-client.unit.test.ts +114 -0
- package/src/github-provisioning-service.ts +569 -0
- package/src/github-provisioning-service.unit.test.ts +123 -0
- package/src/google-auth-client.integration.test.ts +195 -0
- package/src/google-auth-client.ts +121 -0
- package/src/http-chat-client.integration.test.ts +554 -0
- package/src/http-chat-client.ts +533 -0
- package/src/http-chat-client.unit.test.ts +119 -0
- package/src/import-boundary.unit.test.ts +240 -0
- package/src/kubernetes-client.integration.test.ts +697 -0
- package/src/kubernetes-client.ts +1027 -0
- package/src/kubernetes-client.unit.test.ts +726 -0
- package/src/main.smoke.test.ts +91 -0
- package/src/main.ts +21 -0
- package/src/main.unit.test.ts +801 -0
- package/src/okta-auth-client.integration.test.ts +263 -0
- package/src/okta-auth-client.ts +152 -0
- package/src/openapi-schemas.ts +1310 -0
- package/src/openapi-schemas.unit.test.ts +348 -0
- package/src/per-agent-identity.fixtures.ts +305 -0
- package/src/platform-doc-route.ts +40 -0
- package/src/prisma-client.integration.test.ts +57 -0
- package/src/prisma-client.ts +187 -0
- package/src/prisma-client.unit.test.ts +178 -0
- package/src/prisma-tx.ts +23 -0
- package/src/push-content.ts +177 -0
- package/src/push-content.unit.test.ts +213 -0
- package/src/push-crypto.ts +135 -0
- package/src/push-crypto.unit.test.ts +66 -0
- package/src/push-sender.ts +169 -0
- package/src/push-sender.unit.test.ts +107 -0
- package/src/push-service.ts +332 -0
- package/src/push-service.unit.test.ts +679 -0
- package/src/push-toggle.ts +135 -0
- package/src/push-toggle.unit.test.ts +78 -0
- package/src/pwa-https-gating.smoke.test.ts +229 -0
- package/src/pwa-icons.unit.test.ts +41 -0
- package/src/pwa-routes.smoke.test.ts +276 -0
- package/src/pwa.ts +380 -0
- package/src/pwa.unit.test.ts +321 -0
- package/src/schema.unit.test.ts +122 -0
- package/src/self-serve-config.ts +52 -0
- package/src/self-serve-config.unit.test.ts +79 -0
- package/src/server.smoke.test.ts +273 -0
- package/src/server.ts +50 -0
- package/src/service-integration.ts +67 -0
- package/src/service-integration.unit.test.ts +129 -0
- package/src/slack-provisioning-client.integration.test.ts +308 -0
- package/src/slack-provisioning-client.ts +411 -0
- package/src/slack-provisioning-client.unit.test.ts +80 -0
- package/src/slack-provisioning-service.integration.test.ts +637 -0
- package/src/slack-provisioning-service.ts +451 -0
- package/src/slack-provisioning.integration.test.ts +395 -0
- package/src/specialty-manifest-loader.fixture.integration.test.ts +63 -0
- package/src/specialty-manifest-loader.ts +255 -0
- package/src/specialty-manifest-loader.unit.test.ts +301 -0
- package/src/specialty-registry.schema.unit.test.ts +35 -0
- package/src/specialty-registry.ts +236 -0
- package/src/specialty-registry.unit.test.ts +272 -0
- package/src/startup-policy-reconcile.ts +77 -0
- package/src/startup-policy-reconcile.unit.test.ts +251 -0
- package/src/theme-tokens.json +12 -0
- package/src/token-crypto.ts +60 -0
- package/src/token-crypto.unit.test.ts +182 -0
- package/src/trial-expiry-sweeper.integration.test.ts +318 -0
- package/src/trial-expiry-sweeper.ts +132 -0
- package/src/trial-expiry-sweeper.unit.test.ts +391 -0
- package/src/trial-expiry-warning-sweeper.integration.test.ts +468 -0
- package/src/trial-expiry-warning-sweeper.ts +447 -0
- package/src/trial-expiry-warning-sweeper.unit.test.ts +148 -0
- package/src/voice-contract.unit.test.ts +102 -0
- 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
|
+
});
|