@oneuptime/common 12.0.18 → 12.0.19

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 (206) hide show
  1. package/Models/DatabaseModels/EnterpriseLicense.ts +23 -0
  2. package/Models/DatabaseModels/Index.ts +4 -2
  3. package/Models/DatabaseModels/Monitor.ts +39 -9
  4. package/Models/DatabaseModels/UserMicrosoftTeams.ts +344 -0
  5. package/Models/DatabaseModels/UserNotificationRule.ts +144 -8
  6. package/Models/DatabaseModels/UserNotificationSetting.ts +34 -0
  7. package/Models/DatabaseModels/UserOnCallLogTimeline.ts +95 -0
  8. package/Models/DatabaseModels/UserSlack.ts +341 -0
  9. package/Models/DatabaseModels/WorkspaceProjectAuthToken.ts +9 -0
  10. package/Server/API/UserMicrosoftTeamsAPI.ts +127 -0
  11. package/Server/API/UserSlackAPI.ts +125 -0
  12. package/Server/EnvironmentConfig.ts +12 -107
  13. package/Server/Infrastructure/Postgres/SchemaMigrations/1788700000000-RemoveMarketingConversionUploadState.ts +33 -0
  14. package/Server/Infrastructure/Postgres/SchemaMigrations/1788800000000-DropMarketingConversionAddEnterpriseLicenseEmail.ts +51 -0
  15. package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +117 -0
  16. package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +197 -0
  17. package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +8 -0
  18. package/Server/Infrastructure/Queue.ts +7 -0
  19. package/Server/Services/EnterpriseLicenseService.ts +60 -0
  20. package/Server/Services/Index.ts +6 -2
  21. package/Server/Services/LlmLogService.ts +25 -9
  22. package/Server/Services/OnCallReadinessService.ts +86 -2
  23. package/Server/Services/ProjectService.ts +67 -0
  24. package/Server/Services/UserMicrosoftTeamsService.ts +208 -0
  25. package/Server/Services/UserNotificationMethodAdminService.ts +167 -5
  26. package/Server/Services/UserNotificationRuleAdminService.ts +40 -6
  27. package/Server/Services/UserNotificationRuleService.ts +589 -11
  28. package/Server/Services/UserNotificationSettingService.ts +138 -0
  29. package/Server/Services/UserService.ts +28 -0
  30. package/Server/Services/UserSlackService.ts +218 -0
  31. package/Server/Services/WorkspaceProjectAuthTokenService.ts +67 -1
  32. package/Server/Services/WorkspaceUserAuthTokenService.ts +73 -0
  33. package/Server/Services/WorkspaceUserNotificationService.ts +190 -0
  34. package/Server/Utils/Marketing/MarketingEventUtil.ts +145 -0
  35. package/Server/Utils/Marketing/MarketingEventWebhook.ts +114 -0
  36. package/Server/Utils/Monitor/MonitorLogUtil.ts +14 -1
  37. package/Server/Utils/Monitor/MonitorPayloadRedaction.ts +321 -0
  38. package/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.ts +191 -4
  39. package/Tests/App/Dashboard/AdminNotificationRulesPage.test.tsx +22 -14
  40. package/Tests/App/Dashboard/AdminUserNotificationMethodsPage.test.tsx +22 -11
  41. package/Tests/App/Dashboard/OnCallPreventionGuards.test.tsx +37 -1
  42. package/Tests/App/Dashboard/OnCallRulesTable.test.tsx +18 -10
  43. package/Tests/Models/DatabaseModels/DomainRoleTierCoverage.test.ts +27 -1
  44. package/Tests/Models/DatabaseModels/MonitorSecretKeyColumnAccessControl.test.ts +214 -0
  45. package/Tests/Server/API/OnCallReadinessAPI.test.ts +145 -26
  46. package/Tests/Server/API/UserNotificationMethodAdminAPI.test.ts +2 -2
  47. package/Tests/Server/Services/AIServiceDailyBudget.test.ts +11 -1
  48. package/Tests/Server/Services/AdminRuleEditGuards.test.ts +22 -8
  49. package/Tests/Server/Services/DeliverNotificationForRuleExtraction.test.ts +18 -2
  50. package/Tests/Server/Services/EpisodeRuleSeverityRepair.test.ts +5 -1
  51. package/Tests/Server/Services/LlmLogServiceTokenAggregateParams.test.ts +640 -0
  52. package/Tests/Server/Services/NotificationChannelEventCoverage.test.ts +246 -22
  53. package/Tests/Server/Services/NotificationDeletionImpact.test.ts +136 -8
  54. package/Tests/Server/Services/OnCallNotificationFallback.test.ts +191 -8
  55. package/Tests/Server/Services/OnCallReadinessService.test.ts +343 -18
  56. package/Tests/Server/Services/SeverityRuleBackfill.test.ts +5 -1
  57. package/Tests/Server/Services/UserMicrosoftTeamsService.test.ts +343 -0
  58. package/Tests/Server/Services/UserNotificationMethodAdminService.test.ts +209 -8
  59. package/Tests/Server/Services/UserNotificationRuleAdminGuards.test.ts +22 -6
  60. package/Tests/Server/Services/UserNotificationRuleDefaultCreation.test.ts +12 -6
  61. package/Tests/Server/Services/UserNotificationRuleExecuteItem.test.ts +37 -3
  62. package/Tests/Server/Services/UserNotificationRuleWorkspaceDelivery.test.ts +669 -0
  63. package/Tests/Server/Services/UserNotificationSettingWorkspaceChannels.test.ts +410 -0
  64. package/Tests/Server/Services/UserSlackService.test.ts +407 -0
  65. package/Tests/Server/Services/WorkspaceProjectAuthTokenDisconnectCascade.test.ts +224 -0
  66. package/Tests/Server/Services/WorkspaceUserAuthTokenNotificationMethodCascade.test.ts +189 -0
  67. package/Tests/Server/Services/WorkspaceUserNotificationService.test.ts +496 -0
  68. package/Tests/Server/Types/Database/Permissions/AdminNotificationRuleAccess.test.ts +30 -17
  69. package/Tests/Server/Types/Database/Permissions/CreateOwnershipScoping.test.ts +8 -3
  70. package/Tests/Server/Types/Database/Permissions/OwnerOnlyColumns.test.ts +72 -3
  71. package/Tests/Server/Types/Database/Permissions/UserNotificationRuleScoping.test.ts +22 -18
  72. package/Tests/Server/Types/Database/Permissions/WorkspaceMethodStampedColumnCreate.test.ts +178 -0
  73. package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +259 -0
  74. package/Tests/Server/Utils/Marketing/MarketingEventWebhook.test.ts +257 -0
  75. package/Tests/Server/Utils/Monitor/MonitorLogUtilRedaction.test.ts +300 -0
  76. package/Tests/Server/Utils/Monitor/MonitorPayloadRedaction.test.ts +381 -0
  77. package/Tests/Server/Utils/Runbook/RunbookExecutePermission.test.ts +228 -0
  78. package/Tests/Server/Utils/SessionReplay/SessionReplayErasureTombstone.test.ts +189 -0
  79. package/Tests/Server/Utils/SessionReplay/SessionReplayUsage.test.ts +205 -0
  80. package/Tests/Server/Utils/Telemetry/AppMetrics.test.ts +275 -0
  81. package/Tests/Server/Utils/Telemetry/TelemetryContext.test.ts +279 -0
  82. package/Tests/Server/Utils/Workspace/MicrosoftTeamsDirectMessage.test.ts +454 -0
  83. package/Tests/UI/Utils/NotificationMethodUtil.test.ts +137 -9
  84. package/Tests/UI/Utils/PermissionGate.test.ts +105 -0
  85. package/Types/Marketing/MarketingEvent.ts +97 -0
  86. package/UI/Utils/NotificationMethodUtil.ts +41 -1
  87. package/UI/Utils/PermissionGate.ts +56 -0
  88. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js +24 -0
  89. package/build/dist/Models/DatabaseModels/EnterpriseLicense.js.map +1 -1
  90. package/build/dist/Models/DatabaseModels/Index.js +4 -2
  91. package/build/dist/Models/DatabaseModels/Index.js.map +1 -1
  92. package/build/dist/Models/DatabaseModels/Monitor.js +39 -9
  93. package/build/dist/Models/DatabaseModels/Monitor.js.map +1 -1
  94. package/build/dist/Models/DatabaseModels/UserMicrosoftTeams.js +363 -0
  95. package/build/dist/Models/DatabaseModels/UserMicrosoftTeams.js.map +1 -0
  96. package/build/dist/Models/DatabaseModels/UserNotificationRule.js +145 -8
  97. package/build/dist/Models/DatabaseModels/UserNotificationRule.js.map +1 -1
  98. package/build/dist/Models/DatabaseModels/UserNotificationSetting.js +38 -0
  99. package/build/dist/Models/DatabaseModels/UserNotificationSetting.js.map +1 -1
  100. package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js +96 -0
  101. package/build/dist/Models/DatabaseModels/UserOnCallLogTimeline.js.map +1 -1
  102. package/build/dist/Models/DatabaseModels/UserSlack.js +361 -0
  103. package/build/dist/Models/DatabaseModels/UserSlack.js.map +1 -0
  104. package/build/dist/Models/DatabaseModels/WorkspaceProjectAuthToken.js.map +1 -1
  105. package/build/dist/Server/API/UserMicrosoftTeamsAPI.js +82 -0
  106. package/build/dist/Server/API/UserMicrosoftTeamsAPI.js.map +1 -0
  107. package/build/dist/Server/API/UserSlackAPI.js +81 -0
  108. package/build/dist/Server/API/UserSlackAPI.js.map +1 -0
  109. package/build/dist/Server/EnvironmentConfig.js +10 -70
  110. package/build/dist/Server/EnvironmentConfig.js.map +1 -1
  111. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788700000000-RemoveMarketingConversionUploadState.js +26 -0
  112. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788700000000-RemoveMarketingConversionUploadState.js.map +1 -0
  113. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788800000000-DropMarketingConversionAddEnterpriseLicenseEmail.js +37 -0
  114. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788800000000-DropMarketingConversionAddEnterpriseLicenseEmail.js.map +1 -0
  115. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +106 -0
  116. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +1 -0
  117. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +78 -0
  118. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -0
  119. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +8 -0
  120. package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
  121. package/build/dist/Server/Infrastructure/Queue.js +7 -0
  122. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  123. package/build/dist/Server/Services/EnterpriseLicenseService.js +65 -0
  124. package/build/dist/Server/Services/EnterpriseLicenseService.js.map +1 -1
  125. package/build/dist/Server/Services/Index.js +6 -2
  126. package/build/dist/Server/Services/Index.js.map +1 -1
  127. package/build/dist/Server/Services/LlmLogService.js +25 -9
  128. package/build/dist/Server/Services/LlmLogService.js.map +1 -1
  129. package/build/dist/Server/Services/OnCallReadinessService.js +74 -2
  130. package/build/dist/Server/Services/OnCallReadinessService.js.map +1 -1
  131. package/build/dist/Server/Services/ProjectService.js +56 -0
  132. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  133. package/build/dist/Server/Services/UserMicrosoftTeamsService.js +183 -0
  134. package/build/dist/Server/Services/UserMicrosoftTeamsService.js.map +1 -0
  135. package/build/dist/Server/Services/UserNotificationMethodAdminService.js +138 -2
  136. package/build/dist/Server/Services/UserNotificationMethodAdminService.js.map +1 -1
  137. package/build/dist/Server/Services/UserNotificationRuleAdminService.js +32 -4
  138. package/build/dist/Server/Services/UserNotificationRuleAdminService.js.map +1 -1
  139. package/build/dist/Server/Services/UserNotificationRuleService.js +499 -67
  140. package/build/dist/Server/Services/UserNotificationRuleService.js.map +1 -1
  141. package/build/dist/Server/Services/UserNotificationSettingService.js +118 -0
  142. package/build/dist/Server/Services/UserNotificationSettingService.js.map +1 -1
  143. package/build/dist/Server/Services/UserService.js +26 -1
  144. package/build/dist/Server/Services/UserService.js.map +1 -1
  145. package/build/dist/Server/Services/UserSlackService.js +195 -0
  146. package/build/dist/Server/Services/UserSlackService.js.map +1 -0
  147. package/build/dist/Server/Services/WorkspaceProjectAuthTokenService.js +64 -1
  148. package/build/dist/Server/Services/WorkspaceProjectAuthTokenService.js.map +1 -1
  149. package/build/dist/Server/Services/WorkspaceUserAuthTokenService.js +70 -0
  150. package/build/dist/Server/Services/WorkspaceUserAuthTokenService.js.map +1 -1
  151. package/build/dist/Server/Services/WorkspaceUserNotificationService.js +129 -0
  152. package/build/dist/Server/Services/WorkspaceUserNotificationService.js.map +1 -0
  153. package/build/dist/Server/Utils/Marketing/MarketingEventUtil.js +94 -0
  154. package/build/dist/Server/Utils/Marketing/MarketingEventUtil.js.map +1 -0
  155. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +93 -0
  156. package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -0
  157. package/build/dist/Server/Utils/Monitor/MonitorLogUtil.js +12 -1
  158. package/build/dist/Server/Utils/Monitor/MonitorLogUtil.js.map +1 -1
  159. package/build/dist/Server/Utils/Monitor/MonitorPayloadRedaction.js +233 -0
  160. package/build/dist/Server/Utils/Monitor/MonitorPayloadRedaction.js.map +1 -0
  161. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js +141 -4
  162. package/build/dist/Server/Utils/Workspace/MicrosoftTeams/MicrosoftTeams.js.map +1 -1
  163. package/build/dist/Types/Marketing/MarketingEvent.js +43 -0
  164. package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -0
  165. package/build/dist/UI/Utils/NotificationMethodUtil.js +33 -1
  166. package/build/dist/UI/Utils/NotificationMethodUtil.js.map +1 -1
  167. package/build/dist/UI/Utils/PermissionGate.js +32 -0
  168. package/build/dist/UI/Utils/PermissionGate.js.map +1 -1
  169. package/package.json +1 -1
  170. package/Models/DatabaseModels/MarketingConversion.ts +0 -410
  171. package/Server/Services/MarketingConversionService.ts +0 -10
  172. package/Server/Utils/Marketing/ConversionUploadProvider.ts +0 -215
  173. package/Server/Utils/Marketing/ConversionUploadProviders.ts +0 -22
  174. package/Server/Utils/Marketing/Providers/GoogleAds.ts +0 -333
  175. package/Server/Utils/Marketing/Providers/LinkedIn.ts +0 -185
  176. package/Server/Utils/Marketing/Providers/Meta.ts +0 -182
  177. package/Server/Utils/Marketing/Providers/MicrosoftAds.ts +0 -223
  178. package/Server/Utils/Marketing/Providers/Reddit.ts +0 -208
  179. package/Tests/Server/Utils/Marketing/AdUploadableConversionTypes.test.ts +0 -315
  180. package/Tests/Server/Utils/Marketing/ConversionUploadProvider.test.ts +0 -300
  181. package/Tests/Server/Utils/Marketing/GoogleAds.test.ts +0 -592
  182. package/Tests/Server/Utils/Marketing/LinkedIn.test.ts +0 -282
  183. package/Tests/Server/Utils/Marketing/Meta.test.ts +0 -304
  184. package/Tests/Server/Utils/Marketing/MicrosoftAds.test.ts +0 -263
  185. package/Tests/Server/Utils/Marketing/Reddit.test.ts +0 -259
  186. package/Types/Marketing/MarketingConversion.ts +0 -53
  187. package/build/dist/Models/DatabaseModels/MarketingConversion.js +0 -445
  188. package/build/dist/Models/DatabaseModels/MarketingConversion.js.map +0 -1
  189. package/build/dist/Server/Services/MarketingConversionService.js +0 -9
  190. package/build/dist/Server/Services/MarketingConversionService.js.map +0 -1
  191. package/build/dist/Server/Utils/Marketing/ConversionUploadProvider.js +0 -93
  192. package/build/dist/Server/Utils/Marketing/ConversionUploadProvider.js.map +0 -1
  193. package/build/dist/Server/Utils/Marketing/ConversionUploadProviders.js +0 -20
  194. package/build/dist/Server/Utils/Marketing/ConversionUploadProviders.js.map +0 -1
  195. package/build/dist/Server/Utils/Marketing/Providers/GoogleAds.js +0 -221
  196. package/build/dist/Server/Utils/Marketing/Providers/GoogleAds.js.map +0 -1
  197. package/build/dist/Server/Utils/Marketing/Providers/LinkedIn.js +0 -137
  198. package/build/dist/Server/Utils/Marketing/Providers/LinkedIn.js.map +0 -1
  199. package/build/dist/Server/Utils/Marketing/Providers/Meta.js +0 -133
  200. package/build/dist/Server/Utils/Marketing/Providers/Meta.js.map +0 -1
  201. package/build/dist/Server/Utils/Marketing/Providers/MicrosoftAds.js +0 -143
  202. package/build/dist/Server/Utils/Marketing/Providers/MicrosoftAds.js.map +0 -1
  203. package/build/dist/Server/Utils/Marketing/Providers/Reddit.js +0 -145
  204. package/build/dist/Server/Utils/Marketing/Providers/Reddit.js.map +0 -1
  205. package/build/dist/Types/Marketing/MarketingConversion.js +0 -52
  206. package/build/dist/Types/Marketing/MarketingConversion.js.map +0 -1
