Skip to content

Conversation

9larsons
Copy link
Contributor

ref https://linear.app/ghost/issue/ONC-939/
ref #23302 (initial spike)

Previously, we were using Stripe's metadata field to pass the newsletter subscription selection. Stripe has a limit of 500 characters for each key's value, so it was plausible to hit this limit with a dozen or so newsletters. This failed silently by not respecting members' selections when signing up.

With free member creation flows, we set the newsletter data as part of the magic token we generate and send to require email validation. As we're already generating a magic link for the success url (redirect) following the Stripe checkout completion, we can simply parse this for the token and access the newsletter data without having to pass it to Stripe.

In other words, we effectively use the tokens table as a 'pending member'. And by changing our handling here, we align on a single approach for how we handle sign up data.

As a note, the token field allows up to 2000 characters, so even after the other data we pack in there and base64url encoding, we have ample headroom over the previous Stripe metadata handling, as our other fields are reasonably small.


Coauthored by @cmraible

ref https://linear.app/ghost/issue/ONC-939/
ref #23302 (initial spike)

Previously, we were using Stripe's metadata field to pass the newsletter subscription selection. Stripe has a limit of 500 characters for each key's value, so it was plausible to hit this limit with a dozen or so newsletters. This failed silently by not respecting members' selections when signing up.

With free member creation flows, we set the newsletter data as part of the magic token we generate and send to require email validation. As we're already generating a magic link for the success url (redirect) following the Stripe checkout completion, we can simply parse this for the token and access the newsletter data without having to pass it to Stripe.

In other words, we effectively use the tokens table as a 'pending member'. And by changing our handling here, we align on a single approach for how we handle sign up data.

As a note, the token field allows up to 2000 characters, so even after the other data we pack in there and base64url encoding, we have ample headroom over the previous Stripe metadata handling, as our other fields are reasonably small.
@9larsons 9larsons requested a review from cmraible September 30, 2025 22:02
Copy link
Contributor

coderabbitai bot commented Sep 30, 2025

Walkthrough

RouterController moves newsletters out of Stripe metadata into signup tokenData/options, validates metadata.newsletters into validatedNewsletters, removes metadata.newsletters, and includes newsletters in magic link tokenData when creating signup links. _createSubscriptionCheckoutSession JSDoc now documents optional options.newsletters. StripeService exposes getTokenDataFromMagicLinkToken(token) on its CheckoutSessionEventService config delegating to membersService.api. CheckoutSessionEventService accepts getTokenDataFromMagicLinkToken, parses a token from success_url for sessions that create a new member, retrieves tokenData, and uses token-derived newsletters and attribution for new member creation instead of subscription metadata. Unit and e2e tests updated to reflect token-based newsletter/attribution handling; one webhook e2e test for signup-newsletter via metadata was removed.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Pre-merge checks and finishing touches

✅ Passed checks (3 passed)
Check name Status Explanation
Title Check ✅ Passed The title clearly describes the primary update—changing how newsletter subscriptions are handled for new paid members—and is concise, focused, and directly related to the key change in the pull request.
Description Check ✅ Passed The description provides relevant context and rationale for the changeset, explaining why and how newsletter data is moved from Stripe metadata to magic-link tokens and references the related issue and initial spike.
Docstring Coverage ✅ Passed No functions found in the changes. Docstring coverage check skipped.
✨ Finishing touches
  • 📝 Generate Docstrings
🧪 Generate unit tests
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch patch-stripe-newsletter

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions github-actions bot added the community [triage] Community features and bugs label Sep 30, 2025
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
ghost/core/test/e2e-api/members/create-stripe-checkout-session.test.js (1)

531-696: Consider expanding test coverage for newsletter handling.

The current tests only verify that empty newsletter arrays aren't sent to Stripe metadata. Consider adding tests for:

  1. Non-empty newsletter arrays - Verify actual newsletter IDs are handled correctly
  2. Large newsletter selections - Test the expanded capacity (PR mentions moving from 500 to 2000 character limit)
  3. Token verification - Confirm newsletters are actually present in the magic-link token
  4. Invalid data handling - Malformed JSON, invalid newsletter IDs, etc.

