backend-manager 5.0.89 → 5.0.92
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 +2 -2
- package/CLAUDE.md +147 -8
- package/README.md +6 -6
- package/TODO-MARKETING.md +3 -0
- package/TODO-PAYMENT-v2.md +71 -0
- package/TODO.md +7 -0
- package/package.json +7 -5
- package/src/cli/commands/{emulators.js → emulator.js} +15 -15
- package/src/cli/commands/index.js +1 -1
- package/src/cli/commands/setup-tests/{emulators-config.js → emulator-config.js} +4 -4
- package/src/cli/commands/setup-tests/index.js +2 -2
- package/src/cli/commands/setup-tests/project-id-consistency.js +1 -1
- package/src/cli/commands/test.js +16 -16
- package/src/cli/index.js +15 -4
- package/src/manager/events/auth/on-create.js +5 -158
- package/src/manager/events/firestore/payments-webhooks/analytics.js +171 -0
- package/src/manager/events/firestore/payments-webhooks/on-write.js +95 -297
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-completed.js +19 -10
- package/src/manager/events/firestore/payments-webhooks/transitions/one-time/purchase-failed.js +4 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/send-email.js +61 -0
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/cancellation-requested.js +22 -9
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/new-subscription.js +21 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-failed.js +18 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/payment-recovered.js +18 -7
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/plan-changed.js +26 -8
- package/src/manager/events/firestore/payments-webhooks/transitions/subscription/subscription-cancelled.js +24 -9
- package/src/manager/functions/core/actions/api/admin/send-email.js +0 -131
- package/src/manager/functions/core/actions/api/general/add-marketing-contact.js +2 -137
- package/src/manager/functions/core/actions/api/user/sign-up.js +1 -1
- package/src/manager/helpers/user.js +1 -0
- package/src/manager/index.js +12 -0
- package/src/manager/libraries/email.js +483 -0
- package/src/manager/libraries/infer-contact.js +140 -0
- package/src/manager/libraries/payment-processors/resolve-price-id.js +19 -0
- package/src/manager/libraries/payment-processors/stripe.js +87 -48
- package/src/manager/libraries/payment-processors/test.js +4 -4
- package/src/manager/libraries/prompts/infer-contact.md +43 -0
- package/src/manager/routes/admin/backup/post.js +4 -3
- package/src/manager/routes/admin/email/post.js +11 -428
- package/src/manager/routes/admin/hook/post.js +3 -2
- package/src/manager/routes/admin/notification/post.js +14 -12
- package/src/manager/routes/admin/post/post.js +5 -6
- package/src/manager/routes/admin/post/put.js +3 -2
- package/src/manager/routes/admin/stats/get.js +19 -10
- package/src/manager/routes/general/email/post.js +8 -21
- package/src/manager/routes/marketing/contact/post.js +2 -100
- package/src/manager/routes/payments/intent/post.js +44 -2
- package/src/manager/routes/payments/intent/processors/stripe.js +10 -45
- package/src/manager/routes/payments/intent/processors/test.js +20 -25
- package/src/manager/routes/user/oauth2/_helpers.js +3 -2
- package/src/manager/routes/user/oauth2/delete.js +3 -3
- package/src/manager/routes/user/oauth2/get.js +2 -2
- package/src/manager/routes/user/oauth2/post.js +9 -9
- package/src/manager/routes/user/sessions/delete.js +4 -3
- package/src/manager/routes/user/signup/post.js +254 -54
- package/src/manager/schemas/admin/email/post.js +10 -5
- package/src/test/run-tests.js +1 -1
- package/src/test/runner.js +11 -0
- package/src/test/test-accounts.js +18 -0
- package/templates/backend-manager-config.json +31 -12
- package/test/events/payments/journey-payments-one-time-failure.js +105 -0
- package/test/events/payments/journey-payments-one-time.js +128 -0
- package/test/events/payments/journey-payments-plan-change.js +126 -0
- package/test/events/payments/journey-payments-upgrade.js +2 -2
- package/test/functions/admin/send-email.js +1 -88
- package/test/helpers/email.js +381 -0
- package/test/helpers/infer-contact.js +299 -0
- package/test/routes/admin/email.js +41 -90
- package/REFACTOR-BEM-API.md +0 -76
- package/REFACTOR-MIDDLEWARE.md +0 -62
- package/REFACTOR-PAYMENT.md +0 -66
- /package/bin/{bem → backend-manager} +0 -0
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Payment Journey - One-Time Purchase
|
|
3
|
+
* Simulates: user → test intent (one-time product) → auto-webhook → purchase-completed
|
|
4
|
+
*
|
|
5
|
+
* Uses the test processor to exercise the full intent→webhook→trigger pipeline
|
|
6
|
+
* for one-time payments. Unlike subscriptions, one-time payments only write to
|
|
7
|
+
* payments-orders/{orderId} — they do NOT modify users/{uid}.subscription.
|
|
8
|
+
*
|
|
9
|
+
* Requires at least one product with type: 'one-time' in config.payment.products
|
|
10
|
+
*/
|
|
11
|
+
module.exports = {
|
|
12
|
+
description: 'Payment journey: one-time purchase via test intent → purchase-completed',
|
|
13
|
+
type: 'suite',
|
|
14
|
+
timeout: 30000,
|
|
15
|
+
|
|
16
|
+
tests: [
|
|
17
|
+
{
|
|
18
|
+
name: 'resolve-one-time-product',
|
|
19
|
+
async run({ accounts, firestore, assert, state, config }) {
|
|
20
|
+
const uid = accounts['journey-payments-one-time'].uid;
|
|
21
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
22
|
+
|
|
23
|
+
assert.ok(userDoc, 'User doc should exist');
|
|
24
|
+
|
|
25
|
+
// Resolve first one-time product from config
|
|
26
|
+
const oneTimeProduct = config.payment.products.find(p => p.type === 'one-time' && p.prices?.once);
|
|
27
|
+
assert.ok(oneTimeProduct, 'Config should have at least one one-time product');
|
|
28
|
+
|
|
29
|
+
state.uid = uid;
|
|
30
|
+
state.productId = oneTimeProduct.id;
|
|
31
|
+
state.productName = oneTimeProduct.name;
|
|
32
|
+
state.price = oneTimeProduct.prices.once.amount;
|
|
33
|
+
|
|
34
|
+
// Snapshot subscription before purchase — should remain unchanged after
|
|
35
|
+
state.subscriptionBefore = userDoc.subscription || null;
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
{
|
|
40
|
+
name: 'create-one-time-intent',
|
|
41
|
+
async run({ http, assert, state }) {
|
|
42
|
+
const response = await http.as('journey-payments-one-time').post('payments/intent', {
|
|
43
|
+
processor: 'test',
|
|
44
|
+
productId: state.productId,
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
assert.isSuccess(response, 'Intent should succeed');
|
|
48
|
+
assert.ok(response.data.id, 'Should return intent ID');
|
|
49
|
+
assert.ok(response.data.orderId, 'Should return orderId');
|
|
50
|
+
assert.match(response.data.orderId, /^\d{4}-\d{4}-\d{4}$/, 'orderId should be XXXX-XXXX-XXXX format');
|
|
51
|
+
assert.ok(response.data.url, 'Should return URL');
|
|
52
|
+
|
|
53
|
+
state.intentId = response.data.id;
|
|
54
|
+
state.orderId = response.data.orderId;
|
|
55
|
+
|
|
56
|
+
// Derive webhook event ID from intent ID (same timestamp)
|
|
57
|
+
state.eventId = response.data.id.replace('_test-cs-', '_test-evt-');
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
{
|
|
62
|
+
name: 'webhook-transition-purchase-completed',
|
|
63
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
64
|
+
// Poll until the webhook is processed
|
|
65
|
+
await waitFor(async () => {
|
|
66
|
+
const doc = await firestore.get(`payments-webhooks/${state.eventId}`);
|
|
67
|
+
return doc?.status === 'completed';
|
|
68
|
+
}, 15000, 500);
|
|
69
|
+
|
|
70
|
+
const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
|
|
71
|
+
assert.ok(webhookDoc, 'Webhook doc should exist');
|
|
72
|
+
assert.equal(webhookDoc.transition, 'purchase-completed', 'Transition should be purchase-completed');
|
|
73
|
+
assert.equal(webhookDoc.orderId, state.orderId, 'Webhook doc orderId should match intent');
|
|
74
|
+
assert.equal(webhookDoc.event?.category, 'one-time', 'Category should be one-time');
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
{
|
|
79
|
+
name: 'order-doc-created',
|
|
80
|
+
async run({ firestore, assert, state }) {
|
|
81
|
+
const orderDoc = await firestore.get(`payments-orders/${state.orderId}`);
|
|
82
|
+
|
|
83
|
+
assert.ok(orderDoc, 'Order doc should exist');
|
|
84
|
+
assert.equal(orderDoc.id, state.orderId, 'ID should match orderId');
|
|
85
|
+
assert.equal(orderDoc.type, 'one-time', 'Type should be one-time');
|
|
86
|
+
assert.equal(orderDoc.owner, state.uid, 'Owner should match');
|
|
87
|
+
assert.equal(orderDoc.processor, 'test', 'Processor should be test');
|
|
88
|
+
assert.equal(orderDoc.unified.product.id, state.productId, `Product should be ${state.productId}`);
|
|
89
|
+
assert.equal(orderDoc.unified.payment.processor, 'test', 'Unified processor should be test');
|
|
90
|
+
assert.equal(orderDoc.unified.payment.orderId, state.orderId, 'Unified orderId should match');
|
|
91
|
+
},
|
|
92
|
+
},
|
|
93
|
+
|
|
94
|
+
{
|
|
95
|
+
name: 'subscription-unchanged',
|
|
96
|
+
async run({ firestore, assert, state }) {
|
|
97
|
+
// One-time payments must NOT modify users/{uid}.subscription
|
|
98
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
99
|
+
const subAfter = userDoc.subscription || null;
|
|
100
|
+
|
|
101
|
+
assert.deepEqual(
|
|
102
|
+
subAfter?.product?.id,
|
|
103
|
+
state.subscriptionBefore?.product?.id,
|
|
104
|
+
'Subscription product should be unchanged after one-time purchase',
|
|
105
|
+
);
|
|
106
|
+
assert.deepEqual(
|
|
107
|
+
subAfter?.status,
|
|
108
|
+
state.subscriptionBefore?.status,
|
|
109
|
+
'Subscription status should be unchanged after one-time purchase',
|
|
110
|
+
);
|
|
111
|
+
},
|
|
112
|
+
},
|
|
113
|
+
|
|
114
|
+
{
|
|
115
|
+
name: 'intent-doc-created',
|
|
116
|
+
async run({ firestore, assert, state }) {
|
|
117
|
+
const intentDoc = await firestore.get(`payments-intents/${state.orderId}`);
|
|
118
|
+
|
|
119
|
+
assert.ok(intentDoc, 'Intent doc should exist');
|
|
120
|
+
assert.equal(intentDoc.id, state.orderId, 'ID should match orderId');
|
|
121
|
+
assert.equal(intentDoc.intentId, state.intentId, 'Intent ID should match processor session ID');
|
|
122
|
+
assert.equal(intentDoc.owner, state.uid, 'Owner should match');
|
|
123
|
+
assert.equal(intentDoc.processor, 'test', 'Processor should be test');
|
|
124
|
+
assert.equal(intentDoc.productId, state.productId, `Product should be ${state.productId}`);
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test: Payment Journey - Plan Change
|
|
3
|
+
* Simulates: basic → paid product A → plan-changed webhook → paid product B
|
|
4
|
+
*
|
|
5
|
+
* Uses test intent for initial subscription, then manual webhook to change plans.
|
|
6
|
+
* Requires at least two paid subscription products in config.
|
|
7
|
+
*/
|
|
8
|
+
module.exports = {
|
|
9
|
+
description: 'Payment journey: paid product A → plan-changed → paid product B',
|
|
10
|
+
type: 'suite',
|
|
11
|
+
timeout: 30000,
|
|
12
|
+
|
|
13
|
+
tests: [
|
|
14
|
+
{
|
|
15
|
+
name: 'setup-paid-subscription',
|
|
16
|
+
async run({ accounts, firestore, assert, state, config, http, waitFor }) {
|
|
17
|
+
const uid = accounts['journey-payments-plan-change'].uid;
|
|
18
|
+
|
|
19
|
+
// Resolve two distinct paid subscription products from config
|
|
20
|
+
const paidProducts = config.payment.products.filter(p => p.id !== 'basic' && p.type === 'subscription' && p.prices);
|
|
21
|
+
assert.ok(paidProducts.length >= 2, 'Config should have at least two paid subscription products');
|
|
22
|
+
|
|
23
|
+
const productA = paidProducts[0];
|
|
24
|
+
const productB = paidProducts[1];
|
|
25
|
+
|
|
26
|
+
state.uid = uid;
|
|
27
|
+
state.productA = { id: productA.id, name: productA.name, priceId: productA.prices.monthly.stripe };
|
|
28
|
+
state.productB = { id: productB.id, name: productB.name, priceId: productB.prices.monthly.stripe };
|
|
29
|
+
|
|
30
|
+
// Create subscription via test intent (product A)
|
|
31
|
+
const response = await http.as('journey-payments-plan-change').post('payments/intent', {
|
|
32
|
+
processor: 'test',
|
|
33
|
+
productId: productA.id,
|
|
34
|
+
frequency: 'monthly',
|
|
35
|
+
});
|
|
36
|
+
assert.isSuccess(response, 'Intent should succeed');
|
|
37
|
+
state.orderId = response.data.orderId;
|
|
38
|
+
|
|
39
|
+
// Wait for subscription to activate
|
|
40
|
+
await waitFor(async () => {
|
|
41
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
42
|
+
return userDoc?.subscription?.product?.id === productA.id;
|
|
43
|
+
}, 15000, 500);
|
|
44
|
+
|
|
45
|
+
const userDoc = await firestore.get(`users/${uid}`);
|
|
46
|
+
assert.equal(userDoc.subscription?.product?.id, productA.id, `Should start as ${productA.id}`);
|
|
47
|
+
assert.equal(userDoc.subscription?.status, 'active', 'Should be active');
|
|
48
|
+
|
|
49
|
+
state.subscriptionId = userDoc.subscription.payment.resourceId;
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
|
|
53
|
+
{
|
|
54
|
+
name: 'send-plan-change-webhook',
|
|
55
|
+
async run({ http, assert, state, config }) {
|
|
56
|
+
const futureDate = new Date();
|
|
57
|
+
futureDate.setMonth(futureDate.getMonth() + 1);
|
|
58
|
+
|
|
59
|
+
state.eventId = `_test-evt-journey-plan-change-${Date.now()}`;
|
|
60
|
+
|
|
61
|
+
// Send subscription.updated with a different product's price ID
|
|
62
|
+
const response = await http.as('none').post(`payments/webhook?processor=test&key=${config.backendManagerKey}`, {
|
|
63
|
+
id: state.eventId,
|
|
64
|
+
type: 'customer.subscription.updated',
|
|
65
|
+
data: {
|
|
66
|
+
object: {
|
|
67
|
+
id: state.subscriptionId,
|
|
68
|
+
object: 'subscription',
|
|
69
|
+
status: 'active',
|
|
70
|
+
metadata: { uid: state.uid, orderId: state.orderId },
|
|
71
|
+
cancel_at_period_end: false,
|
|
72
|
+
canceled_at: null,
|
|
73
|
+
current_period_end: Math.floor(futureDate.getTime() / 1000),
|
|
74
|
+
current_period_start: Math.floor(Date.now() / 1000),
|
|
75
|
+
start_date: Math.floor(Date.now() / 1000) - 86400 * 30,
|
|
76
|
+
trial_start: null,
|
|
77
|
+
trial_end: null,
|
|
78
|
+
plan: { id: state.productB.priceId, interval: 'month' },
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
assert.isSuccess(response, 'Webhook should be accepted');
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
{
|
|
88
|
+
name: 'plan-changed-transition-detected',
|
|
89
|
+
async run({ firestore, assert, state, waitFor }) {
|
|
90
|
+
await waitFor(async () => {
|
|
91
|
+
const doc = await firestore.get(`payments-webhooks/${state.eventId}`);
|
|
92
|
+
return doc?.status === 'completed';
|
|
93
|
+
}, 15000, 500);
|
|
94
|
+
|
|
95
|
+
const webhookDoc = await firestore.get(`payments-webhooks/${state.eventId}`);
|
|
96
|
+
assert.ok(webhookDoc, 'Webhook doc should exist');
|
|
97
|
+
assert.equal(webhookDoc.transition, 'plan-changed', 'Transition should be plan-changed');
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
name: 'subscription-updated-to-product-b',
|
|
103
|
+
async run({ firestore, assert, state }) {
|
|
104
|
+
const userDoc = await firestore.get(`users/${state.uid}`);
|
|
105
|
+
|
|
106
|
+
assert.equal(userDoc.subscription.product.id, state.productB.id, `Product should be ${state.productB.id}`);
|
|
107
|
+
assert.equal(userDoc.subscription.product.name, state.productB.name, `Product name should be ${state.productB.name}`);
|
|
108
|
+
assert.equal(userDoc.subscription.status, 'active', 'Status should still be active');
|
|
109
|
+
assert.equal(userDoc.subscription.payment.processor, 'test', 'Processor should be test');
|
|
110
|
+
assert.equal(userDoc.subscription.payment.frequency, 'monthly', 'Frequency should be monthly');
|
|
111
|
+
assert.equal(userDoc.subscription.payment.resourceId, state.subscriptionId, 'Resource ID should be the same subscription');
|
|
112
|
+
},
|
|
113
|
+
},
|
|
114
|
+
|
|
115
|
+
{
|
|
116
|
+
name: 'order-doc-updated',
|
|
117
|
+
async run({ firestore, assert, state }) {
|
|
118
|
+
const orderDoc = await firestore.get(`payments-orders/${state.orderId}`);
|
|
119
|
+
|
|
120
|
+
assert.ok(orderDoc, 'Order doc should exist');
|
|
121
|
+
assert.equal(orderDoc.unified.product.id, state.productB.id, `Order product should be ${state.productB.id}`);
|
|
122
|
+
assert.equal(orderDoc.unified.status, 'active', 'Order status should be active');
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
};
|
|
@@ -88,8 +88,8 @@ module.exports = {
|
|
|
88
88
|
assert.equal(orderDoc.owner, state.uid, 'Owner should match');
|
|
89
89
|
assert.equal(orderDoc.processor, 'test', 'Processor should be test');
|
|
90
90
|
assert.equal(orderDoc.resourceId, state.subscriptionId, 'Resource ID should match');
|
|
91
|
-
assert.equal(orderDoc.
|
|
92
|
-
assert.equal(orderDoc.
|
|
91
|
+
assert.equal(orderDoc.unified.product.id, state.paidProductId, `Product should be ${state.paidProductId}`);
|
|
92
|
+
assert.equal(orderDoc.unified.status, 'active', 'Status should be active');
|
|
93
93
|
},
|
|
94
94
|
},
|
|
95
95
|
|
|
@@ -8,7 +8,6 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Possible status values:
|
|
10
10
|
* - 'sent': Email sent via SendGrid
|
|
11
|
-
* - 'non-unique': Duplicate email detected (when ensureUnique: true)
|
|
12
11
|
* - 'queued': Scheduled beyond 71 hours, saved to queue for later
|
|
13
12
|
*/
|
|
14
13
|
module.exports = {
|
|
@@ -26,7 +25,6 @@ module.exports = {
|
|
|
26
25
|
const response = await http.command('admin:send-email', {
|
|
27
26
|
to: [{ email: `_test-receiver@${config.domain}` }],
|
|
28
27
|
copy: false,
|
|
29
|
-
ensureUnique: false,
|
|
30
28
|
});
|
|
31
29
|
|
|
32
30
|
assert.isError(response, 400, 'Missing subject should return 400');
|
|
@@ -44,7 +42,6 @@ module.exports = {
|
|
|
44
42
|
subject: 'BEM Test Email - Status Sent',
|
|
45
43
|
to: [{ email: `_test-receiver@${config.domain}`, name: 'Test Receiver' }],
|
|
46
44
|
copy: false,
|
|
47
|
-
ensureUnique: false,
|
|
48
45
|
data: {
|
|
49
46
|
email: {
|
|
50
47
|
subject: 'BEM Test Email - Status Sent',
|
|
@@ -55,7 +52,7 @@ module.exports = {
|
|
|
55
52
|
|
|
56
53
|
assert.isSuccess(response, 'Admin should be able to send email');
|
|
57
54
|
assert.hasProperty(response, 'data.status', 'Response should have status');
|
|
58
|
-
assert.equal(response.data.status, 'sent', 'Status should be sent
|
|
55
|
+
assert.equal(response.data.status, 'sent', 'Status should be sent');
|
|
59
56
|
},
|
|
60
57
|
},
|
|
61
58
|
|
|
@@ -73,7 +70,6 @@ module.exports = {
|
|
|
73
70
|
subject: 'BEM Test Email - Status Queued',
|
|
74
71
|
to: [{ email: `_test-receiver@${config.domain}`, name: 'Test Receiver' }],
|
|
75
72
|
copy: false,
|
|
76
|
-
ensureUnique: false,
|
|
77
73
|
sendAt: sendAt,
|
|
78
74
|
data: {
|
|
79
75
|
email: {
|
|
@@ -89,89 +85,6 @@ module.exports = {
|
|
|
89
85
|
},
|
|
90
86
|
},
|
|
91
87
|
|
|
92
|
-
// Test 6: Status 'non-unique' - Duplicate email detected
|
|
93
|
-
// This test sends two identical emails with ensureUnique: true
|
|
94
|
-
// The second one should return 'non-unique' status
|
|
95
|
-
{
|
|
96
|
-
name: 'status-non-unique',
|
|
97
|
-
auth: 'admin',
|
|
98
|
-
timeout: 120000, // Long timeout because ensureUnique waits ~45 seconds
|
|
99
|
-
|
|
100
|
-
async run({ http, assert, config }) {
|
|
101
|
-
const uniqueSubject = `BEM Test Email - Unique Check ${Date.now()}`;
|
|
102
|
-
|
|
103
|
-
// Send first email with ensureUnique: true
|
|
104
|
-
const response1Promise = http.command('admin:send-email', {
|
|
105
|
-
subject: uniqueSubject,
|
|
106
|
-
to: [{ email: `_test-receiver@${config.domain}` }],
|
|
107
|
-
copy: false,
|
|
108
|
-
ensureUnique: true,
|
|
109
|
-
categories: ['bem-test-unique'],
|
|
110
|
-
data: {
|
|
111
|
-
email: {
|
|
112
|
-
subject: uniqueSubject,
|
|
113
|
-
body: 'Testing ensureUnique feature.',
|
|
114
|
-
},
|
|
115
|
-
},
|
|
116
|
-
});
|
|
117
|
-
|
|
118
|
-
// Send second identical email immediately (same subject, to, categories = same hash)
|
|
119
|
-
const response2Promise = http.command('admin:send-email', {
|
|
120
|
-
subject: uniqueSubject,
|
|
121
|
-
to: [{ email: `_test-receiver@${config.domain}` }],
|
|
122
|
-
copy: false,
|
|
123
|
-
ensureUnique: true,
|
|
124
|
-
categories: ['bem-test-unique'],
|
|
125
|
-
data: {
|
|
126
|
-
email: {
|
|
127
|
-
subject: uniqueSubject,
|
|
128
|
-
body: 'Testing ensureUnique feature.',
|
|
129
|
-
},
|
|
130
|
-
},
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
// Wait for both
|
|
134
|
-
const [response1, response2] = await Promise.all([response1Promise, response2Promise]);
|
|
135
|
-
|
|
136
|
-
// Both should succeed
|
|
137
|
-
assert.isSuccess(response1, 'First email should succeed');
|
|
138
|
-
assert.isSuccess(response2, 'Second email should succeed');
|
|
139
|
-
|
|
140
|
-
// One should be 'sent', the other 'non-unique'
|
|
141
|
-
const statuses = [response1.data.status, response2.data.status].sort();
|
|
142
|
-
assert.equal(statuses[0], 'non-unique', 'One email should have status non-unique');
|
|
143
|
-
assert.equal(statuses[1], 'sent', 'One email should have status sent');
|
|
144
|
-
},
|
|
145
|
-
},
|
|
146
|
-
|
|
147
|
-
// Test 7: Unauthorized sender domain rejected by SendGrid
|
|
148
|
-
// TODO: SendGrid accepts emails from unauthorized domains at API level
|
|
149
|
-
// (they may fail at delivery). Consider adding BEM-level validation.
|
|
150
|
-
// {
|
|
151
|
-
// name: 'unauthorized-from-domain-rejected',
|
|
152
|
-
// auth: 'admin',
|
|
153
|
-
// timeout: 30000,
|
|
154
|
-
//
|
|
155
|
-
// async run({ http, assert, config }) {
|
|
156
|
-
// const response = await http.command('admin:send-email', {
|
|
157
|
-
// subject: 'BEM Test Email - Unauthorized Sender',
|
|
158
|
-
// to: [{ email: `_test-receiver@${config.domain}` }],
|
|
159
|
-
// from: { email: 'fake-sender@example.com', name: 'Fake Sender' },
|
|
160
|
-
// copy: false,
|
|
161
|
-
// ensureUnique: false,
|
|
162
|
-
// data: {
|
|
163
|
-
// email: {
|
|
164
|
-
// subject: 'BEM Test Email - Unauthorized Sender',
|
|
165
|
-
// body: 'This email should fail because the sender domain is not authorized.',
|
|
166
|
-
// },
|
|
167
|
-
// },
|
|
168
|
-
// });
|
|
169
|
-
//
|
|
170
|
-
// // SendGrid rejects emails from unauthorized sender domains with 403
|
|
171
|
-
// assert.isError(response, 500, 'Sending from unauthorized domain should fail');
|
|
172
|
-
// },
|
|
173
|
-
// },
|
|
174
|
-
|
|
175
88
|
// --- Auth rejection tests (at end per convention) ---
|
|
176
89
|
{
|
|
177
90
|
name: 'unauthenticated-rejected',
|