@@ -1,592 +0,0 @@
1
- import Attribution from "../../../../Server/Utils/Attribution";
2
- import MarketingConversion from "../../../../Models/DatabaseModels/MarketingConversion";
3
- import logger from "../../../../Server/Utils/Logger";
4
- import GoogleAdsProvider from "../../../../Server/Utils/Marketing/Providers/GoogleAds";
5
- import { ConversionUploadBatchResult } from "../../../../Server/Utils/Marketing/ConversionUploadProvider";
6
- import { JSONObject } from "../../../../Types/JSON";
7
- import { MarketingConversionType } from "../../../../Types/Marketing/MarketingConversion";
8
- import axios, { AxiosResponse } from "axios";
9
- import {
10
- afterEach,
11
- beforeEach,
12
- describe,
13
- expect,
14
- jest,
15
- test,
16
- } from "@jest/globals";
17
- import { SpyInstance } from "jest-mock";
18
-
19
- /*
20
- * Enhanced conversions for leads is an account-level Google setting as much as
21
- * a OneUptime one, so the provider reads a flag for it. Both states have to be
22
- * reachable from this file, which a plain constant on the mock cannot do — the
23
- * provider imports the binding once. A getter can.
24
- */
25
- let mockEnhancedConversionsForLeadsEnabled: boolean = false;
26
-
27
- jest.mock("../../../../Server/EnvironmentConfig", () => {
28
- const mocked: Record<string, unknown> = {
29
- GoogleAdsApiVersion: "v23",
30
- GoogleAdsCustomerId: "1234567890",
31
- GoogleAdsDeveloperToken: "developer-token",
32
- GoogleAdsLoginCustomerId: "0987654321",
33
- GoogleAdsOAuthClientId: "oauth-client-id",
34
- GoogleAdsOAuthClientSecret: "oauth-client-secret",
35
- GoogleAdsOAuthRefreshToken: "oauth-refresh-token",
36
- GoogleAdsPaidSubscriptionConversionActionId: "222222",
37
- GoogleAdsSignUpConversionActionId: "111111",
38
- GoogleAdsMeetingBookedConversionActionId: "333333",
39
- };
40
-
41
- Object.defineProperty(mocked, "GoogleAdsEnhancedConversionsForLeadsEnabled", {
42
- get: (): boolean => {
43
- return mockEnhancedConversionsForLeadsEnabled;
44
- },
45
- });
46
-
47
- return mocked;
48
- });
49
-
50
- jest.mock("../../../../Server/Utils/Logger", () => {
51
- return {
52
- __esModule: true,
53
- default: {
54
- error: jest.fn(),
55
- },
56
- };
57
- });
58
-
59
- const makeConversion: (
60
- data?: Partial<MarketingConversion>,
61
- ) => MarketingConversion = (
62
- data: Partial<MarketingConversion> = {},
63
- ): MarketingConversion => {
64
- return Object.assign(new MarketingConversion(), {
65
- conversionType: MarketingConversionType.SignUp,
66
- conversionAt: new Date("2026-07-22T10:11:12.345Z"),
67
- clickIds: { gclid: "google-click" },
68
- ...data,
69
- });
70
- };
71
-
72
- const response: (data: JSONObject) => AxiosResponse<JSONObject> = (
73
- data: JSONObject,
74
- ): AxiosResponse<JSONObject> => {
75
- return { data } as AxiosResponse<JSONObject>;
76
- };
77
-
78
- describe("GoogleAdsProvider", () => {
79
- let provider: GoogleAdsProvider;
80
- let postSpy: SpyInstance<any>;
81
-
82
- beforeEach(() => {
83
- provider = new GoogleAdsProvider();
84
- postSpy = jest.spyOn(axios, "post") as SpyInstance<any>;
85
- mockEnhancedConversionsForLeadsEnabled = false;
86
- });
87
-
88
- afterEach(() => {
89
- jest.restoreAllMocks();
90
- });
91
-
92
- test("reports configured only when the OAuth and account settings exist", () => {
93
- expect(provider.isConfigured()).toBe(true);
94
- });
95
-
96
- test("permanently skips conversions without a Google click identifier", () => {
97
- expect(
98
- provider.getSkipReason(makeConversion({ clickIds: { fbclid: "meta" } })),
99
- ).toEqual({
100
- reason: "No Google click id (gclid/wbraid/gbraid)",
101
- isPermanent: true,
102
- });
103
- });
104
-
105
- test.each([
106
- ["gclid", "standard-click"],
107
- ["wbraid", "web-to-app-click"],
108
- ["gbraid", "app-to-web-click"],
109
- ])("accepts the supported %s identifier", (key: string, value: string) => {
110
- expect(
111
- provider.getSkipReason(makeConversion({ clickIds: { [key]: value } })),
112
- ).toBeNull();
113
- });
114
-
115
- test("prefers gclid when multiple Google identifiers are present", async () => {
116
- postSpy
117
- .mockResolvedValueOnce(
118
- response({ access_token: "access-token", expires_in: 3600 }) as never,
119
- )
120
- .mockResolvedValueOnce(response({}) as never);
121
-
122
- await provider.upload([
123
- makeConversion({
124
- clickIds: {
125
- gclid: "preferred-gclid",
126
- wbraid: "secondary-wbraid",
127
- gbraid: "secondary-gbraid",
128
- },
129
- }),
130
- ]);
131
-
132
- const uploadBody: JSONObject = postSpy.mock.calls[1]?.[1] as JSONObject;
133
- const uploadedConversion: JSONObject = (
134
- uploadBody["conversions"] as Array<JSONObject>
135
- )[0]!;
136
-
137
- expect(uploadedConversion).toMatchObject({ gclid: "preferred-gclid" });
138
- expect(uploadedConversion["wbraid"]).toBeUndefined();
139
- expect(uploadedConversion["gbraid"]).toBeUndefined();
140
- });
141
-
142
- test("permanently skips conversions outside Google's 90-day window", () => {
143
- jest
144
- .spyOn(Date, "now")
145
- .mockReturnValue(new Date("2026-07-22T12:00:00.000Z").getTime());
146
-
147
- expect(
148
- provider.getSkipReason(
149
- makeConversion({
150
- conversionAt: new Date("2026-04-01T00:00:00.000Z"),
151
- }),
152
- ),
153
- ).toEqual({
154
- reason: "Conversion older than Google's 90-day click window",
155
- isPermanent: true,
156
- });
157
- });
158
-
159
- test("uploads signup and paid conversions with the correct actions and value", async () => {
160
- postSpy
161
- .mockResolvedValueOnce(
162
- response({ access_token: "access-token", expires_in: 3600 }) as never,
163
- )
164
- .mockResolvedValueOnce(response({}) as never);
165
-
166
- const result: ConversionUploadBatchResult = await provider.upload([
167
- makeConversion({
168
- conversionType: MarketingConversionType.SignUp,
169
- clickIds: { wbraid: "signup-wbraid" },
170
- }),
171
- makeConversion({
172
- conversionType: MarketingConversionType.PaidSubscription,
173
- clickIds: { gbraid: "paid-gbraid" },
174
- conversionValueInUSDCents: 12999,
175
- }),
176
- ]);
177
-
178
- expect(result.permanentFailures.size).toBe(0);
179
- expect(postSpy).toHaveBeenCalledTimes(2);
180
- expect(postSpy.mock.calls[0]?.[0]).toBe(
181
- "https://oauth2.googleapis.com/token",
182
- );
183
- expect(postSpy.mock.calls[0]?.[1]).toContain(
184
- "refresh_token=oauth-refresh-token",
185
- );
186
-
187
- const uploadUrl: string = postSpy.mock.calls[1]?.[0] as string;
188
- const uploadBody: JSONObject = postSpy.mock.calls[1]?.[1] as JSONObject;
189
- const uploadOptions: JSONObject = postSpy.mock.calls[1]?.[2] as JSONObject;
190
- const conversions: Array<JSONObject> = uploadBody[
191
- "conversions"
192
- ] as Array<JSONObject>;
193
-
194
- expect(uploadUrl).toBe(
195
- "https://googleads.googleapis.com/v23/customers/1234567890:uploadClickConversions",
196
- );
197
- expect(uploadBody["partialFailure"]).toBe(true);
198
- expect(conversions[0]).toEqual({
199
- wbraid: "signup-wbraid",
200
- conversionAction: "customers/1234567890/conversionActions/111111",
201
- conversionDateTime: "2026-07-22 10:11:12+00:00",
202
- });
203
- expect(conversions[1]).toEqual({
204
- gbraid: "paid-gbraid",
205
- conversionAction: "customers/1234567890/conversionActions/222222",
206
- conversionDateTime: "2026-07-22 10:11:12+00:00",
207
- conversionValue: 129.99,
208
- currencyCode: "USD",
209
- });
210
- expect(uploadOptions).toMatchObject({
211
- headers: {
212
- Authorization: "Bearer access-token",
213
- "developer-token": "developer-token",
214
- "login-customer-id": "0987654321",
215
- },
216
- timeout: 30000,
217
- });
218
- });
219
-
220
- test("does not send zero or negative conversion values", async () => {
221
- postSpy
222
- .mockResolvedValueOnce(
223
- response({ access_token: "access-token", expires_in: 3600 }) as never,
224
- )
225
- .mockResolvedValueOnce(response({}) as never);
226
-
227
- await provider.upload([
228
- makeConversion({ conversionValueInUSDCents: 0 }),
229
- makeConversion({ conversionValueInUSDCents: -100 }),
230
- ]);
231
-
232
- const uploadBody: JSONObject = postSpy.mock.calls[1]?.[1] as JSONObject;
233
- const conversions: Array<JSONObject> = uploadBody[
234
- "conversions"
235
- ] as Array<JSONObject>;
236
-
237
- expect(conversions[0]?.["conversionValue"]).toBeUndefined();
238
- expect(conversions[0]?.["currencyCode"]).toBeUndefined();
239
- expect(conversions[1]?.["conversionValue"]).toBeUndefined();
240
- });
241
-
242
- test("reuses an unexpired OAuth access token across batches", async () => {
243
- postSpy
244
- .mockResolvedValueOnce(
245
- response({ access_token: "cached-token", expires_in: 3600 }) as never,
246
- )
247
- .mockResolvedValue(response({}) as never);
248
-
249
- await provider.upload([makeConversion()]);
250
- await provider.upload([makeConversion({ clickIds: { gclid: "second" } })]);
251
-
252
- expect(postSpy).toHaveBeenCalledTimes(3);
253
- expect(
254
- postSpy.mock.calls.filter((call: Array<unknown>) => {
255
- return call[0] === "https://oauth2.googleapis.com/token";
256
- }),
257
- ).toHaveLength(1);
258
- });
259
-
260
- test("maps indexed partial failures to the submitted conversion", async () => {
261
- postSpy
262
- .mockResolvedValueOnce(
263
- response({ access_token: "access-token", expires_in: 3600 }) as never,
264
- )
265
- .mockResolvedValueOnce(
266
- response({
267
- partialFailureError: {
268
- details: [
269
- {
270
- errors: [
271
- {
272
- message: "Expired click identifier",
273
- location: {
274
- fieldPathElements: [
275
- { fieldName: "conversions", index: 1 },
276
- ],
277
- },
278
- },
279
- ],
280
- },
281
- ],
282
- },
283
- }) as never,
284
- );
285
-
286
- const result: ConversionUploadBatchResult = await provider.upload([
287
- makeConversion(),
288
- makeConversion({ clickIds: { gclid: "expired" } }),
289
- ]);
290
-
291
- expect(Array.from(result.permanentFailures.entries())).toEqual([
292
- [1, "Expired click identifier"],
293
- ]);
294
- });
295
-
296
- test("treats ALREADY_EXISTS as successful idempotent delivery", async () => {
297
- postSpy
298
- .mockResolvedValueOnce(
299
- response({ access_token: "access-token", expires_in: 3600 }) as never,
300
- )
301
- .mockResolvedValueOnce(
302
- response({
303
- partialFailureError: {
304
- details: [
305
- {
306
- errors: [
307
- {
308
- errorCode: { conversionUploadError: "ALREADY_EXISTS" },
309
- message: "The conversion was already uploaded",
310
- location: {
311
- fieldPathElements: [
312
- { fieldName: "conversions", index: 0 },
313
- ],
314
- },
315
- },
316
- ],
317
- },
318
- ],
319
- },
320
- }) as never,
321
- );
322
-
323
- const result: ConversionUploadBatchResult = await provider.upload([
324
- makeConversion(),
325
- ]);
326
-
327
- expect(result.permanentFailures.size).toBe(0);
328
- });
329
-
330
- test("logs an unparseable partial failure instead of silently hiding it", async () => {
331
- const errorSpy: SpyInstance<typeof logger.error> = jest
332
- .spyOn(logger, "error")
333
- .mockImplementation((): void => {
334
- return undefined;
335
- });
336
- postSpy
337
- .mockResolvedValueOnce(
338
- response({ access_token: "access-token", expires_in: 3600 }) as never,
339
- )
340
- .mockResolvedValueOnce(
341
- response({
342
- partialFailureError: {
343
- details: [{ errors: [{ message: "Unindexed failure" }] }],
344
- },
345
- }) as never,
346
- );
347
-
348
- const result: ConversionUploadBatchResult = await provider.upload([
349
- makeConversion(),
350
- ]);
351
-
352
- expect(result.permanentFailures.size).toBe(0);
353
- expect(errorSpy).toHaveBeenCalledWith(
354
- expect.stringContaining(
355
- "Google Ads partial failure with unparseable indexes",
356
- ),
357
- );
358
- });
359
-
360
- /*
361
- * -------------------------------------------------------------------------
362
- * Enhanced conversions.
363
- *
364
- * A hashed email raises the match rate of any upload, and is the ONLY thing
365
- * that can attribute a sales-led deal: the gclid that started it is long gone
366
- * from the browser by the time a licence is signed. Two separate behaviours,
367
- * deliberately gated differently:
368
- *
369
- * - attaching userIdentifiers alongside a click id needs no configuration
370
- * and always happens;
371
- * - uploading with ONLY a hashed email is enhanced conversions for leads,
372
- * which the Google Ads account must be set up for, so it is behind a
373
- * flag and refused when off.
374
- * -------------------------------------------------------------------------
375
- */
376
- describe("enhanced conversions", () => {
377
- test("attaches a hashed email alongside a click id without any flag", async () => {
378
- postSpy
379
- .mockResolvedValueOnce(
380
- response({ access_token: "access-token", expires_in: 3600 }) as never,
381
- )
382
- .mockResolvedValueOnce(response({}) as never);
383
-
384
- await provider.upload([makeConversion({ email: "ada@example.com" })]);
385
-
386
- const uploadBody: JSONObject = postSpy.mock.calls[1]?.[1] as JSONObject;
387
- const uploaded: JSONObject = (
388
- uploadBody["conversions"] as Array<JSONObject>
389
- )[0]!;
390
-
391
- expect(uploaded["userIdentifiers"]).toEqual([
392
- {
393
- hashedEmail: Attribution.hashEmail("ada@example.com"),
394
- userIdentifierSource: "FIRST_PARTY",
395
- },
396
- ]);
397
- // The click id is still there: enhanced matching adds, it does not replace.
398
- expect(uploaded["gclid"]).toBe("google-click");
399
- });
400
-
401
- test("never sends the address in the clear", async () => {
402
- postSpy
403
- .mockResolvedValueOnce(
404
- response({ access_token: "access-token", expires_in: 3600 }) as never,
405
- )
406
- .mockResolvedValueOnce(response({}) as never);
407
-
408
- await provider.upload([makeConversion({ email: "ada@example.com" })]);
409
-
410
- expect(JSON.stringify(postSpy.mock.calls[1]?.[1])).not.toContain(
411
- "ada@example.com",
412
- );
413
- });
414
-
415
- test("sends no userIdentifiers when there is no email at all", async () => {
416
- postSpy
417
- .mockResolvedValueOnce(
418
- response({ access_token: "access-token", expires_in: 3600 }) as never,
419
- )
420
- .mockResolvedValueOnce(response({}) as never);
421
-
422
- await provider.upload([makeConversion()]);
423
-
424
- const uploadBody: JSONObject = postSpy.mock.calls[1]?.[1] as JSONObject;
425
- const uploaded: JSONObject = (
426
- uploadBody["conversions"] as Array<JSONObject>
427
- )[0]!;
428
-
429
- expect(uploaded["userIdentifiers"]).toBeUndefined();
430
- });
431
-
432
- test("prefers the stored emailHash over the address column", async () => {
433
- postSpy
434
- .mockResolvedValueOnce(
435
- response({ access_token: "access-token", expires_in: 3600 }) as never,
436
- )
437
- .mockResolvedValueOnce(response({}) as never);
438
-
439
- await provider.upload([
440
- makeConversion({
441
- email: "ada@example.com",
442
- emailHash: "already-hashed-elsewhere",
443
- }),
444
- ]);
445
-
446
- const uploadBody: JSONObject = postSpy.mock.calls[1]?.[1] as JSONObject;
447
- const uploaded: JSONObject = (
448
- uploadBody["conversions"] as Array<JSONObject>
449
- )[0]!;
450
-
451
- expect(uploaded["userIdentifiers"]).toEqual([
452
- {
453
- hashedEmail: "already-hashed-elsewhere",
454
- userIdentifierSource: "FIRST_PARTY",
455
- },
456
- ]);
457
- });
458
-
459
- test("refuses an email-only conversion while the leads flag is off", () => {
460
- expect(
461
- provider.getSkipReason(
462
- makeConversion({ clickIds: {}, email: "ada@example.com" }),
463
- ),
464
- ).toEqual({
465
- reason: "No Google click id (gclid/wbraid/gbraid)",
466
- isPermanent: true,
467
- });
468
- });
469
-
470
- test("accepts an email-only conversion once the leads flag is on", () => {
471
- mockEnhancedConversionsForLeadsEnabled = true;
472
-
473
- expect(
474
- provider.getSkipReason(
475
- makeConversion({ clickIds: {}, email: "ada@example.com" }),
476
- ),
477
- ).toBeNull();
478
- });
479
-
480
- test("still refuses a conversion with neither a click id nor an email", () => {
481
- mockEnhancedConversionsForLeadsEnabled = true;
482
-
483
- expect(provider.getSkipReason(makeConversion({ clickIds: {} }))).toEqual({
484
- reason:
485
- "No Google click id (gclid/wbraid/gbraid) and no email to match on",
486
- isPermanent: true,
487
- });
488
- });
489
-
490
- test("uploads an email-only lead with no click id field at all", async () => {
491
- mockEnhancedConversionsForLeadsEnabled = true;
492
-
493
- postSpy
494
- .mockResolvedValueOnce(
495
- response({ access_token: "access-token", expires_in: 3600 }) as never,
496
- )
497
- .mockResolvedValueOnce(response({}) as never);
498
-
499
- await provider.upload([
500
- makeConversion({
501
- conversionType: MarketingConversionType.MeetingBooked,
502
- clickIds: {},
503
- email: "ada@example.com",
504
- }),
505
- ]);
506
-
507
- const uploadBody: JSONObject = postSpy.mock.calls[1]?.[1] as JSONObject;
508
- const uploaded: JSONObject = (
509
- uploadBody["conversions"] as Array<JSONObject>
510
- )[0]!;
511
-
512
- expect(uploaded).toEqual({
513
- conversionAction: "customers/1234567890/conversionActions/333333",
514
- conversionDateTime: "2026-07-22 10:11:12+00:00",
515
- userIdentifiers: [
516
- {
517
- hashedEmail: Attribution.hashEmail("ada@example.com"),
518
- userIdentifierSource: "FIRST_PARTY",
519
- },
520
- ],
521
- });
522
- });
523
- });
524
-
525
- /*
526
- * -------------------------------------------------------------------------
527
- * One conversion action per conversion type.
528
- *
529
- * The provider used to pick between two actions with `isSignUp ? a : b`, so
530
- * anything that was not a signup was reported against the paid-subscription
531
- * action. Each type now names its own.
532
- * -------------------------------------------------------------------------
533
- */
534
- describe("conversion action mapping", () => {
535
- test.each([
536
- [MarketingConversionType.SignUp, "111111"],
537
- [MarketingConversionType.PaidSubscription, "222222"],
538
- [MarketingConversionType.MeetingBooked, "333333"],
539
- ])(
540
- "reports %s against its own conversion action",
541
- async (
542
- conversionType: MarketingConversionType,
543
- expectedActionId: string,
544
- ) => {
545
- postSpy
546
- .mockResolvedValueOnce(
547
- response({
548
- access_token: "access-token",
549
- expires_in: 3600,
550
- }) as never,
551
- )
552
- .mockResolvedValueOnce(response({}) as never);
553
-
554
- await provider.upload([
555
- makeConversion({ conversionType: conversionType }),
556
- ]);
557
-
558
- const uploadBody: JSONObject = postSpy.mock.calls[1]?.[1] as JSONObject;
559
- const uploaded: JSONObject = (
560
- uploadBody["conversions"] as Array<JSONObject>
561
- )[0]!;
562
-
563
- expect(uploaded["conversionAction"]).toBe(
564
- `customers/1234567890/conversionActions/${expectedActionId}`,
565
- );
566
- },
567
- );
568
-
569
- test("never attaches revenue to a booked meeting", async () => {
570
- postSpy
571
- .mockResolvedValueOnce(
572
- response({ access_token: "access-token", expires_in: 3600 }) as never,
573
- )
574
- .mockResolvedValueOnce(response({}) as never);
575
-
576
- await provider.upload([
577
- makeConversion({
578
- conversionType: MarketingConversionType.MeetingBooked,
579
- conversionValueInUSDCents: 500000,
580
- }),
581
- ]);
582
-
583
- const uploadBody: JSONObject = postSpy.mock.calls[1]?.[1] as JSONObject;
584
- const uploaded: JSONObject = (
585
- uploadBody["conversions"] as Array<JSONObject>
586
- )[0]!;
587
-
588
- expect(uploaded["conversionValue"]).toBeUndefined();
589
- expect(uploaded["currencyCode"]).toBeUndefined();
590
- });
591
- });
592
- });