@oneuptime/common 12.0.19 → 12.0.21
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/Models/AnalyticsModels/ChangeEvent.ts +331 -0
- package/Models/AnalyticsModels/Index.ts +2 -0
- package/Server/API/EnterpriseLicenseAPI.ts +94 -19
- package/Server/EnvironmentConfig.ts +15 -5
- package/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.ts +42 -0
- package/Server/Infrastructure/Postgres/SchemaMigrations/Index.ts +0 -2
- package/Server/Services/ChangeEventService.ts +11 -0
- package/Server/Services/EnterpriseLicenseService.ts +3 -3
- package/Server/Services/Index.ts +2 -0
- package/Server/Services/ProjectService.ts +104 -0
- package/Server/Utils/Attribution.ts +11 -11
- package/Server/Utils/Marketing/MarketingEventWebhook.ts +4 -5
- package/Server/Utils/Response.ts +2 -2
- package/Server/Utils/StartServer.ts +2 -2
- package/Server/Utils/Telemetry/ChangeEventRow.ts +228 -0
- package/Server/Views/Partials/AnalyticsConsent.ejs +441 -0
- package/Tests/App/Dashboard/AlertEpisodeViewFields.test.tsx +465 -0
- package/Tests/App/Dashboard/DashboardChartWidgetZoom.test.tsx +366 -0
- package/Tests/App/Dashboard/DiscoveryScanWizardValidation.test.tsx +408 -0
- package/Tests/App/Dashboard/EventOverlayHook.test.tsx +249 -0
- package/Tests/App/Dashboard/IncidentEpisodeViewFields.test.tsx +474 -0
- package/Tests/App/Dashboard/InvestigationArtifacts.test.tsx +315 -0
- package/Tests/App/Dashboard/InvestigationDrawer.test.tsx +258 -0
- package/Tests/App/Dashboard/MetricChartsCompare.test.tsx +171 -0
- package/Tests/App/Dashboard/MetricSeriesInvestigateMenu.test.tsx +368 -0
- package/Tests/App/Dashboard/MetricViewCompare.test.tsx +233 -0
- package/Tests/Server/API/EnterpriseLicenseReportUserCount.test.ts +763 -0
- package/Tests/Server/Infrastructure/Postgres/AddUserSlackAndMicrosoftTeamsMigration.test.ts +236 -0
- package/Tests/Server/Services/ProjectServiceChangePlan.test.ts +138 -0
- package/Tests/Server/Services/ProjectServiceCreateSubscriptionStarted.test.ts +516 -0
- package/Tests/Server/Utils/AI/SRE/Insights/Detectors/ExceptionIdentity.test.ts +224 -0
- package/Tests/Server/Utils/Attribution.test.ts +4 -5
- package/Tests/Server/Utils/Marketing/MarketingEventUtil.test.ts +2 -2
- package/Tests/Server/Utils/Monitor/MonitorCriteriaExpectationBuilder.test.ts +300 -0
- package/Tests/Server/Utils/ResponseRenderAnalytics.test.ts +53 -0
- package/Tests/Server/Views/AnalyticsConsentPartial.test.ts +241 -0
- package/Tests/UI/Components/Charts/ChartGhostSeries.test.tsx +103 -0
- package/Tests/UI/Components/Charts/ChartTooltipSorted.test.tsx +129 -0
- package/Tests/UI/Components/Charts/TooltipEntries.test.ts +191 -0
- package/Tests/UI/Components/Detail/DetailFieldErrors.test.tsx +347 -0
- package/Tests/UI/Components/Forms/BasicFormStepValidation.test.tsx +392 -0
- package/Tests/UI/Components/Forms/ValidationFormSteps.test.ts +549 -0
- package/Tests/Utils/EnterpriseLicenseSync.test.ts +562 -0
- package/Tests/Utils/Telemetry/CrossSignalScope.test.ts +13 -2
- package/Types/Analytics/RevenueEvent.ts +1 -0
- package/Types/AnalyticsDatabase/AnalyticsTableName.ts +7 -0
- package/Types/Marketing/Attribution.ts +9 -15
- package/Types/Marketing/MarketingEvent.ts +19 -12
- package/UI/Components/Charts/Area/AreaChart.tsx +23 -1
- package/UI/Components/Charts/Bar/BarChart.tsx +8 -1
- package/UI/Components/Charts/ChartGroup/ChartGroup.tsx +16 -1
- package/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.tsx +208 -97
- package/UI/Components/Charts/ChartLibrary/BarChart/BarChart.tsx +26 -3
- package/UI/Components/Charts/ChartLibrary/LineChart/LineChart.tsx +232 -118
- package/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.ts +120 -0
- package/UI/Components/Charts/Line/LineChart.tsx +23 -1
- package/UI/Components/Detail/Detail.tsx +18 -1
- package/Utils/EnterpriseLicense/EnterpriseLicenseSync.ts +244 -0
- package/Utils/Metrics/MetricExplorerUrl.ts +119 -5
- package/Utils/Telemetry/CrossSignalScope.ts +19 -10
- package/build/dist/Models/AnalyticsModels/ChangeEvent.js +298 -0
- package/build/dist/Models/AnalyticsModels/ChangeEvent.js.map +1 -0
- package/build/dist/Models/AnalyticsModels/Index.js +2 -0
- package/build/dist/Models/AnalyticsModels/Index.js.map +1 -1
- package/build/dist/Server/API/EnterpriseLicenseAPI.js +66 -12
- package/build/dist/Server/API/EnterpriseLicenseAPI.js.map +1 -1
- package/build/dist/Server/EnvironmentConfig.js +14 -5
- package/build/dist/Server/EnvironmentConfig.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js +39 -0
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1789000000000-AddUserSlackAndMicrosoftTeams.js.map +1 -1
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js +0 -2
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/Index.js.map +1 -1
- package/build/dist/Server/Services/ChangeEventService.js +9 -0
- package/build/dist/Server/Services/ChangeEventService.js.map +1 -0
- package/build/dist/Server/Services/EnterpriseLicenseService.js +3 -3
- package/build/dist/Server/Services/Index.js +2 -0
- package/build/dist/Server/Services/Index.js.map +1 -1
- package/build/dist/Server/Services/ProjectService.js +88 -0
- package/build/dist/Server/Services/ProjectService.js.map +1 -1
- package/build/dist/Server/Utils/Attribution.js +6 -6
- package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js +4 -5
- package/build/dist/Server/Utils/Marketing/MarketingEventWebhook.js.map +1 -1
- package/build/dist/Server/Utils/Response.js +2 -2
- package/build/dist/Server/Utils/Response.js.map +1 -1
- package/build/dist/Server/Utils/StartServer.js +2 -2
- package/build/dist/Server/Utils/StartServer.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js +150 -0
- package/build/dist/Server/Utils/Telemetry/ChangeEventRow.js.map +1 -0
- package/build/dist/Types/Analytics/RevenueEvent.js +1 -0
- package/build/dist/Types/Analytics/RevenueEvent.js.map +1 -1
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js +7 -0
- package/build/dist/Types/AnalyticsDatabase/AnalyticsTableName.js.map +1 -1
- package/build/dist/Types/Marketing/Attribution.js +9 -15
- package/build/dist/Types/Marketing/Attribution.js.map +1 -1
- package/build/dist/Types/Marketing/MarketingEvent.js +12 -4
- package/build/dist/Types/Marketing/MarketingEvent.js.map +1 -1
- package/build/dist/UI/Components/Charts/Area/AreaChart.js +10 -2
- package/build/dist/UI/Components/Charts/Area/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js +9 -1
- package/build/dist/UI/Components/Charts/Bar/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js +9 -1
- package/build/dist/UI/Components/Charts/ChartGroup/ChartGroup.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js +114 -51
- package/build/dist/UI/Components/Charts/ChartLibrary/AreaChart/AreaChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js +35 -23
- package/build/dist/UI/Components/Charts/ChartLibrary/BarChart/BarChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js +122 -55
- package/build/dist/UI/Components/Charts/ChartLibrary/LineChart/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js +86 -0
- package/build/dist/UI/Components/Charts/ChartLibrary/Utils/TooltipEntries.js.map +1 -0
- package/build/dist/UI/Components/Charts/Line/LineChart.js +10 -2
- package/build/dist/UI/Components/Charts/Line/LineChart.js.map +1 -1
- package/build/dist/UI/Components/Detail/Detail.js +17 -1
- package/build/dist/UI/Components/Detail/Detail.js.map +1 -1
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js +174 -0
- package/build/dist/Utils/EnterpriseLicense/EnterpriseLicenseSync.js.map +1 -0
- package/build/dist/Utils/Metrics/MetricExplorerUrl.js +81 -0
- package/build/dist/Utils/Metrics/MetricExplorerUrl.js.map +1 -1
- package/build/dist/Utils/Telemetry/CrossSignalScope.js +14 -8
- package/build/dist/Utils/Telemetry/CrossSignalScope.js.map +1 -1
- package/package.json +1 -1
- package/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.ts +0 -117
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js +0 -106
- package/build/dist/Server/Infrastructure/Postgres/SchemaMigrations/1788900000000-RedactStoredMonitorIngestSecrets.js.map +0 -1
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
import ProjectService from "../../../Server/Services/ProjectService";
|
|
2
|
+
import BillingService from "../../../Server/Services/BillingService";
|
|
3
|
+
import MarketingEventUtil from "../../../Server/Utils/Marketing/MarketingEventUtil";
|
|
4
|
+
import ProductAnalytics from "../../../Server/Utils/ProductAnalytics";
|
|
5
|
+
import Project from "../../../Models/DatabaseModels/Project";
|
|
6
|
+
import SubscriptionPlan from "../../../Types/Billing/SubscriptionPlan";
|
|
7
|
+
import { MarketingEvent } from "../../../Types/Marketing/MarketingEvent";
|
|
8
|
+
import { JSONObject } from "../../../Types/JSON";
|
|
9
|
+
import ObjectID from "../../../Types/ObjectID";
|
|
10
|
+
import { describe, expect, it, afterEach } from "@jest/globals";
|
|
11
|
+
|
|
12
|
+
/*
|
|
13
|
+
* A project created directly on a paid plan never passes through changePlan,
|
|
14
|
+
* so before subscription_started existed the most bid-worthy self-serve
|
|
15
|
+
* conversion there is - ad click, signup, pick Growth, pay - emitted nothing
|
|
16
|
+
* at all. This suite covers the event that closes that gap, and in particular
|
|
17
|
+
* the two things a receiver cannot recover if they are wrong: the dedup key,
|
|
18
|
+
* and the fact that this is new business rather than expansion.
|
|
19
|
+
*
|
|
20
|
+
* Everything below the service boundary is spied: no database, no Stripe, no
|
|
21
|
+
* queue, no analytics client.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
jest.mock("../../../Server/EnvironmentConfig", () => {
|
|
25
|
+
return {
|
|
26
|
+
...jest.requireActual("../../../Server/EnvironmentConfig"),
|
|
27
|
+
IsBillingEnabled: true,
|
|
28
|
+
|
|
29
|
+
/*
|
|
30
|
+
* config.env carries real incoming webhook URLs, and project creation
|
|
31
|
+
* announces itself on one. Blanked so the suite cannot post to Slack.
|
|
32
|
+
*/
|
|
33
|
+
NotificationSlackWebhookOnCreateProject: "",
|
|
34
|
+
NotificationSlackWebhookOnSubscriptionUpdate: "",
|
|
35
|
+
};
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const PROJECT_ID: ObjectID = new ObjectID(
|
|
39
|
+
"22222222-2222-4222-8222-222222222222",
|
|
40
|
+
);
|
|
41
|
+
const OWNER_EMAIL: string = "owner@example.com";
|
|
42
|
+
const CREATED_AT: Date = new Date("2026-08-24T10:00:00.000Z");
|
|
43
|
+
|
|
44
|
+
const GROWTH_MONTHLY_PLAN_ID: string = "price_monthly_growth";
|
|
45
|
+
const GROWTH_YEARLY_PLAN_ID: string = "price_yearly_growth";
|
|
46
|
+
const FREE_MONTHLY_PLAN_ID: string = "price_monthly_free";
|
|
47
|
+
const ENTERPRISE_MONTHLY_PLAN_ID: string = "price_monthly_enterprise";
|
|
48
|
+
|
|
49
|
+
// Growth: $49/mo, or $45/mo when billed yearly.
|
|
50
|
+
const GROWTH_MONTHLY_AMOUNT: number = 49;
|
|
51
|
+
const GROWTH_YEARLY_AMOUNT: number = 45;
|
|
52
|
+
|
|
53
|
+
/*
|
|
54
|
+
* The three plan shapes that matter here. Enterprise carries the -1 amount
|
|
55
|
+
* sentinel that means "priced by a human", which is what isCustomPricing
|
|
56
|
+
* reads.
|
|
57
|
+
*/
|
|
58
|
+
const GROWTH_PLAN: SubscriptionPlan = new SubscriptionPlan(
|
|
59
|
+
GROWTH_MONTHLY_PLAN_ID,
|
|
60
|
+
GROWTH_YEARLY_PLAN_ID,
|
|
61
|
+
"Growth",
|
|
62
|
+
GROWTH_MONTHLY_AMOUNT,
|
|
63
|
+
GROWTH_YEARLY_AMOUNT,
|
|
64
|
+
2,
|
|
65
|
+
14,
|
|
66
|
+
);
|
|
67
|
+
|
|
68
|
+
const FREE_PLAN: SubscriptionPlan = new SubscriptionPlan(
|
|
69
|
+
FREE_MONTHLY_PLAN_ID,
|
|
70
|
+
"price_yearly_free",
|
|
71
|
+
"Free",
|
|
72
|
+
0,
|
|
73
|
+
0,
|
|
74
|
+
1,
|
|
75
|
+
0,
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
const ENTERPRISE_PLAN: SubscriptionPlan = new SubscriptionPlan(
|
|
79
|
+
ENTERPRISE_MONTHLY_PLAN_ID,
|
|
80
|
+
"price_yearly_enterprise",
|
|
81
|
+
"Enterprise",
|
|
82
|
+
-1,
|
|
83
|
+
-1,
|
|
84
|
+
4,
|
|
85
|
+
0,
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
function fakeProject(overrides?: Record<string, unknown>): Project {
|
|
89
|
+
return {
|
|
90
|
+
id: PROJECT_ID,
|
|
91
|
+
_id: PROJECT_ID.toString(),
|
|
92
|
+
name: "Acme",
|
|
93
|
+
createdAt: CREATED_AT,
|
|
94
|
+
createdOwnerEmail: OWNER_EMAIL,
|
|
95
|
+
paymentProviderPlanId: GROWTH_MONTHLY_PLAN_ID,
|
|
96
|
+
planName: "Growth",
|
|
97
|
+
|
|
98
|
+
// The attribution the creating user carried, copied onto the project row.
|
|
99
|
+
utmSource: "google",
|
|
100
|
+
utmMedium: "cpc",
|
|
101
|
+
utmCampaign: "brand-uk",
|
|
102
|
+
utmTerm: "status page",
|
|
103
|
+
utmContent: "hero-cta",
|
|
104
|
+
clickIds: { gclid: "gclid_abc123" },
|
|
105
|
+
...overrides,
|
|
106
|
+
} as unknown as Project;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("ProjectService project creation - subscription_started", () => {
|
|
110
|
+
afterEach(() => {
|
|
111
|
+
jest.restoreAllMocks();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
interface CreateSpies {
|
|
115
|
+
createCustomer: jest.SpyInstance;
|
|
116
|
+
subscribeToPlan: jest.SpyInstance;
|
|
117
|
+
updateOneById: jest.SpyInstance;
|
|
118
|
+
capture: jest.SpyInstance;
|
|
119
|
+
emitInBackground: jest.SpyInstance;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/*
|
|
123
|
+
* The nine default-row seeders run after the conversion is captured and each
|
|
124
|
+
* one writes to a different table. None of them are what is under test, so
|
|
125
|
+
* they are stubbed out wholesale.
|
|
126
|
+
*/
|
|
127
|
+
const DEFAULT_SEEDERS: Array<string> = [
|
|
128
|
+
"addDefaultIncidentSeverity",
|
|
129
|
+
"addDefaultAlertSeverity",
|
|
130
|
+
"addDefaultProjectTeams",
|
|
131
|
+
"addDefaultMonitorStatus",
|
|
132
|
+
"addDefaultIncidentState",
|
|
133
|
+
"addDefaultScheduledMaintenanceState",
|
|
134
|
+
"addDefaultAlertState",
|
|
135
|
+
"addDefaultIncidentRoles",
|
|
136
|
+
"addDefaultNetworkSiteTypes",
|
|
137
|
+
];
|
|
138
|
+
|
|
139
|
+
function setup(data?: {
|
|
140
|
+
// The plan the payment provider is told about. Growth monthly by default.
|
|
141
|
+
plan?: SubscriptionPlan;
|
|
142
|
+
}): CreateSpies {
|
|
143
|
+
const createCustomer: jest.SpyInstance = jest
|
|
144
|
+
.spyOn(BillingService, "createCustomer")
|
|
145
|
+
.mockResolvedValue("cus_created_123" as never);
|
|
146
|
+
|
|
147
|
+
jest
|
|
148
|
+
.spyOn(SubscriptionPlan, "getSubscriptionPlanById")
|
|
149
|
+
.mockReturnValue(data?.plan ?? GROWTH_PLAN);
|
|
150
|
+
|
|
151
|
+
const subscribeToPlan: jest.SpyInstance = jest
|
|
152
|
+
.spyOn(BillingService, "subscribeToPlan")
|
|
153
|
+
.mockResolvedValue({
|
|
154
|
+
subscriptionId: "sub_main_created",
|
|
155
|
+
meteredSubscriptionId: "sub_metered_created",
|
|
156
|
+
trialEndsAt: null,
|
|
157
|
+
} as never);
|
|
158
|
+
|
|
159
|
+
const updateOneById: jest.SpyInstance = jest
|
|
160
|
+
.spyOn(ProjectService, "updateOneById")
|
|
161
|
+
.mockResolvedValue(undefined as never);
|
|
162
|
+
|
|
163
|
+
for (const seeder of DEFAULT_SEEDERS) {
|
|
164
|
+
jest
|
|
165
|
+
.spyOn(
|
|
166
|
+
ProjectService as unknown as Record<string, () => unknown>,
|
|
167
|
+
seeder,
|
|
168
|
+
)
|
|
169
|
+
.mockResolvedValue(undefined as never);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const capture: jest.SpyInstance = jest
|
|
173
|
+
.spyOn(ProductAnalytics, "capture")
|
|
174
|
+
.mockReturnValue(undefined);
|
|
175
|
+
|
|
176
|
+
const emitInBackground: jest.SpyInstance = jest
|
|
177
|
+
.spyOn(MarketingEventUtil, "emitInBackground")
|
|
178
|
+
.mockReturnValue(undefined);
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
createCustomer,
|
|
182
|
+
subscribeToPlan,
|
|
183
|
+
updateOneById,
|
|
184
|
+
capture,
|
|
185
|
+
emitInBackground,
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/*
|
|
190
|
+
* onCreateSuccess is the hook the create path runs once the row exists; it
|
|
191
|
+
* is protected, so it is reached the way the framework reaches it.
|
|
192
|
+
*/
|
|
193
|
+
async function createProject(project: Project): Promise<void> {
|
|
194
|
+
await (
|
|
195
|
+
ProjectService as unknown as {
|
|
196
|
+
onCreateSuccess: (
|
|
197
|
+
onCreate: unknown,
|
|
198
|
+
createdItem: Project,
|
|
199
|
+
) => Promise<Project>;
|
|
200
|
+
}
|
|
201
|
+
).onCreateSuccess({}, project);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function getEmittedEvent(spies: CreateSpies): MarketingEvent {
|
|
205
|
+
return spies.emitInBackground.mock.calls[0]![0] as MarketingEvent;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function getEmittedData(spies: CreateSpies): JSONObject {
|
|
209
|
+
return getEmittedEvent(spies).data;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
describe("emitting the conversion", () => {
|
|
213
|
+
it("should emit subscription_started when a project is created on a paid plan", async () => {
|
|
214
|
+
const spies: CreateSpies = setup();
|
|
215
|
+
|
|
216
|
+
await createProject(fakeProject());
|
|
217
|
+
|
|
218
|
+
expect(spies.emitInBackground).toHaveBeenCalledTimes(1);
|
|
219
|
+
expect(getEmittedEvent(spies).eventType).toBe("subscription_started");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("should key the event on the project id alone, with no timestamp", async () => {
|
|
223
|
+
/*
|
|
224
|
+
* This is the assertion the whole event stands on. The queue retries
|
|
225
|
+
* delivery, and the receiver dedupes on eventId - a project has exactly
|
|
226
|
+
* one first subscription, so the id must be stable across attempts. Put
|
|
227
|
+
* an instant in it, the way a plan change has to, and every retry lands
|
|
228
|
+
* as another net-new conversion.
|
|
229
|
+
*/
|
|
230
|
+
const spies: CreateSpies = setup();
|
|
231
|
+
|
|
232
|
+
await createProject(fakeProject());
|
|
233
|
+
|
|
234
|
+
expect(getEmittedEvent(spies).eventId).toBe(
|
|
235
|
+
`subscription_started:${PROJECT_ID.toString()}`,
|
|
236
|
+
);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("should carry the owner's address as the identity", async () => {
|
|
240
|
+
const spies: CreateSpies = setup();
|
|
241
|
+
|
|
242
|
+
await createProject(fakeProject());
|
|
243
|
+
|
|
244
|
+
expect(getEmittedEvent(spies).email).toBe(OWNER_EMAIL);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it("should date the conversion from the project's creation, not from the emit", async () => {
|
|
248
|
+
/*
|
|
249
|
+
* occurredAt is what the receiver orders on, and the conversion happened
|
|
250
|
+
* when the project was created. Reading the clock here instead would
|
|
251
|
+
* date every replayed or delayed emit to whenever the queue got round
|
|
252
|
+
* to it.
|
|
253
|
+
*/
|
|
254
|
+
const spies: CreateSpies = setup();
|
|
255
|
+
|
|
256
|
+
await createProject(fakeProject());
|
|
257
|
+
|
|
258
|
+
expect(getEmittedEvent(spies).occurredAt).toBe(CREATED_AT.toISOString());
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
it("should mark the conversion as paid", async () => {
|
|
262
|
+
const spies: CreateSpies = setup();
|
|
263
|
+
|
|
264
|
+
await createProject(fakeProject());
|
|
265
|
+
|
|
266
|
+
expect(getEmittedData(spies)["is_paid_conversion"]).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("should capture the same conversion in product analytics", async () => {
|
|
270
|
+
const spies: CreateSpies = setup();
|
|
271
|
+
|
|
272
|
+
await createProject(fakeProject());
|
|
273
|
+
|
|
274
|
+
expect(spies.capture).toHaveBeenCalledWith(
|
|
275
|
+
expect.objectContaining({
|
|
276
|
+
event: "server/subscription_started",
|
|
277
|
+
distinctId: OWNER_EMAIL,
|
|
278
|
+
properties: expect.objectContaining({
|
|
279
|
+
new_plan: "Growth",
|
|
280
|
+
is_paid_conversion: true,
|
|
281
|
+
}),
|
|
282
|
+
}),
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
/*
|
|
288
|
+
* Net-new revenue and expansion revenue are different numbers, and once one
|
|
289
|
+
* is reported as the other nothing downstream can separate them again. This
|
|
290
|
+
* event exists precisely because a first subscription has no previous tier
|
|
291
|
+
* to have moved from.
|
|
292
|
+
*/
|
|
293
|
+
describe("distinctness from an upgrade", () => {
|
|
294
|
+
it("should never emit a first subscription as subscription_upgraded", async () => {
|
|
295
|
+
const spies: CreateSpies = setup();
|
|
296
|
+
|
|
297
|
+
await createProject(fakeProject());
|
|
298
|
+
|
|
299
|
+
const emittedTypes: Array<string> = spies.emitInBackground.mock.calls.map(
|
|
300
|
+
(call: Array<unknown>) => {
|
|
301
|
+
return (call[0] as MarketingEvent).eventType as string;
|
|
302
|
+
},
|
|
303
|
+
);
|
|
304
|
+
|
|
305
|
+
expect(emittedTypes).toEqual(["subscription_started"]);
|
|
306
|
+
expect(emittedTypes).not.toContain("subscription_upgraded");
|
|
307
|
+
expect(emittedTypes).not.toContain("subscription_downgraded");
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
it("should not describe a first subscription as a movement between tiers", async () => {
|
|
311
|
+
// There is no old plan, so nothing that implies one may be reported.
|
|
312
|
+
const spies: CreateSpies = setup();
|
|
313
|
+
|
|
314
|
+
await createProject(fakeProject());
|
|
315
|
+
|
|
316
|
+
expect(getEmittedData(spies)).not.toHaveProperty("is_upgrade");
|
|
317
|
+
expect(getEmittedData(spies)).not.toHaveProperty("old_plan");
|
|
318
|
+
expect(getEmittedData(spies)).not.toHaveProperty(
|
|
319
|
+
"old_monthly_amount_in_usd",
|
|
320
|
+
);
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
describe("what counts as revenue", () => {
|
|
325
|
+
it("should stay silent for a project created on the free plan", async () => {
|
|
326
|
+
/*
|
|
327
|
+
* Every project is subscribed at the payment provider, free ones
|
|
328
|
+
* included, so creation alone is not the signal - the paid check is the
|
|
329
|
+
* only thing keeping this a revenue event rather than a signup event
|
|
330
|
+
* under another name.
|
|
331
|
+
*/
|
|
332
|
+
const spies: CreateSpies = setup({ plan: FREE_PLAN });
|
|
333
|
+
|
|
334
|
+
await createProject(
|
|
335
|
+
fakeProject({ paymentProviderPlanId: FREE_MONTHLY_PLAN_ID }),
|
|
336
|
+
);
|
|
337
|
+
|
|
338
|
+
expect(spies.emitInBackground).not.toHaveBeenCalled();
|
|
339
|
+
expect(spies.capture).not.toHaveBeenCalledWith(
|
|
340
|
+
expect.objectContaining({ event: "server/subscription_started" }),
|
|
341
|
+
);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
it("should stay silent when the project has no owner email", async () => {
|
|
345
|
+
/*
|
|
346
|
+
* The address is the identity the conversion joins on. Without one there
|
|
347
|
+
* is nothing for a receiver to attach the revenue to.
|
|
348
|
+
*/
|
|
349
|
+
const spies: CreateSpies = setup();
|
|
350
|
+
|
|
351
|
+
await createProject(fakeProject({ createdOwnerEmail: null }));
|
|
352
|
+
|
|
353
|
+
expect(spies.emitInBackground).not.toHaveBeenCalled();
|
|
354
|
+
expect(spies.capture).not.toHaveBeenCalledWith(
|
|
355
|
+
expect.objectContaining({ event: "server/subscription_started" }),
|
|
356
|
+
);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("should still emit for a custom-priced plan, which is paid without a published amount", async () => {
|
|
360
|
+
const spies: CreateSpies = setup({ plan: ENTERPRISE_PLAN });
|
|
361
|
+
|
|
362
|
+
await createProject(
|
|
363
|
+
fakeProject({ paymentProviderPlanId: ENTERPRISE_MONTHLY_PLAN_ID }),
|
|
364
|
+
);
|
|
365
|
+
|
|
366
|
+
expect(spies.emitInBackground).toHaveBeenCalledTimes(1);
|
|
367
|
+
expect(getEmittedData(spies)["is_paid_conversion"]).toBe(true);
|
|
368
|
+
expect(getEmittedData(spies)["has_custom_pricing"]).toBe(true);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
describe("the amount reported", () => {
|
|
373
|
+
it("should report the monthly amount and its currency for a monthly plan", async () => {
|
|
374
|
+
const spies: CreateSpies = setup();
|
|
375
|
+
|
|
376
|
+
await createProject(fakeProject());
|
|
377
|
+
|
|
378
|
+
expect(getEmittedData(spies)["new_monthly_amount_in_usd"]).toBe(
|
|
379
|
+
GROWTH_MONTHLY_AMOUNT,
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
// One seat at creation, so value is the monthly amount itself.
|
|
383
|
+
expect(getEmittedData(spies)["value"]).toBe(GROWTH_MONTHLY_AMOUNT);
|
|
384
|
+
expect(getEmittedData(spies)["currency"]).toBe("USD");
|
|
385
|
+
expect(getEmittedData(spies)["seats"]).toBe(1);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
it("should report the yearly plan's per-month amount, not the monthly one", async () => {
|
|
389
|
+
/*
|
|
390
|
+
* Yearly is discounted. Reporting the monthly sticker price for a
|
|
391
|
+
* customer on yearly overstates the recurring revenue on every yearly
|
|
392
|
+
* conversion, which is the bid signal these events feed.
|
|
393
|
+
*/
|
|
394
|
+
const spies: CreateSpies = setup();
|
|
395
|
+
|
|
396
|
+
await createProject(
|
|
397
|
+
fakeProject({ paymentProviderPlanId: GROWTH_YEARLY_PLAN_ID }),
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
expect(getEmittedData(spies)["new_monthly_amount_in_usd"]).toBe(
|
|
401
|
+
GROWTH_YEARLY_AMOUNT,
|
|
402
|
+
);
|
|
403
|
+
expect(getEmittedData(spies)["value"]).toBe(GROWTH_YEARLY_AMOUNT);
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it("should omit the amount entirely for a custom-priced plan", async () => {
|
|
407
|
+
/*
|
|
408
|
+
* The -1 sentinel means "not published here", not "free". Sending it as
|
|
409
|
+
* a value - or as a zero, or a null - would be a made-up number in a
|
|
410
|
+
* revenue field, and a receiver has no way to tell it from a real one.
|
|
411
|
+
* Absent is the only honest answer.
|
|
412
|
+
*/
|
|
413
|
+
const spies: CreateSpies = setup({ plan: ENTERPRISE_PLAN });
|
|
414
|
+
|
|
415
|
+
await createProject(
|
|
416
|
+
fakeProject({ paymentProviderPlanId: ENTERPRISE_MONTHLY_PLAN_ID }),
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
expect(getEmittedData(spies)["value"]).toBeUndefined();
|
|
420
|
+
expect(getEmittedData(spies)["currency"]).toBeUndefined();
|
|
421
|
+
expect(
|
|
422
|
+
getEmittedData(spies)["new_monthly_amount_in_usd"],
|
|
423
|
+
).toBeUndefined();
|
|
424
|
+
|
|
425
|
+
expect(getEmittedData(spies)).not.toHaveProperty("value");
|
|
426
|
+
expect(getEmittedData(spies)).not.toHaveProperty("currency");
|
|
427
|
+
expect(getEmittedData(spies)).not.toHaveProperty(
|
|
428
|
+
"new_monthly_amount_in_usd",
|
|
429
|
+
);
|
|
430
|
+
});
|
|
431
|
+
});
|
|
432
|
+
|
|
433
|
+
/*
|
|
434
|
+
* The campaign that produced the project is the whole point of reporting the
|
|
435
|
+
* conversion: without it the revenue cannot be attributed to the spend that
|
|
436
|
+
* bought it. This event deliberately carries more of it than the plan-change
|
|
437
|
+
* event does - term and content included.
|
|
438
|
+
*/
|
|
439
|
+
describe("attribution", () => {
|
|
440
|
+
it("should copy the campaign off the project row onto the event", async () => {
|
|
441
|
+
const spies: CreateSpies = setup();
|
|
442
|
+
|
|
443
|
+
await createProject(fakeProject());
|
|
444
|
+
|
|
445
|
+
expect(getEmittedEvent(spies).attribution).toEqual(
|
|
446
|
+
expect.objectContaining({
|
|
447
|
+
utmSource: "google",
|
|
448
|
+
utmMedium: "cpc",
|
|
449
|
+
utmCampaign: "brand-uk",
|
|
450
|
+
utmTerm: "status page",
|
|
451
|
+
utmContent: "hero-cta",
|
|
452
|
+
clickIds: { gclid: "gclid_abc123" },
|
|
453
|
+
}),
|
|
454
|
+
);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it("should carry the campaign in the event payload as well", async () => {
|
|
458
|
+
const spies: CreateSpies = setup();
|
|
459
|
+
|
|
460
|
+
await createProject(fakeProject());
|
|
461
|
+
|
|
462
|
+
expect(getEmittedData(spies)).toEqual(
|
|
463
|
+
expect.objectContaining({
|
|
464
|
+
utm_source: "google",
|
|
465
|
+
utm_medium: "cpc",
|
|
466
|
+
utm_campaign: "brand-uk",
|
|
467
|
+
utm_term: "status page",
|
|
468
|
+
utm_content: "hero-cta",
|
|
469
|
+
click_ids: { gclid: "gclid_abc123" },
|
|
470
|
+
}),
|
|
471
|
+
);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("should still emit a conversion that carried no campaign at all", async () => {
|
|
475
|
+
const spies: CreateSpies = setup();
|
|
476
|
+
|
|
477
|
+
await createProject(
|
|
478
|
+
fakeProject({
|
|
479
|
+
utmSource: null,
|
|
480
|
+
utmMedium: null,
|
|
481
|
+
utmCampaign: null,
|
|
482
|
+
utmTerm: null,
|
|
483
|
+
utmContent: null,
|
|
484
|
+
clickIds: null,
|
|
485
|
+
}),
|
|
486
|
+
);
|
|
487
|
+
|
|
488
|
+
expect(spies.emitInBackground).toHaveBeenCalledTimes(1);
|
|
489
|
+
expect(getEmittedData(spies)["utm_source"]).toBe("");
|
|
490
|
+
expect(getEmittedData(spies)["click_ids"]).toEqual({});
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
|
|
494
|
+
describe("ordering against the project row", () => {
|
|
495
|
+
it("should report the conversion only after the subscription ids are written down", async () => {
|
|
496
|
+
/*
|
|
497
|
+
* The write is what makes the subscription real to OneUptime, and the
|
|
498
|
+
* conversion describes a subscription that exists. Same ordering the
|
|
499
|
+
* plan-change path uses.
|
|
500
|
+
*/
|
|
501
|
+
const spies: CreateSpies = setup();
|
|
502
|
+
|
|
503
|
+
await createProject(fakeProject());
|
|
504
|
+
|
|
505
|
+
expect(spies.updateOneById).toHaveBeenCalled();
|
|
506
|
+
expect(spies.emitInBackground).toHaveBeenCalled();
|
|
507
|
+
|
|
508
|
+
const writeOrder: number =
|
|
509
|
+
spies.updateOneById.mock.invocationCallOrder[0]!;
|
|
510
|
+
const emitOrder: number =
|
|
511
|
+
spies.emitInBackground.mock.invocationCallOrder[0]!;
|
|
512
|
+
|
|
513
|
+
expect(emitOrder).toBeGreaterThan(writeOrder);
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
});
|