backend-manager 5.0.122 → 5.0.124
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/CHANGELOG.md +48 -0
- package/CLAUDE.md +18 -19
- package/README.md +7 -7
- package/package.json +1 -1
- package/src/manager/cron/daily/reset-usage.js +79 -73
- package/src/manager/cron/daily.js +2 -53
- package/src/manager/cron/frequent/abandoned-carts.js +148 -0
- package/src/manager/cron/frequent.js +3 -0
- package/src/manager/cron/runner.js +60 -0
- package/src/manager/events/firestore/payments-disputes/on-write.js +358 -0
- package/src/manager/events/firestore/payments-webhooks/analytics.js +245 -121
- package/src/manager/events/firestore/payments-webhooks/on-write.js +32 -2
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +11 -34
- package/src/manager/helpers/analytics.js +2 -2
- package/src/manager/helpers/usage.js +44 -20
- package/src/manager/helpers/user.js +2 -1
- package/src/manager/index.js +10 -0
- package/src/manager/libraries/abandoned-cart-config.js +12 -0
- package/src/manager/libraries/email.js +5 -5
- package/src/manager/libraries/openai.js +76 -7
- package/src/manager/libraries/payment/discount-codes.js +40 -0
- package/src/manager/libraries/recaptcha.js +36 -0
- package/src/manager/routes/app/get.js +1 -1
- package/src/manager/routes/marketing/contact/post.js +11 -29
- package/src/manager/routes/payments/discount/get.js +22 -0
- package/src/manager/routes/payments/dispute-alert/post.js +93 -0
- package/src/manager/routes/payments/dispute-alert/processors/chargeblast.js +43 -0
- package/src/manager/routes/payments/intent/post.js +29 -0
- package/src/manager/routes/payments/intent/processors/chargebee.js +59 -7
- package/src/manager/routes/payments/intent/processors/stripe.js +55 -7
- package/src/manager/routes/test/usage/post.js +10 -6
- package/src/manager/schemas/payments/discount/get.js +9 -0
- package/src/manager/schemas/payments/dispute-alert/post.js +6 -0
- package/src/manager/schemas/payments/intent/post.js +16 -0
- package/src/test/runner.js +14 -4
- package/src/test/test-accounts.js +18 -0
- package/templates/backend-manager-config.json +7 -1
- package/templates/firestore.rules +9 -1
- package/test/_legacy/usage.js +5 -5
- package/test/routes/marketing/contact.js +3 -2
- package/test/routes/payments/discount.js +80 -0
- package/test/routes/payments/dispute-alert.js +271 -0
- package/test/routes/payments/intent.js +60 -0
- package/test/routes/test/usage.js +134 -30
- package/test/rules/payments-carts.js +371 -0
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: POST /payments/dispute-alert
|
|
3
|
+
* Tests the dispute alert endpoint validates requests and saves to Firestore
|
|
4
|
+
*/
|
|
5
|
+
module.exports = {
|
|
6
|
+
description: 'Dispute alert endpoint',
|
|
7
|
+
type: 'group',
|
|
8
|
+
timeout: 30000,
|
|
9
|
+
|
|
10
|
+
tests: [
|
|
11
|
+
{
|
|
12
|
+
name: 'rejects-missing-key',
|
|
13
|
+
auth: 'none',
|
|
14
|
+
async run({ http, assert }) {
|
|
15
|
+
const response = await http.as('none').post('payments/dispute-alert', {});
|
|
16
|
+
|
|
17
|
+
assert.isError(response, 401, 'Should reject missing key');
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
{
|
|
22
|
+
name: 'rejects-invalid-key',
|
|
23
|
+
auth: 'none',
|
|
24
|
+
async run({ http, assert }) {
|
|
25
|
+
const response = await http.as('none').post('payments/dispute-alert?key=wrong-key', {});
|
|
26
|
+
|
|
27
|
+
assert.isError(response, 401, 'Should reject invalid key');
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
|
|
31
|
+
{
|
|
32
|
+
name: 'rejects-unknown-provider',
|
|
33
|
+
auth: 'none',
|
|
34
|
+
async run({ http, assert }) {
|
|
35
|
+
const response = await http.as('none').post(`payments/dispute-alert?alerts=unknown&key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
36
|
+
id: '_test-dispute-unknown-provider',
|
|
37
|
+
card: '4242',
|
|
38
|
+
amount: 9.99,
|
|
39
|
+
transactionDate: '2026-01-15',
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
assert.isError(response, 400, 'Should reject unknown alert provider');
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
|
|
46
|
+
{
|
|
47
|
+
name: 'rejects-missing-id',
|
|
48
|
+
auth: 'none',
|
|
49
|
+
async run({ http, assert }) {
|
|
50
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
51
|
+
card: '4242',
|
|
52
|
+
amount: 9.99,
|
|
53
|
+
transactionDate: '2026-01-15',
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
assert.isError(response, 400, 'Should reject missing id');
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
{
|
|
61
|
+
name: 'rejects-missing-card',
|
|
62
|
+
auth: 'none',
|
|
63
|
+
async run({ http, assert }) {
|
|
64
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
65
|
+
id: '_test-dispute-no-card',
|
|
66
|
+
amount: 9.99,
|
|
67
|
+
transactionDate: '2026-01-15',
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
assert.isError(response, 400, 'Should reject missing card');
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
{
|
|
75
|
+
name: 'rejects-missing-amount',
|
|
76
|
+
auth: 'none',
|
|
77
|
+
async run({ http, assert }) {
|
|
78
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
79
|
+
id: '_test-dispute-no-amount',
|
|
80
|
+
card: '4242',
|
|
81
|
+
transactionDate: '2026-01-15',
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
assert.isError(response, 400, 'Should reject missing amount');
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
{
|
|
89
|
+
name: 'rejects-missing-transaction-date',
|
|
90
|
+
auth: 'none',
|
|
91
|
+
async run({ http, assert }) {
|
|
92
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
93
|
+
id: '_test-dispute-no-date',
|
|
94
|
+
card: '4242',
|
|
95
|
+
amount: 9.99,
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
assert.isError(response, 400, 'Should reject missing transactionDate');
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
|
|
102
|
+
{
|
|
103
|
+
name: 'accepts-valid-chargeblast-alert',
|
|
104
|
+
auth: 'none',
|
|
105
|
+
async run({ http, assert, firestore }) {
|
|
106
|
+
const alertId = '_test-dispute-valid';
|
|
107
|
+
|
|
108
|
+
// Clean up any existing doc
|
|
109
|
+
await firestore.delete(`payments-disputes/${alertId}`);
|
|
110
|
+
|
|
111
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
112
|
+
id: alertId,
|
|
113
|
+
card: '4242424242424242',
|
|
114
|
+
cardBrand: 'Visa',
|
|
115
|
+
amount: 29.99,
|
|
116
|
+
transactionDate: '2026-03-07 14:30:00',
|
|
117
|
+
processor: 'stripe',
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
assert.isSuccess(response, 'Should accept valid Chargeblast alert');
|
|
121
|
+
assert.equal(response.data.received, true, 'Should confirm receipt');
|
|
122
|
+
|
|
123
|
+
// Verify doc was saved to Firestore
|
|
124
|
+
const doc = await firestore.get(`payments-disputes/${alertId}`);
|
|
125
|
+
assert.ok(doc, 'Dispute doc should exist in Firestore');
|
|
126
|
+
assert.equal(doc.provider, 'chargeblast', 'Provider should be chargeblast');
|
|
127
|
+
assert.ok(
|
|
128
|
+
doc.status === 'pending' || doc.status === 'processing',
|
|
129
|
+
'Status should be pending or processing',
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
// Verify normalized alert data
|
|
133
|
+
assert.equal(doc.alert.card.last4, '4242', 'Should extract last4 from full card number');
|
|
134
|
+
assert.equal(doc.alert.card.brand, 'visa', 'Should lowercase card brand');
|
|
135
|
+
assert.equal(doc.alert.amount, 29.99, 'Amount should be preserved');
|
|
136
|
+
assert.equal(doc.alert.transactionDate, '2026-03-07', 'Should extract date without time');
|
|
137
|
+
assert.equal(doc.alert.processor, 'stripe', 'Processor should be stripe');
|
|
138
|
+
|
|
139
|
+
// Verify raw payload is preserved
|
|
140
|
+
assert.ok(doc.raw, 'Raw payload should be preserved');
|
|
141
|
+
assert.equal(doc.raw.id, alertId, 'Raw id should match');
|
|
142
|
+
|
|
143
|
+
// Clean up
|
|
144
|
+
await firestore.delete(`payments-disputes/${alertId}`);
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
|
|
148
|
+
{
|
|
149
|
+
name: 'accepts-alert-with-last4-only',
|
|
150
|
+
auth: 'none',
|
|
151
|
+
async run({ http, assert, firestore }) {
|
|
152
|
+
const alertId = '_test-dispute-last4';
|
|
153
|
+
|
|
154
|
+
// Clean up any existing doc
|
|
155
|
+
await firestore.delete(`payments-disputes/${alertId}`);
|
|
156
|
+
|
|
157
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
158
|
+
id: alertId,
|
|
159
|
+
card: '1234',
|
|
160
|
+
amount: 9.99,
|
|
161
|
+
transactionDate: '2026-01-15',
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
assert.isSuccess(response, 'Should accept alert with card last4 only');
|
|
165
|
+
|
|
166
|
+
const doc = await firestore.get(`payments-disputes/${alertId}`);
|
|
167
|
+
assert.equal(doc.alert.card.last4, '1234', 'Should use card value as last4 when already 4 digits');
|
|
168
|
+
assert.equal(doc.alert.processor, 'stripe', 'Processor should default to stripe');
|
|
169
|
+
|
|
170
|
+
// Clean up
|
|
171
|
+
await firestore.delete(`payments-disputes/${alertId}`);
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
|
|
175
|
+
{
|
|
176
|
+
name: 'deduplicates-dispute-alerts',
|
|
177
|
+
auth: 'none',
|
|
178
|
+
async run({ http, assert, firestore }) {
|
|
179
|
+
const alertId = '_test-dispute-duplicate';
|
|
180
|
+
|
|
181
|
+
// Clean up any existing doc
|
|
182
|
+
await firestore.delete(`payments-disputes/${alertId}`);
|
|
183
|
+
|
|
184
|
+
// Send first alert
|
|
185
|
+
await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
186
|
+
id: alertId,
|
|
187
|
+
card: '4242',
|
|
188
|
+
amount: 29.99,
|
|
189
|
+
transactionDate: '2026-03-07',
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// Send duplicate
|
|
193
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
194
|
+
id: alertId,
|
|
195
|
+
card: '4242',
|
|
196
|
+
amount: 29.99,
|
|
197
|
+
transactionDate: '2026-03-07',
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
assert.isSuccess(response, 'Duplicate should still return 200');
|
|
201
|
+
assert.equal(response.data.duplicate, true, 'Should indicate duplicate');
|
|
202
|
+
|
|
203
|
+
// Clean up
|
|
204
|
+
await firestore.delete(`payments-disputes/${alertId}`);
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
{
|
|
209
|
+
name: 'retries-failed-alerts',
|
|
210
|
+
auth: 'none',
|
|
211
|
+
async run({ http, assert, firestore }) {
|
|
212
|
+
const alertId = '_test-dispute-retry';
|
|
213
|
+
|
|
214
|
+
// Pre-seed a failed dispute
|
|
215
|
+
await firestore.set(`payments-disputes/${alertId}`, {
|
|
216
|
+
id: alertId,
|
|
217
|
+
status: 'failed',
|
|
218
|
+
error: 'Previous error',
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Send alert with same ID — should retry since previous status was 'failed'
|
|
222
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
223
|
+
id: alertId,
|
|
224
|
+
card: '4242',
|
|
225
|
+
amount: 29.99,
|
|
226
|
+
transactionDate: '2026-03-07',
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
assert.isSuccess(response, 'Should accept retry of failed alert');
|
|
230
|
+
assert.ok(!response.data.duplicate, 'Should not indicate duplicate for failed retry');
|
|
231
|
+
|
|
232
|
+
// Verify doc was updated
|
|
233
|
+
const doc = await firestore.get(`payments-disputes/${alertId}`);
|
|
234
|
+
assert.ok(
|
|
235
|
+
doc.status === 'pending' || doc.status === 'processing',
|
|
236
|
+
'Status should be pending or processing after retry',
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
// Clean up
|
|
240
|
+
await firestore.delete(`payments-disputes/${alertId}`);
|
|
241
|
+
},
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
{
|
|
245
|
+
name: 'defaults-alerts-to-chargeblast',
|
|
246
|
+
auth: 'none',
|
|
247
|
+
async run({ http, assert, firestore }) {
|
|
248
|
+
const alertId = '_test-dispute-default-provider';
|
|
249
|
+
|
|
250
|
+
// Clean up any existing doc
|
|
251
|
+
await firestore.delete(`payments-disputes/${alertId}`);
|
|
252
|
+
|
|
253
|
+
// Send without alerts query param
|
|
254
|
+
const response = await http.as('none').post(`payments/dispute-alert?key=${process.env.BACKEND_MANAGER_KEY}`, {
|
|
255
|
+
id: alertId,
|
|
256
|
+
card: '4242',
|
|
257
|
+
amount: 9.99,
|
|
258
|
+
transactionDate: '2026-01-15',
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
assert.isSuccess(response, 'Should accept without explicit alerts param');
|
|
262
|
+
|
|
263
|
+
const doc = await firestore.get(`payments-disputes/${alertId}`);
|
|
264
|
+
assert.equal(doc.provider, 'chargeblast', 'Provider should default to chargeblast');
|
|
265
|
+
|
|
266
|
+
// Clean up
|
|
267
|
+
await firestore.delete(`payments-disputes/${alertId}`);
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
],
|
|
271
|
+
};
|
|
@@ -113,6 +113,66 @@ module.exports = {
|
|
|
113
113
|
},
|
|
114
114
|
},
|
|
115
115
|
|
|
116
|
+
{
|
|
117
|
+
name: 'rejects-invalid-discount-code',
|
|
118
|
+
async run({ http, assert, config }) {
|
|
119
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
120
|
+
|
|
121
|
+
const response = await http.as('basic').post('payments/intent', {
|
|
122
|
+
processor: 'stripe',
|
|
123
|
+
productId: paidProduct.id,
|
|
124
|
+
frequency: 'monthly',
|
|
125
|
+
discount: 'FAKECODE',
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
assert.isError(response, 400, 'Should reject invalid discount code');
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
|
|
132
|
+
{
|
|
133
|
+
name: 'saves-discount-to-intent-doc',
|
|
134
|
+
async run({ http, assert, config, firestore }) {
|
|
135
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
136
|
+
|
|
137
|
+
const response = await http.as('journey-payments-intent-discount').post('payments/intent', {
|
|
138
|
+
processor: 'test',
|
|
139
|
+
productId: paidProduct.id,
|
|
140
|
+
frequency: 'monthly',
|
|
141
|
+
discount: 'FLASH20',
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
assert.isSuccess(response, 'Should succeed with valid discount');
|
|
145
|
+
|
|
146
|
+
// Verify discount was resolved and saved to intent doc
|
|
147
|
+
const intentDoc = await firestore.get(`payments-intents/${response.data.orderId}`);
|
|
148
|
+
assert.ok(intentDoc.discount, 'Discount should be saved on intent');
|
|
149
|
+
assert.equal(intentDoc.discount.code, 'FLASH20', 'Discount code should match');
|
|
150
|
+
assert.equal(intentDoc.discount.percent, 20, 'Discount percent should be 20');
|
|
151
|
+
assert.equal(intentDoc.discount.duration, 'once', 'Discount duration should be once');
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
|
|
155
|
+
{
|
|
156
|
+
name: 'saves-attribution-and-supplemental-to-intent-doc',
|
|
157
|
+
async run({ http, assert, config, firestore }) {
|
|
158
|
+
const paidProduct = config.payment.products.find(p => p.id !== 'basic' && p.prices);
|
|
159
|
+
|
|
160
|
+
const response = await http.as('journey-payments-intent-attribution').post('payments/intent', {
|
|
161
|
+
processor: 'test',
|
|
162
|
+
productId: paidProduct.id,
|
|
163
|
+
frequency: 'monthly',
|
|
164
|
+
attribution: { utm_source: 'test', utm_medium: 'unit-test' },
|
|
165
|
+
supplemental: { referral: 'friend' },
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
assert.isSuccess(response, 'Should succeed with attribution and supplemental');
|
|
169
|
+
|
|
170
|
+
const intentDoc = await firestore.get(`payments-intents/${response.data.orderId}`);
|
|
171
|
+
assert.equal(intentDoc.attribution.utm_source, 'test', 'Attribution utm_source should be saved');
|
|
172
|
+
assert.equal(intentDoc.supplemental.referral, 'friend', 'Supplemental should be saved');
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
|
|
116
176
|
{
|
|
117
177
|
name: 'succeeds-with-test-processor',
|
|
118
178
|
async run({ http, assert, config, firestore, accounts, waitFor }) {
|
|
@@ -19,7 +19,8 @@ module.exports = {
|
|
|
19
19
|
state.initialUsage = userDoc?.usage || {};
|
|
20
20
|
|
|
21
21
|
// Store initial values for requests metric (may not exist yet)
|
|
22
|
-
state.
|
|
22
|
+
state.initialMonthly = state.initialUsage?.requests?.monthly || 0;
|
|
23
|
+
state.initialDaily = state.initialUsage?.requests?.daily || 0;
|
|
23
24
|
state.initialTotal = state.initialUsage?.requests?.total || 0;
|
|
24
25
|
|
|
25
26
|
assert.ok(true, 'Initial usage state captured');
|
|
@@ -42,12 +43,21 @@ module.exports = {
|
|
|
42
43
|
assert.equal(response.data.metric, 'requests', 'Metric should be requests');
|
|
43
44
|
assert.equal(response.data.amount, 1, 'Default amount should be 1');
|
|
44
45
|
|
|
45
|
-
// Verify
|
|
46
|
+
// Verify monthly incremented
|
|
46
47
|
assert.equal(
|
|
47
|
-
response.data.after.
|
|
48
|
-
response.data.before.
|
|
49
|
-
'
|
|
48
|
+
response.data.after.monthly,
|
|
49
|
+
response.data.before.monthly + 1,
|
|
50
|
+
'Monthly should be incremented by 1'
|
|
50
51
|
);
|
|
52
|
+
|
|
53
|
+
// Verify daily incremented
|
|
54
|
+
assert.equal(
|
|
55
|
+
response.data.after.daily,
|
|
56
|
+
response.data.before.daily + 1,
|
|
57
|
+
'Daily should be incremented by 1'
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
// Verify total incremented
|
|
51
61
|
assert.equal(
|
|
52
62
|
response.data.after.total,
|
|
53
63
|
response.data.before.total + 1,
|
|
@@ -69,15 +79,25 @@ module.exports = {
|
|
|
69
79
|
assert.ok(userDoc?.usage?.requests, 'User should have requests usage');
|
|
70
80
|
|
|
71
81
|
assert.equal(
|
|
72
|
-
userDoc.usage.requests.
|
|
73
|
-
state.afterFirstIncrement.
|
|
74
|
-
'Persisted
|
|
82
|
+
userDoc.usage.requests.monthly,
|
|
83
|
+
state.afterFirstIncrement.monthly,
|
|
84
|
+
'Persisted monthly should match API response'
|
|
85
|
+
);
|
|
86
|
+
assert.equal(
|
|
87
|
+
userDoc.usage.requests.daily,
|
|
88
|
+
state.afterFirstIncrement.daily,
|
|
89
|
+
'Persisted daily should match API response'
|
|
75
90
|
);
|
|
76
91
|
assert.equal(
|
|
77
92
|
userDoc.usage.requests.total,
|
|
78
93
|
state.afterFirstIncrement.total,
|
|
79
94
|
'Persisted total should match API response'
|
|
80
95
|
);
|
|
96
|
+
|
|
97
|
+
// Verify last timestamp exists
|
|
98
|
+
assert.ok(userDoc.usage.requests.last, 'Should have last object');
|
|
99
|
+
assert.ok(userDoc.usage.requests.last.timestamp, 'Should have last.timestamp');
|
|
100
|
+
assert.ok(userDoc.usage.requests.last.timestampUNIX, 'Should have last.timestampUNIX');
|
|
81
101
|
},
|
|
82
102
|
},
|
|
83
103
|
|
|
@@ -92,11 +112,16 @@ module.exports = {
|
|
|
92
112
|
assert.isSuccess(response, 'Custom amount increment should succeed');
|
|
93
113
|
assert.equal(response.data.amount, 5, 'Amount should be 5');
|
|
94
114
|
|
|
95
|
-
// Verify
|
|
115
|
+
// Verify all counters incremented by 5
|
|
116
|
+
assert.equal(
|
|
117
|
+
response.data.after.monthly,
|
|
118
|
+
response.data.before.monthly + 5,
|
|
119
|
+
'Monthly should be incremented by 5'
|
|
120
|
+
);
|
|
96
121
|
assert.equal(
|
|
97
|
-
response.data.after.
|
|
98
|
-
response.data.before.
|
|
99
|
-
'
|
|
122
|
+
response.data.after.daily,
|
|
123
|
+
response.data.before.daily + 5,
|
|
124
|
+
'Daily should be incremented by 5'
|
|
100
125
|
);
|
|
101
126
|
assert.equal(
|
|
102
127
|
response.data.after.total,
|
|
@@ -117,9 +142,14 @@ module.exports = {
|
|
|
117
142
|
assert.ok(userDoc?.usage?.requests, 'User should have requests usage');
|
|
118
143
|
|
|
119
144
|
assert.equal(
|
|
120
|
-
userDoc.usage.requests.
|
|
121
|
-
state.afterCustomAmount.
|
|
122
|
-
'Requests
|
|
145
|
+
userDoc.usage.requests.monthly,
|
|
146
|
+
state.afterCustomAmount.monthly,
|
|
147
|
+
'Requests monthly should be persisted'
|
|
148
|
+
);
|
|
149
|
+
assert.equal(
|
|
150
|
+
userDoc.usage.requests.daily,
|
|
151
|
+
state.afterCustomAmount.daily,
|
|
152
|
+
'Requests daily should be persisted'
|
|
123
153
|
);
|
|
124
154
|
assert.equal(
|
|
125
155
|
userDoc.usage.requests.total,
|
|
@@ -151,13 +181,19 @@ module.exports = {
|
|
|
151
181
|
assert.isSuccess(response3, 'Third increment should succeed');
|
|
152
182
|
|
|
153
183
|
// Verify accumulation: should be initial + 1 (test 2) + 5 (test 4) + 1 + 1 + 3 = initial + 11
|
|
154
|
-
const
|
|
184
|
+
const expectedMonthly = state.initialMonthly + 11;
|
|
185
|
+
const expectedDaily = state.initialDaily + 11;
|
|
155
186
|
const expectedTotal = state.initialTotal + 11;
|
|
156
187
|
|
|
157
188
|
assert.equal(
|
|
158
|
-
response3.data.after.
|
|
159
|
-
|
|
160
|
-
`
|
|
189
|
+
response3.data.after.monthly,
|
|
190
|
+
expectedMonthly,
|
|
191
|
+
`Monthly should accumulate to ${expectedMonthly}`
|
|
192
|
+
);
|
|
193
|
+
assert.equal(
|
|
194
|
+
response3.data.after.daily,
|
|
195
|
+
expectedDaily,
|
|
196
|
+
`Daily should accumulate to ${expectedDaily}`
|
|
161
197
|
);
|
|
162
198
|
assert.equal(
|
|
163
199
|
response3.data.after.total,
|
|
@@ -180,9 +216,11 @@ module.exports = {
|
|
|
180
216
|
assert.equal(response.data.authenticated, false, 'Should report as unauthenticated');
|
|
181
217
|
assert.equal(response.data.key, state.unauthKey, 'Key should be unknown');
|
|
182
218
|
|
|
183
|
-
// Verify
|
|
184
|
-
|
|
185
|
-
assert.equal(response.data.after.
|
|
219
|
+
// Verify all counters incremented
|
|
220
|
+
assert.equal(response.data.after.monthly, response.data.before.monthly + 1, 'Monthly should increment by 1');
|
|
221
|
+
assert.equal(response.data.after.daily, response.data.before.daily + 1, 'Daily should increment by 1');
|
|
222
|
+
|
|
223
|
+
state.unauthMonthly = response.data.after.monthly;
|
|
186
224
|
},
|
|
187
225
|
},
|
|
188
226
|
|
|
@@ -194,22 +232,66 @@ module.exports = {
|
|
|
194
232
|
|
|
195
233
|
assert.ok(usageDoc, 'Usage doc should exist in usage collection');
|
|
196
234
|
assert.ok(usageDoc?.requests, 'Usage doc should have the requests metric');
|
|
197
|
-
assert.equal(usageDoc.requests.
|
|
235
|
+
assert.equal(usageDoc.requests.monthly, state.unauthMonthly, 'Persisted monthly should match');
|
|
198
236
|
},
|
|
199
237
|
},
|
|
200
238
|
|
|
201
|
-
// Test 9:
|
|
239
|
+
// Test 9: Cron resets daily counters for authenticated users
|
|
202
240
|
{
|
|
203
|
-
name: '
|
|
204
|
-
async run({ assert, firestore, state, waitFor, pubsub }) {
|
|
205
|
-
// Verify
|
|
206
|
-
const
|
|
207
|
-
assert.ok(
|
|
241
|
+
name: 'cron-resets-daily-counters',
|
|
242
|
+
async run({ assert, firestore, state, accounts, waitFor, pubsub }) {
|
|
243
|
+
// Verify daily counter is > 0 before cron
|
|
244
|
+
const beforeDoc = await firestore.get(`users/${accounts.basic.uid}`);
|
|
245
|
+
assert.ok(beforeDoc?.usage?.requests?.daily > 0, 'Daily counter should be > 0 before cron');
|
|
246
|
+
|
|
247
|
+
// Store monthly and total before cron (should NOT be reset by daily cron)
|
|
248
|
+
state.monthlyBeforeCron = beforeDoc.usage.requests.monthly;
|
|
249
|
+
state.totalBeforeCron = beforeDoc.usage.requests.total;
|
|
208
250
|
|
|
209
251
|
// Trigger cron via PubSub
|
|
210
252
|
await pubsub.trigger('bm_cronDaily');
|
|
211
253
|
|
|
212
|
-
// Wait for cron to
|
|
254
|
+
// Wait for cron to reset daily counter
|
|
255
|
+
try {
|
|
256
|
+
await waitFor(
|
|
257
|
+
async () => {
|
|
258
|
+
const doc = await firestore.get(`users/${accounts.basic.uid}`);
|
|
259
|
+
return doc?.usage?.requests?.daily === 0;
|
|
260
|
+
},
|
|
261
|
+
10000,
|
|
262
|
+
500
|
|
263
|
+
);
|
|
264
|
+
assert.ok(true, 'Daily counter was reset to 0 by cron');
|
|
265
|
+
} catch (error) {
|
|
266
|
+
assert.fail('Daily counter should be reset to 0 within 10s');
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
},
|
|
270
|
+
|
|
271
|
+
// Test 10: Cron preserves monthly and total counters (non-1st of month)
|
|
272
|
+
{
|
|
273
|
+
name: 'cron-preserves-monthly-and-total',
|
|
274
|
+
async run({ assert, firestore, state, accounts }) {
|
|
275
|
+
const afterDoc = await firestore.get(`users/${accounts.basic.uid}`);
|
|
276
|
+
|
|
277
|
+
assert.equal(
|
|
278
|
+
afterDoc.usage.requests.monthly,
|
|
279
|
+
state.monthlyBeforeCron,
|
|
280
|
+
'Monthly counter should be preserved after daily cron'
|
|
281
|
+
);
|
|
282
|
+
assert.equal(
|
|
283
|
+
afterDoc.usage.requests.total,
|
|
284
|
+
state.totalBeforeCron,
|
|
285
|
+
'Total counter should be preserved after daily cron'
|
|
286
|
+
);
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
// Test 11: Cron deletes unauthenticated usage collection
|
|
291
|
+
{
|
|
292
|
+
name: 'cron-deletes-unauthenticated-usage',
|
|
293
|
+
async run({ assert, firestore, state, waitFor }) {
|
|
294
|
+
// The cron was already triggered in test 9, so the usage collection should be deleted
|
|
213
295
|
try {
|
|
214
296
|
await waitFor(
|
|
215
297
|
async () => {
|
|
@@ -225,5 +307,27 @@ module.exports = {
|
|
|
225
307
|
}
|
|
226
308
|
},
|
|
227
309
|
},
|
|
310
|
+
|
|
311
|
+
// Test 12: Daily counter accumulates after cron reset
|
|
312
|
+
{
|
|
313
|
+
name: 'daily-counter-accumulates-after-reset',
|
|
314
|
+
async run({ http, assert }) {
|
|
315
|
+
// After cron reset daily to 0, new increments should start from 0
|
|
316
|
+
const response = await http.as('basic').post('test/usage', {
|
|
317
|
+
amount: 3,
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
assert.isSuccess(response, 'Increment after cron reset should succeed');
|
|
321
|
+
assert.equal(response.data.before.daily, 0, 'Daily should be 0 after cron reset');
|
|
322
|
+
assert.equal(response.data.after.daily, 3, 'Daily should be 3 after increment');
|
|
323
|
+
|
|
324
|
+
// Monthly should have continued accumulating (not reset)
|
|
325
|
+
assert.equal(
|
|
326
|
+
response.data.after.monthly,
|
|
327
|
+
response.data.before.monthly + 3,
|
|
328
|
+
'Monthly should continue accumulating'
|
|
329
|
+
);
|
|
330
|
+
},
|
|
331
|
+
},
|
|
228
332
|
],
|
|
229
333
|
};
|