@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,282 +0,0 @@
1
- import Attribution from "../../../../Server/Utils/Attribution";
2
- import MarketingConversion from "../../../../Models/DatabaseModels/MarketingConversion";
3
- import LinkedInProvider from "../../../../Server/Utils/Marketing/Providers/LinkedIn";
4
- import { ConversionSkip } from "../../../../Server/Utils/Marketing/ConversionUploadProvider";
5
- import { JSONObject } from "../../../../Types/JSON";
6
- import { MarketingConversionType } from "../../../../Types/Marketing/MarketingConversion";
7
- import ObjectID from "../../../../Types/ObjectID";
8
- import axios, { AxiosError } 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
- * ---------------------------------------------------------------------------
21
- * LinkedIn Conversions API.
22
- *
23
- * LinkedIn is the platform where enhanced matching matters most for this
24
- * product: it is where enterprise buyers are advertised to, and an enterprise
25
- * cycle is exactly the case where the li_fat_id from the original click is
26
- * long gone by the time the deal closes. LinkedIn accepts several userIds per
27
- * event and resolves whichever it can, so both identifiers are sent.
28
- *
29
- * Conversion rules are per-type and operator-configured, so an unset rule id
30
- * must leave the conversion PENDING rather than discard it.
31
- * ---------------------------------------------------------------------------
32
- */
33
-
34
- jest.mock("../../../../Server/EnvironmentConfig", () => {
35
- return {
36
- LinkedInApiVersion: "202606",
37
- LinkedInConversionsAccessToken: "linkedin-token",
38
- LinkedInSignUpConversionId: "1111",
39
- LinkedInPaidSubscriptionConversionId: "2222",
40
- LinkedInMeetingBookedConversionId: "3333",
41
- };
42
- });
43
-
44
- type MakeConversionFunction = (
45
- data?: Partial<MarketingConversion>,
46
- ) => MarketingConversion;
47
-
48
- const makeConversion: MakeConversionFunction = (
49
- data: Partial<MarketingConversion> = {},
50
- ): MarketingConversion => {
51
- return Object.assign(new MarketingConversion(), {
52
- id: new ObjectID("33333333-3333-4333-8333-333333333333"),
53
- conversionType: MarketingConversionType.SignUp,
54
- conversionAt: new Date("2026-07-22T10:11:12.345Z"),
55
- clickIds: { li_fat_id: "linkedin-click" },
56
- ...data,
57
- });
58
- };
59
-
60
- type UploadedBodyFunction = (postSpy: SpyInstance<any>) => JSONObject;
61
-
62
- const uploadedBody: UploadedBodyFunction = (
63
- postSpy: SpyInstance<any>,
64
- ): JSONObject => {
65
- return postSpy.mock.calls[0]?.[1] as JSONObject;
66
- };
67
-
68
- describe("LinkedInProvider", () => {
69
- let provider: LinkedInProvider;
70
- let postSpy: SpyInstance<any>;
71
-
72
- beforeEach(() => {
73
- provider = new LinkedInProvider();
74
- postSpy = jest.spyOn(axios, "post") as SpyInstance<any>;
75
- jest
76
- .spyOn(Date, "now")
77
- .mockReturnValue(new Date("2026-07-23T10:11:12.345Z").getTime());
78
- });
79
-
80
- afterEach(() => {
81
- jest.restoreAllMocks();
82
- });
83
-
84
- test("reports configured when the access token exists", () => {
85
- expect(provider.isConfigured()).toBe(true);
86
- });
87
-
88
- describe("identifiers", () => {
89
- test("accepts a conversion with only a click id", () => {
90
- expect(provider.getSkipReason(makeConversion())).toBeNull();
91
- });
92
-
93
- test("accepts a conversion with only an email", () => {
94
- expect(
95
- provider.getSkipReason(
96
- makeConversion({ clickIds: {}, email: "ada@example.com" }),
97
- ),
98
- ).toBeNull();
99
- });
100
-
101
- test("permanently skips a conversion with neither", () => {
102
- expect(provider.getSkipReason(makeConversion({ clickIds: {} }))).toEqual({
103
- reason: "No LinkedIn click id (li_fat_id) and no email to match on",
104
- isPermanent: true,
105
- });
106
- });
107
-
108
- test("sends both identifiers when both are known", async () => {
109
- postSpy.mockResolvedValueOnce({} as never);
110
-
111
- await provider.upload([makeConversion({ email: "Ada@Example.com" })]);
112
-
113
- expect(uploadedBody(postSpy)["user"]).toEqual({
114
- userIds: [
115
- {
116
- idType: "LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID",
117
- idValue: "linkedin-click",
118
- },
119
- {
120
- idType: "SHA256_EMAIL",
121
- idValue: Attribution.hashEmail("ada@example.com"),
122
- },
123
- ],
124
- });
125
- });
126
-
127
- /*
128
- * The click id used to be sent as an empty idValue when absent, which is a
129
- * userId LinkedIn can never resolve rather than an absent one.
130
- */
131
- test("sends only the email identifier when there is no click id", async () => {
132
- postSpy.mockResolvedValueOnce({} as never);
133
-
134
- await provider.upload([
135
- makeConversion({ clickIds: {}, email: "ada@example.com" }),
136
- ]);
137
-
138
- expect(uploadedBody(postSpy)["user"]).toEqual({
139
- userIds: [
140
- {
141
- idType: "SHA256_EMAIL",
142
- idValue: Attribution.hashEmail("ada@example.com"),
143
- },
144
- ],
145
- });
146
- });
147
-
148
- test("never sends the address in the clear", async () => {
149
- postSpy.mockResolvedValueOnce({} as never);
150
-
151
- await provider.upload([makeConversion({ email: "ada@example.com" })]);
152
-
153
- expect(JSON.stringify(uploadedBody(postSpy))).not.toContain(
154
- "ada@example.com",
155
- );
156
- });
157
- });
158
-
159
- describe("conversion rules", () => {
160
- test.each([
161
- [MarketingConversionType.SignUp, "1111"],
162
- [MarketingConversionType.PaidSubscription, "2222"],
163
- [MarketingConversionType.MeetingBooked, "3333"],
164
- ])(
165
- "reports %s against its own conversion rule",
166
- async (
167
- conversionType: MarketingConversionType,
168
- expectedRuleId: string,
169
- ) => {
170
- postSpy.mockResolvedValueOnce({} as never);
171
-
172
- await provider.upload([
173
- makeConversion({ conversionType: conversionType }),
174
- ]);
175
-
176
- expect(uploadedBody(postSpy)["conversion"]).toBe(
177
- `urn:lla:llaPartnerConversion:${expectedRuleId}`,
178
- );
179
- },
180
- );
181
-
182
- test.each([MarketingConversionType.MeetingBooked])(
183
- "attaches no conversionValue to %s even when the row holds one",
184
- async (conversionType: MarketingConversionType) => {
185
- postSpy.mockResolvedValueOnce({} as never);
186
-
187
- await provider.upload([
188
- makeConversion({
189
- conversionType: conversionType,
190
- conversionValueInUSDCents: 500000,
191
- }),
192
- ]);
193
-
194
- expect(uploadedBody(postSpy)["conversionValue"]).toBeUndefined();
195
- },
196
- );
197
-
198
- test("attaches conversionValue to a paid subscription", async () => {
199
- postSpy.mockResolvedValueOnce({} as never);
200
-
201
- await provider.upload([
202
- makeConversion({
203
- conversionType: MarketingConversionType.PaidSubscription,
204
- conversionValueInUSDCents: 12999,
205
- }),
206
- ]);
207
-
208
- expect(uploadedBody(postSpy)["conversionValue"]).toEqual({
209
- currencyCode: "USD",
210
- amount: "129.99",
211
- });
212
- });
213
- });
214
-
215
- test("keys the event on the conversion row id so retries dedupe", async () => {
216
- postSpy.mockResolvedValueOnce({} as never);
217
-
218
- await provider.upload([makeConversion()]);
219
-
220
- expect(uploadedBody(postSpy)["eventId"]).toBe(
221
- "33333333-3333-4333-8333-333333333333",
222
- );
223
- });
224
-
225
- test("permanently skips a conversion outside LinkedIn's 90-day window", () => {
226
- const skip: ConversionSkip | null = provider.getSkipReason(
227
- makeConversion({ conversionAt: new Date("2026-01-01T00:00:00.000Z") }),
228
- );
229
-
230
- expect(skip).toEqual({
231
- reason: "Conversion older than LinkedIn's 90-day window",
232
- isPermanent: true,
233
- });
234
- });
235
-
236
- describe("failure handling", () => {
237
- type BuildAxiosErrorFunction = (status: number) => AxiosError;
238
-
239
- const buildAxiosError: BuildAxiosErrorFunction = (
240
- status: number,
241
- ): AxiosError => {
242
- const error: AxiosError = new AxiosError("rejected");
243
- Object.assign(error, { response: { status: status, data: {} } });
244
- return error;
245
- };
246
-
247
- test.each([400, 422])(
248
- "records a %s as a permanent per-event failure",
249
- async (status: number) => {
250
- postSpy.mockRejectedValueOnce(buildAxiosError(status) as never);
251
-
252
- const result: { permanentFailures: Map<number, string> } =
253
- await provider.upload([makeConversion()]);
254
-
255
- expect(result.permanentFailures.get(0)).toContain(`HTTP ${status}`);
256
- },
257
- );
258
-
259
- /*
260
- * Anything that is not a definitive validation rejection is transport
261
- * level. It must throw so the whole batch stays pending and is retried —
262
- * recording it as a permanent failure would discard a conversion because
263
- * the token had expired or LinkedIn had a bad minute.
264
- */
265
- test.each([401, 429, 500, 503])(
266
- "throws on a %s so the batch is retried",
267
- async (status: number) => {
268
- postSpy.mockRejectedValueOnce(buildAxiosError(status) as never);
269
-
270
- await expect(provider.upload([makeConversion()])).rejects.toBeDefined();
271
- },
272
- );
273
-
274
- test("throws on a network error with no response at all", async () => {
275
- postSpy.mockRejectedValueOnce(new Error("socket hang up") as never);
276
-
277
- await expect(provider.upload([makeConversion()])).rejects.toThrow(
278
- "socket hang up",
279
- );
280
- });
281
- });
282
- });
@@ -1,304 +0,0 @@
1
- import Attribution from "../../../../Server/Utils/Attribution";
2
- import MarketingConversion from "../../../../Models/DatabaseModels/MarketingConversion";
3
- import MetaProvider from "../../../../Server/Utils/Marketing/Providers/Meta";
4
- import { ConversionSkip } from "../../../../Server/Utils/Marketing/ConversionUploadProvider";
5
- import { JSONObject } from "../../../../Types/JSON";
6
- import { MarketingConversionType } from "../../../../Types/Marketing/MarketingConversion";
7
- import ObjectID from "../../../../Types/ObjectID";
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
- * ---------------------------------------------------------------------------
21
- * Meta Conversions API.
22
- *
23
- * Two things this file is really about:
24
- *
25
- * - WHAT A CONVERSION IS CALLED. Meta's event names carry meaning to the
26
- * optimiser. `Purchase` demands a value and pulls the event into the
27
- * revenue optimisation pool; `Schedule` and `Lead` do not. The provider
28
- * used to send `isSignUp ? CompleteRegistration : Purchase`, so anything
29
- * that was not a signup was revenue by default.
30
- *
31
- * - HOW A PERSON IS IDENTIFIED. Meta matches on a click id, a hashed email,
32
- * or both. Requiring fbclid — which the provider used to — discarded every
33
- * conversion whose click id had not survived to the moment of conversion,
34
- * which is nearly all of a sales-led funnel.
35
- * ---------------------------------------------------------------------------
36
- */
37
-
38
- jest.mock("../../../../Server/EnvironmentConfig", () => {
39
- return {
40
- MetaConversionsPixelId: "pixel-id",
41
- MetaConversionsAccessToken: "meta-access-token",
42
- MetaGraphApiVersion: "v22.0",
43
- };
44
- });
45
-
46
- type MakeConversionFunction = (
47
- data?: Partial<MarketingConversion>,
48
- ) => MarketingConversion;
49
-
50
- const makeConversion: MakeConversionFunction = (
51
- data: Partial<MarketingConversion> = {},
52
- ): MarketingConversion => {
53
- return Object.assign(new MarketingConversion(), {
54
- id: new ObjectID("11111111-1111-4111-8111-111111111111"),
55
- conversionType: MarketingConversionType.SignUp,
56
- conversionAt: new Date("2026-07-22T10:11:12.345Z"),
57
- clickIds: { fbclid: "meta-click" },
58
- ...data,
59
- });
60
- };
61
-
62
- type ResponseFunction = (data: JSONObject) => AxiosResponse<JSONObject>;
63
-
64
- const response: ResponseFunction = (
65
- data: JSONObject,
66
- ): AxiosResponse<JSONObject> => {
67
- return { data } as AxiosResponse<JSONObject>;
68
- };
69
-
70
- type UploadedEventFunction = (postSpy: SpyInstance<any>) => JSONObject;
71
-
72
- const uploadedEvent: UploadedEventFunction = (
73
- postSpy: SpyInstance<any>,
74
- ): JSONObject => {
75
- const body: JSONObject = postSpy.mock.calls[0]?.[1] as JSONObject;
76
- return (body["data"] as Array<JSONObject>)[0]!;
77
- };
78
-
79
- describe("MetaProvider", () => {
80
- let provider: MetaProvider;
81
- let postSpy: SpyInstance<any>;
82
-
83
- beforeEach(() => {
84
- provider = new MetaProvider();
85
- postSpy = jest.spyOn(axios, "post") as SpyInstance<any>;
86
- // Meta only accepts events up to 7 days old; freeze inside that window.
87
- jest
88
- .spyOn(Date, "now")
89
- .mockReturnValue(new Date("2026-07-23T10:11:12.345Z").getTime());
90
- });
91
-
92
- afterEach(() => {
93
- jest.restoreAllMocks();
94
- });
95
-
96
- test("reports configured only when the pixel and token exist", () => {
97
- expect(provider.isConfigured()).toBe(true);
98
- });
99
-
100
- describe("identifiers", () => {
101
- test("accepts a conversion with only a click id", () => {
102
- expect(
103
- provider.getSkipReason(makeConversion({ clickIds: { fbclid: "x" } })),
104
- ).toBeNull();
105
- });
106
-
107
- test("accepts a conversion with only an email", () => {
108
- expect(
109
- provider.getSkipReason(
110
- makeConversion({ clickIds: {}, email: "ada@example.com" }),
111
- ),
112
- ).toBeNull();
113
- });
114
-
115
- test("permanently skips a conversion with neither", () => {
116
- expect(provider.getSkipReason(makeConversion({ clickIds: {} }))).toEqual({
117
- reason: "No Meta click id (fbclid) and no email to match on",
118
- isPermanent: true,
119
- });
120
- });
121
-
122
- test("permanently skips a conversion outside Meta's 7-day window", () => {
123
- const skip: ConversionSkip | null = provider.getSkipReason(
124
- makeConversion({
125
- conversionAt: new Date("2026-07-01T00:00:00.000Z"),
126
- }),
127
- );
128
-
129
- expect(skip).toEqual({
130
- reason: "Conversion older than Meta's 7-day upload window",
131
- isPermanent: true,
132
- });
133
- });
134
-
135
- test("sends fbc and a hashed email when both are known", async () => {
136
- postSpy.mockResolvedValueOnce(response({ events_received: 1 }) as never);
137
-
138
- await provider.upload([makeConversion({ email: "Ada@Example.com" })]);
139
-
140
- /*
141
- * fbc is fb.1.{creationTimeMs}.{fbclid} per Meta's spec, and the
142
- * creation time it uses is the conversion time — the original click
143
- * timestamp is not stored.
144
- */
145
- expect(uploadedEvent(postSpy)["user_data"]).toEqual({
146
- fbc: `fb.1.${new Date("2026-07-22T10:11:12.345Z").getTime()}.meta-click`,
147
- em: [Attribution.hashEmail("ada@example.com")],
148
- });
149
- });
150
-
151
- /*
152
- * fbc is built by splicing the click id into a template, so an absent
153
- * click id must omit the field entirely rather than send a well-formed
154
- * string ending in nothing.
155
- */
156
- test("omits fbc entirely when there is no click id", async () => {
157
- postSpy.mockResolvedValueOnce(response({ events_received: 1 }) as never);
158
-
159
- await provider.upload([
160
- makeConversion({ clickIds: {}, email: "ada@example.com" }),
161
- ]);
162
-
163
- const userData: JSONObject = uploadedEvent(postSpy)[
164
- "user_data"
165
- ] as JSONObject;
166
-
167
- expect(userData["fbc"]).toBeUndefined();
168
- expect(userData["em"]).toEqual([
169
- Attribution.hashEmail("ada@example.com"),
170
- ]);
171
- });
172
-
173
- test("never sends the address in the clear", async () => {
174
- postSpy.mockResolvedValueOnce(response({ events_received: 1 }) as never);
175
-
176
- await provider.upload([makeConversion({ email: "ada@example.com" })]);
177
-
178
- expect(JSON.stringify(postSpy.mock.calls[0]?.[1])).not.toContain(
179
- "ada@example.com",
180
- );
181
- });
182
- });
183
-
184
- describe("event naming", () => {
185
- test.each([
186
- [MarketingConversionType.SignUp, "CompleteRegistration"],
187
- [MarketingConversionType.MeetingBooked, "Schedule"],
188
- [MarketingConversionType.PaidSubscription, "Purchase"],
189
- ])(
190
- "sends %s as %s",
191
- async (
192
- conversionType: MarketingConversionType,
193
- expectedEventName: string,
194
- ) => {
195
- postSpy.mockResolvedValueOnce(
196
- response({ events_received: 1 }) as never,
197
- );
198
-
199
- await provider.upload([
200
- makeConversion({ conversionType: conversionType }),
201
- ]);
202
-
203
- expect(uploadedEvent(postSpy)["event_name"]).toBe(expectedEventName);
204
- },
205
- );
206
-
207
- /*
208
- * The regression this exists for: a booked meeting reported as a Purchase
209
- * carrying the row's value would teach Meta's optimiser that demos are
210
- * revenue.
211
- */
212
- test.each([MarketingConversionType.MeetingBooked])(
213
- "attaches no custom_data to %s even when the row holds a value",
214
- async (conversionType: MarketingConversionType) => {
215
- postSpy.mockResolvedValueOnce(
216
- response({ events_received: 1 }) as never,
217
- );
218
-
219
- await provider.upload([
220
- makeConversion({
221
- conversionType: conversionType,
222
- conversionValueInUSDCents: 500000,
223
- }),
224
- ]);
225
-
226
- const event: JSONObject = uploadedEvent(postSpy);
227
-
228
- expect(event["event_name"]).not.toBe("Purchase");
229
- expect(event["custom_data"]).toBeUndefined();
230
- },
231
- );
232
-
233
- /*
234
- * Purchase is the one event Meta rejects without a value, and one rejected
235
- * event fails the whole batch — so a custom-pricing subscription with no
236
- * known revenue must still send a value, even a zero one.
237
- */
238
- test("sends a zero value for a purchase whose revenue is unknown", async () => {
239
- postSpy.mockResolvedValueOnce(response({ events_received: 1 }) as never);
240
-
241
- await provider.upload([
242
- makeConversion({
243
- conversionType: MarketingConversionType.PaidSubscription,
244
- }),
245
- ]);
246
-
247
- expect(uploadedEvent(postSpy)["custom_data"]).toEqual({
248
- value: 0,
249
- currency: "USD",
250
- });
251
- });
252
-
253
- test("sends the real value for a purchase that has one", async () => {
254
- postSpy.mockResolvedValueOnce(response({ events_received: 1 }) as never);
255
-
256
- await provider.upload([
257
- makeConversion({
258
- conversionType: MarketingConversionType.PaidSubscription,
259
- conversionValueInUSDCents: 12999,
260
- }),
261
- ]);
262
-
263
- expect(uploadedEvent(postSpy)["custom_data"]).toEqual({
264
- value: 129.99,
265
- currency: "USD",
266
- });
267
- });
268
- });
269
-
270
- describe("delivery", () => {
271
- test("keys the event on the conversion row id so retries dedupe", async () => {
272
- postSpy.mockResolvedValueOnce(response({ events_received: 1 }) as never);
273
-
274
- await provider.upload([makeConversion()]);
275
-
276
- expect(uploadedEvent(postSpy)["event_id"]).toBe(
277
- "11111111-1111-4111-8111-111111111111",
278
- );
279
- });
280
-
281
- test("posts to the configured pixel", async () => {
282
- postSpy.mockResolvedValueOnce(response({ events_received: 1 }) as never);
283
-
284
- await provider.upload([makeConversion()]);
285
-
286
- expect(postSpy.mock.calls[0]?.[0]).toBe(
287
- "https://graph.facebook.com/v22.0/pixel-id/events",
288
- );
289
- });
290
-
291
- /*
292
- * A partial acceptance is thrown, not recorded as a per-row failure: the
293
- * response does not say WHICH events were dropped, so the only safe move is
294
- * to retry the batch, which event_id makes idempotent.
295
- */
296
- test("throws when Meta accepts fewer events than were sent", async () => {
297
- postSpy.mockResolvedValueOnce(response({ events_received: 1 }) as never);
298
-
299
- await expect(
300
- provider.upload([makeConversion(), makeConversion()]),
301
- ).rejects.toThrow("Meta accepted 1 of 2 events");
302
- });
303
- });
304
- });