This would provide comprehensive coverage of the new newsletter handling flow described in the PR objectives.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 009e92b and c7f4bc0.

📒 Files selected for processing (2)
  • ghost/core/test/e2e-api/members/create-stripe-checkout-session.test.js (1 hunks)
  • ghost/core/test/e2e-api/members/webhooks.test.js (0 hunks)
💤 Files with no reviewable changes (1)
  • ghost/core/test/e2e-api/members/webhooks.test.js
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
  • GitHub Check: Acceptance tests (Node 22.13.1, mysql8)
  • GitHub Check: Acceptance tests (Node 22.13.1, sqlite3)
  • GitHub Check: Legacy tests (Node 22.13.1, mysql8)
  • GitHub Check: Legacy tests (Node 22.13.1, sqlite3)
  • GitHub Check: Unit tests (Node 22.13.1)
  • GitHub Check: Ghost-CLI tests
  • GitHub Check: Lint
  • GitHub Check: Build & Push
🔇 Additional comments (1)
ghost/core/test/e2e-api/members/create-stripe-checkout-session.test.js (1)

528-531: LGTM! Clear test suite organization.

The test suite is well-organized and the comment clearly describes the purpose of the tests.

Copy link
Collaborator

@cmraible cmraible left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work! This whole area is such a pain to reason about, sorry it took so long to review.

A few concerns:

  • It looks like we sometimes allow checkout sessions without email addresses — I'm not sure why we do that, but if that's a valid case then this whole approach might not work. Trying to sort out why we'd accept this, but haven't gotten anywhere yet.
  • Some of the new tests seem duplicative and/or don't match the test's title.
  • I think maybe we're missing an e2e-api test for the case where newsletter are passed?

.persist()
.get(/v1\/.*/)
.reply((uri) => {
const [match, resource, id] = uri.match(/\/v1\/(\w+)\/(.+)\/?/) || [null];
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: What is this regex doing? I wonder if we could pull it into a helper to make it easier to understand what's happening here.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tried putting these mocks into a helper and it became an absolute mess to reason about.. although the current state is effectively the same. I'll play around and see if I can get it in better shape.

It's just trying to match any of the stripe v1 apis. e.g. /v1/products/prod_123 or /v1/prices/price_123

Comment on lines 284 to 447
it('Does pass url attribution source to session metadata', async function () {
const {body: {tiers}} = await adminAgent.get('/tiers/?include=monthly_price&yearly_price');

const paidTier = tiers.find(tier => tier.type === 'paid');

nock('https://api.stripe.com')
.persist()
.get(/v1\/.*/)
.reply((uri) => {
const [match, resource, id] = uri.match(/\/v1\/(\w+)\/(.+)\/?/) || [null];
if (match) {
if (resource === 'products') {
return [200, {
id: id,
active: true
}];
}
if (resource === 'prices') {
return [200, {
id: id,
active: true,
currency: 'usd',
unit_amount: 500,
recurring: {
interval: 'month'
}
}];
}
}

return [500];
});

const scope = nock('https://api.stripe.com')
.persist()
.post(/v1\/.*/)
.reply((uri, body) => {
if (uri === '/v1/checkout/sessions') {
const parsed = new URLSearchParams(body);
should(parsed.get('metadata[attribution_url]')).eql('/test');
should(parsed.get('metadata[attribution_type]')).eql('url');
should(parsed.get('metadata[attribution_id]')).be.null();

return [200, {id: 'cs_123', url: 'https://site.com'}];
}
if (uri === '/v1/prices') {
return [200, {
id: 'price_4',
active: true,
currency: 'usd',
unit_amount: 500,
recurring: {
interval: 'month'
}
}];
}

return [500];
});

await membersAgent.post('/api/create-stripe-checkout-session/')
.body({
customerEmail: '[email protected]',
tierId: paidTier.id,
cadence: 'month',
metadata: {
urlHistory: [
{
path: '/test',
time: Date.now()
}
]
}
})
.expectStatus(200)
.matchBodySnapshot()
.matchHeaderSnapshot();

should(scope.isDone()).eql(true);
});

it('Does pass post attribution source to session metadata', async function () {
const post = await getPost(fixtureManager.get('posts', 0).id);
const url = urlService.getUrlByResourceId(post.id, {absolute: false});

const {body: {tiers}} = await adminAgent.get('/tiers/?include=monthly_price&yearly_price');

const paidTier = tiers.find(tier => tier.type === 'paid');

nock('https://api.stripe.com')
.persist()
.get(/v1\/.*/)
.reply((uri) => {
const [match, resource, id] = uri.match(/\/v1\/(\w+)\/(.+)\/?/) || [null];
if (match) {
if (resource === 'products') {
return [200, {
id: id,
active: true
}];
}
if (resource === 'prices') {
return [200, {
id: id,
active: true,
currency: 'usd',
unit_amount: 50,
recurring: {
interval: 'month'
}
}];
}
}

return [500];
});

const scope = nock('https://api.stripe.com')
.persist()
.post(/v1\/.*/)
.reply((uri, body) => {
if (uri === '/v1/checkout/sessions') {
const parsed = new URLSearchParams(body);
should(parsed.get('metadata[attribution_url]')).eql(url);
should(parsed.get('metadata[attribution_type]')).eql('post');
should(parsed.get('metadata[attribution_id]')).eql(post.id);

return [200, {id: 'cs_123', url: 'https://site.com'}];
}
if (uri === '/v1/prices') {
return [200, {
id: 'price_5',
active: true,
currency: 'usd',
unit_amount: 500,
recurring: {
interval: 'month'
}
}];
}

return [500];
});

await membersAgent.post('/api/create-stripe-checkout-session/')
.body({
customerEmail: '[email protected]',
tierId: paidTier.id,
cadence: 'month',
metadata: {
urlHistory: [
{
path: url,
time: Date.now()
}
]
}
})
.expectStatus(200)
.matchBodySnapshot()
.matchHeaderSnapshot();

should(scope.isDone()).eql(true);
});
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think we should update the attribution while we're at it? Seems a bit weird to handle newsletters one way, and attribution data a different way.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated to do this. I didn't want to bloat this but the changes are certainly overlaid.

* Newsletter preference tests
*/
describe('Newsletter preferences', function () {
it('Should not pass newsletter data to Stripe metadata but include in success URL token', async function () {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
it('Should not pass newsletter data to Stripe metadata but include in success URL token', async function () {
it('Should not pass newsletter data to Stripe metadata', async function () {

In its current form, this only tests that we don't pass newsletters in the Stripe metadata. I think maybe we're missing a test for the success URL token part? Or maybe we test that elsewhere...

tierId: paidTier.id,
cadence: 'month',
metadata: {
newsletters: JSON.stringify([])
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we cover the empty newsletter array case below, I think we should add some newsletters here to be sure it works when there is data.

});

it('should create new member with newsletters if metadata newsletters is not valid JSON', async function () {
it('should create new member without newsletters when token has no newsletter data', async function () {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is good, but I think we're missing a test for the "happy path", where the token does have newsletter data.

assert.equal(member.newsletters.length, 1, 'The member should have a single newsletter');
});

it('Will create a member with signup newsletter preference', async function () {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this covered elsewhere? The implementation of this test will need to change, but I think we still want this test case.

.matchHeaderSnapshot();
});

it('Can create a checkout session without passing a customerEmail', async function () {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

potential issue: what happens in this scenario? I can't think of why we would allow a checkout session without an email address — but if there is a valid case for this, then presumably we don't create a magic link, and this whole approach might not be viable 🤔

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
ghost/core/test/e2e-api/members/create-stripe-checkout-session.test.js (1)

269-337: Verify token content, not just its presence.

The test verifies that newsletters/attribution are absent from Stripe metadata and that the success URL contains a token pattern, but it doesn't verify that the token actually contains the newsletter/attribution data.

Consider extracting and validating the token content:

             // Verify that success URL contains a token (for magic link with data)
             should(stripeSuccessUrl).match(/token=[A-Za-z0-9_-]+/);
+
+            // Extract and verify token content
+            const tokenMatch = stripeSuccessUrl.match(/token=([A-Za-z0-9_-]+)/);
+            should(tokenMatch).not.be.null();
+            const token = tokenMatch[1];
+            
+            // TODO: Add helper to decode/validate token contains newsletters and attribution
+            // This would require access to the token validation logic used by the members API

Alternatively, if token content validation is tested elsewhere (e.g., in webhook handler tests), update the test name to reflect what it actually tests:

-        it('Includes attribution and newsletters in token, not Stripe metadata', async function () {
+        it('Excludes attribution and newsletters from Stripe metadata', async function () {
🧹 Nitpick comments (1)
ghost/core/test/unit/server/services/stripe/services/webhooks/CheckoutSessionEventService.test.js (1)

637-650: Remove extra blank line; test logic is correct.

ESLint flagged more than one blank line at lines 637-638. Remove the extra line to comply with the style guide.

Otherwise, the test correctly verifies graceful error handling when token retrieval fails, ensuring newsletters defaults to null.

Apply this diff:

     });
-

-
+
     it('should handle token retrieval errors gracefully', async function () {
📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between c7f4bc0 and 7e827a1.

⛔ Files ignored due to path filters (1)
  • ghost/core/test/e2e-api/members/__snapshots__/create-stripe-checkout-session.test.js.snap is excluded by !**/*.snap
📒 Files selected for processing (4)
  • ghost/core/core/server/services/members/members-api/controllers/RouterController.js (4 hunks)
  • ghost/core/core/server/services/stripe/services/webhook/CheckoutSessionEventService.js (3 hunks)
  • ghost/core/test/e2e-api/members/create-stripe-checkout-session.test.js (6 hunks)
  • ghost/core/test/unit/server/services/stripe/services/webhooks/CheckoutSessionEventService.test.js (5 hunks)
🧰 Additional context used
🧬 Code graph analysis (3)
ghost/core/core/server/services/stripe/services/webhook/CheckoutSessionEventService.js (1)
ghost/core/core/server/services/members/members-api/controllers/RouterController.js (5)
  • attribution (213-213)
  • tokenData (379-389)
  • tokenData (778-784)
  • tokenData (806-806)
  • logging (2-2)
ghost/core/test/unit/server/services/stripe/services/webhooks/CheckoutSessionEventService.test.js (1)
ghost/core/core/server/services/stripe/services/webhook/CheckoutSessionEventService.js (8)
  • customer (166-168)
  • memberRepository (63-63)
  • memberRepository (99-99)
  • memberRepository (176-176)
  • memberData (216-216)
  • member (64-64)
  • member (100-102)
  • member (178-180)
ghost/core/core/server/services/members/members-api/controllers/RouterController.js (3)
ghost/core/core/server/services/stripe/services/webhook/CheckoutSessionEventService.js (1)
  • tokenData (198-198)
ghost/core/core/server/services/members/members-api/controllers/MemberController.js (2)
  • tokenData (60-60)
  • options (43-45)
ghost/core/core/server/services/members/middleware.js (3)
  • options (231-233)
  • options (281-284)
  • options (305-308)
🪛 ESLint
ghost/core/test/unit/server/services/stripe/services/webhooks/CheckoutSessionEventService.test.js

[error] 637-638: More than 1 blank line not allowed.

(no-multiple-empty-lines)

⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (3)
  • GitHub Check: Setup
  • GitHub Check: Build & Push
  • GitHub Check: Setup
🔇 Additional comments (15)
ghost/core/core/server/services/stripe/services/webhook/CheckoutSessionEventService.js (4)

26-26: LGTM! Dependency properly documented.

The new getTokenDataFromMagicLinkToken dependency is correctly added to the JSDoc and will be accessible via this.deps.


170-174: LGTM! Appropriate guard for deleted customers.

This guard prevents processing checkout sessions for deleted Stripe customers, which could otherwise cause downstream issues.


187-211: LGTM! Token-based data retrieval is well-implemented.

The implementation correctly:

  • Extracts the token from the success URL
  • Gracefully handles missing/invalid tokens and retrieval errors
  • Preserves the distinction between undefined newsletters (use defaults) and [] (opted out) via the 'newsletters' in tokenData check
  • Logs warnings without disrupting the checkout flow

213-225: LGTM! Member creation properly uses token-derived data.

The member creation flow correctly incorporates newsletters and attribution from the token data, with appropriate fallbacks when token retrieval fails (both fields will be null).

ghost/core/test/e2e-api/members/create-stripe-checkout-session.test.js (2)

15-113: LGTM! Well-structured Stripe mocking helpers.

The helper functions effectively centralize Stripe API mock responses, reducing duplication across tests. The JSDoc is clear, and the implementation correctly handles different resource types and configuration options.


339-365: LGTM! Graceful handling test is appropriate.

This test correctly verifies that the checkout session creation succeeds even when no metadata is provided, ensuring the system handles missing token data gracefully.

ghost/core/test/unit/server/services/stripe/services/webhooks/CheckoutSessionEventService.test.js (4)

539-564: LGTM! Test setup properly reflects token-based flow.

The test setup correctly:

  • Stubs getTokenDataFromMagicLinkToken with realistic newsletter and attribution data
  • Passes the dependency to the service constructor
  • Includes a success_url with a token parameter in the session

596-618: LGTM! Happy path test for token extraction.

This test correctly verifies that newsletters and attribution are extracted from the token and passed to member creation. This addresses the previously flagged missing "happy path" test.


677-690: Clarify test intent for existing member path.

This test verifies that newsletters is undefined when not in token data, but the existing member flow (lines 226-267 in CheckoutSessionEventService.js) doesn't actually extract or use token data—it only uses session metadata for attribution.

The test setup includes getTokenDataFromMagicLinkToken.resolves(...) but this stub isn't invoked in the existing member path. Consider either:

  1. Removing this test if it's not testing actual behavior
  2. Updating the test to reflect that token data isn't used for existing members
  3. Clarifying the test name/comments if there's a specific edge case being tested

Based on learnings


692-752: LGTM! Comprehensive edge case coverage.

These tests provide excellent coverage for:

  • Missing token in success URL (graceful fallback to null)
  • Invalid token retrieval (graceful fallback to null)
  • Newsletter opt-out distinction (empty array [] vs. undefined/missing field)

The opt-out distinction test is particularly valuable as it ensures the 'newsletters' in tokenData check in the implementation correctly preserves the difference between explicit opt-out and "use defaults."

ghost/core/core/server/services/members/members-api/controllers/RouterController.js (5)

345-345: LGTM! JSDoc correctly documents the new parameter.

The documentation accurately reflects the optional newsletters parameter and its purpose.


379-395: LGTM! Token-based newsletter storage correctly implemented.

The tokenData structure properly consolidates email, attribution, and newsletters. The conditional inclusion of newsletters and the explanatory comment clearly document the approach to avoid Stripe's metadata limits.


397-404: LGTM! Attribution correctly removed from Stripe metadata.

The deletion of attribution fields from metadata is correct since they're now included in the token. This avoids duplication and reduces metadata size.


406-411: LGTM! Magic link creation correctly uses new tokenData structure.

The getMagicLink call properly passes the consolidated tokenData object, maintaining the signup flow while including newsletters and attribution.


566-574: LGTM! Options correctly include validated newsletters.

The validated newsletters are properly included in the options object, which is then passed to the checkout session creation. This completes the flow from metadata extraction through validation to token-based storage.

Comment on lines +556 to 563
// Newsletters are passed through magic link tokens instead of Stripe metadata to avoid Stripe's 500 character limit
let validatedNewsletters = null;
const newsletterData = metadata.newsletters;
delete metadata.newsletters;

if (newsletterData) {
validatedNewsletters = await this._validateNewsletters(JSON.parse(newsletterData));
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

Verify JSON.parse safety for metadata.newsletters.

The code assumes metadata.newsletters is a JSON string, but if it's already an object, JSON.parse will throw. Consider adding a type check or try-catch to handle both cases gracefully.


🏁 Script executed:

#!/bin/bash
# Search for how metadata.newsletters is set in the codebase
rg -nP --type=js -C3 'metadata\.newsletters\s*='

Length of output: 0


🏁 Script executed:

#!/bin/bash
# Locate RouterController.js
router_path=$(find . -type f -name 'RouterController.js' | head -n 1)
echo "RouterController.js path: $router_path"
echo "----- Lines 540-600 -----"
sed -n '540,600p' "$router_path"
echo "----- Occurrences of 'newsletters' -----"
grep -n 'newsletters' "$router_path"

Length of output: 3705


🏁 Script executed:

#!/bin/bash
# Locate RouterController.js and inspect metadata initialization
router_path=$(find . -type f -name 'RouterController.js' | head -n 1)
echo "RouterController.js path: $router_path"
echo "--- Lines 480-550 ---"
sed -n '480,550p' "$router_path"
echo "--- metadata declarations and assignments ---"
grep -nP '(const|let)\s+metadata' "$router_path"
grep -nP 'metadata\.' "$router_path" | grep -P '=|:' | head -n 20

Length of output: 4197


Guard parsing of metadata.newsletters in RouterController.js (l.558–563)
Use typeof newsletterData === 'string' before calling JSON.parse (or wrap it in try/catch) so that if metadata.newsletters comes in as an object, we avoid unhandled exceptions.

🤖 Prompt for AI Agents
In
ghost/core/core/server/services/members/members-api/controllers/RouterController.js
around lines 556 to 563, the code calls JSON.parse(newsletterData) without
guarding for non-string input which can throw if metadata.newsletters is already
an object; update the logic to first check typeof newsletterData === 'string'
before parsing and only call JSON.parse when it's a string, otherwise if it's
already an object assign it directly, or wrap the parse in a try/catch to handle
malformed JSON and fall back to a safe default/error path.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
ghost/core/test/e2e-api/members/webhooks.test.js (1)

1788-1793: Consider simplifying the stub setup.

The current approach uses callsFake to delegate to getTokenDataStub, which adds an extra layer. You could simplify by directly using the stub:

-            membersService = require('../../../core/server/services/members');
-            getTokenDataStub = sinon.stub();
-            // Inject the stub into the members service
-            sinon.stub(membersService.api, 'getTokenDataFromMagicLinkToken').callsFake(getTokenDataStub);
+            membersService = require('../../../core/server/services/members');
+            getTokenDataStub = sinon.stub(membersService.api, 'getTokenDataFromMagicLinkToken');

Also, consider moving the require to the before hook if the module doesn't need to be reloaded for each test.

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7e827a1 and bf46ca0.

📒 Files selected for processing (1)
  • ghost/core/test/e2e-api/members/webhooks.test.js (4 hunks)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
  • GitHub Check: Ghost-CLI tests
  • GitHub Check: Unit tests (Node 22.13.1)
  • GitHub Check: Legacy tests (Node 22.13.1, mysql8)
  • GitHub Check: Acceptance tests (Node 22.13.1, sqlite3)
  • GitHub Check: Legacy tests (Node 22.13.1, sqlite3)
  • GitHub Check: Acceptance tests (Node 22.13.1, mysql8)
  • GitHub Check: Lint
  • GitHub Check: Build & Push
🔇 Additional comments (4)
ghost/core/test/e2e-api/members/webhooks.test.js (4)

1774-1775: LGTM!

Variable declarations are appropriately scoped for the test suite.


1798-1798: LGTM!

Standard sinon cleanup is correctly implemented.


1840-1855: LGTM!

The token stub configuration correctly handles both attribution and null cases, with appropriate field mapping from snake_case to camelCase.


1771-1772: Coverage for token-based newsletter selection is present. Unit tests in test/unit/server/services/stripe/services/webhooks/CheckoutSessionEventService.test.js include cases for extracting and handling newsletters from magic-link tokens (e.g., “extracts attribution and newsletters from token for new members” at line 596 and handling missing newsletters at line 677); the E2E test in test/e2e-api/members/create-stripe-checkout-session.test.js verifies that newsletters are included in the token; and test/unit/server/services/members/members-api/controllers/RouterController.test.js asserts that tokenData.newsletters is passed correctly.

Comment on lines +1905 to +1908
// Attribution now comes from tokens in success_url for new members
metadata: {},
// Include a success_url with a token to simulate the new flow
success_url: 'https://example.com/success?token=test-token-123'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

Verify token extraction is tested elsewhere.

The test adds a success_url with a token, but the stub is configured to return attribution regardless of the token value. This means the actual token parsing logic from the URL isn't being exercised in these e2e tests.

Run the following script to verify that token extraction from success_url is covered in other tests:


🏁 Script executed:

#!/bin/bash
# Description: Find tests that verify token extraction from success_url

# Search for tests covering URL token parsing
rg -n -C5 "success_url.*token|parseToken|extractToken" --type=js -g "*test*.js"

# Search for CheckoutSessionEventService tests
rg -n -C3 "CheckoutSessionEventService" --type=js -g "*test*.js"

Length of output: 29228


🏁 Script executed:

#!/bin/bash
# Inspect unit tests around token in success_url for assertions on getTokenDataFromMagicLinkToken
sed -n '540,580p' ghost/core/test/unit/server/services/stripe/services/webhooks/CheckoutSessionEventService.test.js

Length of output: 1489


🏁 Script executed:

#!/bin/bash
# Search for calls/assertions on getTokenDataFromMagicLinkToken stub
rg -n -C3 "getTokenDataFromMagicLinkToken.*calledWith" ghost/core/test/unit/server/services/stripe/services/webhooks/CheckoutSessionEventService.test.js

Length of output: 0


Add assertion for URL token parsing in unit tests
Unit tests in ghost/core/test/unit/server/services/stripe/services/webhooks/CheckoutSessionEventService.test.js stub getTokenDataFromMagicLinkToken but never assert it’s called with the token extracted from success_url (test_token_123), so the URL-parsing logic isn’t exercised. Add an assertion that getTokenDataFromMagicLinkToken is invoked with the correct token.

🤖 Prompt for AI Agents
In ghost/core/test/e2e-api/members/webhooks.test.js around lines 1905 to 1908,
the test sets success_url='https://example.com/success?token=test-token-123' but
never asserts that the token is parsed and passed to
getTokenDataFromMagicLinkToken; add an assertion that the stubbed
getTokenDataFromMagicLinkToken was called with the extracted token string
"test-token-123" (or the exact token value used in the success_url) so the
URL-parsing logic is exercised and verified.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

📜 Review details

Configuration used: CodeRabbit UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between bf46ca0 and bac8963.

📒 Files selected for processing (1)
  • ghost/core/test/unit/server/services/stripe/services/webhooks/CheckoutSessionEventService.test.js (5 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
ghost/core/test/unit/server/services/stripe/services/webhooks/CheckoutSessionEventService.test.js (1)
ghost/core/core/server/services/stripe/services/webhook/CheckoutSessionEventService.js (8)
  • customer (166-168)
  • memberRepository (63-63)
  • memberRepository (99-99)
  • memberRepository (176-176)
  • memberData (216-216)
  • member (64-64)
  • member (100-102)
  • member (178-180)
⏰ Context from checks skipped due to timeout of 90000ms. You can increase the timeout in your CodeRabbit configuration to a maximum of 15 minutes (900000ms). (8)
  • GitHub Check: Ghost-CLI tests
  • GitHub Check: Acceptance tests (Node 22.13.1, mysql8)
  • GitHub Check: Legacy tests (Node 22.13.1, mysql8)
  • GitHub Check: Acceptance tests (Node 22.13.1, sqlite3)
  • GitHub Check: Legacy tests (Node 22.13.1, sqlite3)
  • GitHub Check: Unit tests (Node 22.13.1)
  • GitHub Check: Lint
  • GitHub Check: Build & Push
🔇 Additional comments (6)
ghost/core/test/unit/server/services/stripe/services/webhooks/CheckoutSessionEventService.test.js (6)

539-555: LGTM! Well-structured test setup for token-based flow.

The new getTokenDataFromMagicLinkToken dependency is properly stubbed with realistic default data, and the service constructor correctly receives it. The session setup with a token in success_url provides a good foundation for the test scenarios.


596-618: Excellent! This addresses the missing happy path test.

This test now properly validates that newsletters and attribution are extracted from the token and passed correctly to memberRepository.create for new member creation. The assertions comprehensively verify both the token extraction logic and the resulting member data structure.


676-689: LGTM! Correctly validates newsletter absence for existing members.

The test properly verifies that when the token lacks newsletter data, the newsletters field remains undefined in the member update data. This ensures existing members' newsletter preferences aren't inadvertently modified.


691-704: LGTM! Proper handling of missing token scenario.

This test correctly validates that when no token is present in the success_url, both attribution and newsletters are set to null, ensuring graceful degradation for new member creation.


706-718: LGTM! Proper handling of invalid token scenario.

This test correctly validates that when the token is invalid (returns null), both attribution and newsletters are set to null. This covers an important edge case distinct from the missing token scenario.


720-751: Excellent test! Critical distinction between opt-out and defaults.

This test validates an important semantic difference:

  • Empty array [] = explicit opt-out from all newsletters
  • undefined (field absent) = use system defaults

The test correctly verifies both scenarios and properly resets stubs between them, ensuring the service preserves this distinction when creating new members.

Comment on lines +637 to 649
it('should handle token retrieval errors gracefully', async function () {
api.getCustomer.resolves(customer);
memberRepository.get.resolves(null);
session.metadata.newsletters = 'invalid';

getTokenDataFromMagicLinkToken.rejects(new Error('Token retrieval failed'));

await service.handleSubscriptionEvent(session);

const memberData = memberRepository.create.getCall(0).args[0];
assert.equal(memberData.email, '[email protected]');
assert.equal(memberData.name, 'Metadata Name');
assert.equal(memberData.newsletters, undefined);
assert.equal(memberData.newsletters, null); // null when token retrieval fails
});
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

Verify attribution is also null on token retrieval failure.

The test correctly validates that newsletters is null when token retrieval fails. However, it should also assert that attribution is null to ensure complete error handling verification.

Apply this diff to add the missing assertion:

     const memberData = memberRepository.create.getCall(0).args[0];
     assert.equal(memberData.email, '[email protected]');
     assert.equal(memberData.name, 'Metadata Name');
     assert.equal(memberData.newsletters, null); // null when token retrieval fails
+    assert.equal(memberData.attribution, null); // attribution should also be null on error
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it('should handle token retrieval errors gracefully', async function () {
api.getCustomer.resolves(customer);
memberRepository.get.resolves(null);
session.metadata.newsletters = 'invalid';
getTokenDataFromMagicLinkToken.rejects(new Error('Token retrieval failed'));
await service.handleSubscriptionEvent(session);
const memberData = memberRepository.create.getCall(0).args[0];
assert.equal(memberData.email, '[email protected]');
assert.equal(memberData.name, 'Metadata Name');
assert.equal(memberData.newsletters, undefined);
assert.equal(memberData.newsletters, null); // null when token retrieval fails
});
it('should handle token retrieval errors gracefully', async function () {
api.getCustomer.resolves(customer);
memberRepository.get.resolves(null);
getTokenDataFromMagicLinkToken.rejects(new Error('Token retrieval failed'));
await service.handleSubscriptionEvent(session);
const memberData = memberRepository.create.getCall(0).args[0];
assert.equal(memberData.email, '[email protected]');
assert.equal(memberData.name, 'Metadata Name');
assert.equal(memberData.newsletters, null); // null when token retrieval fails
assert.equal(memberData.attribution, null); // attribution should also be null on error
});
🤖 Prompt for AI Agents
In
ghost/core/test/unit/server/services/stripe/services/webhooks/CheckoutSessionEventService.test.js
around lines 637 to 649, the test for token retrieval errors checks that
newsletters is null but misses asserting that attribution is null; update the
test to also assert that memberData.attribution is null after token retrieval
fails by adding an assertion similar to the existing newsletters check (e.g.,
assert.equal(memberData.attribution, null)) immediately after the newsletters
assertion so the test verifies both fields are null on token retrieval failure.

Copy link
Collaborator

@cmraible cmraible left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Upon further inspection, I don't think we need this change at all:

  • The 500 character limit on Stripe metadata is per key, not an overall limit
  • Therefore, even if we add 5 new UTM related keys to the metadata object, there's no additional risk of passing the limit even if we still use metadata for newsletters
  • Subscription upgrades (free member -> paid) and donations don't use magic link tokens, so we'd probably still be using Stripe metadata for UTMs regardless.

Ultimately I think change just isn't necessary — but since we have it all queued up and context loaded, I wouldn't be against merging it for just the newsletters for new subscriptions — but either way I don't think should block our UTM implementation.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

browser-tests community [triage] Community features and bugs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants