-
-
Notifications
You must be signed in to change notification settings - Fork 11.1k
Updated new paid member newsletter handling #25022
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
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.
WalkthroughRouterController 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)
✨ Finishing touches
🧪 Generate unit tests
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. Comment |
There was a problem hiding this 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:
- Non-empty newsletter arrays - Verify actual newsletter IDs are handled correctly
- Large newsletter selections - Test the expanded capacity (PR mentions moving from 500 to 2000 character limit)
- Token verification - Confirm newsletters are actually present in the magic-link token
- 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
📒 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.
ghost/core/test/e2e-api/members/create-stripe-checkout-session.test.js
Outdated
Show resolved
Hide resolved
ghost/core/test/e2e-api/members/create-stripe-checkout-session.test.js
Outdated
Show resolved
Hide resolved
There was a problem hiding this 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]; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
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); | ||
}); |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 () { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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([]) |
There was a problem hiding this comment.
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 () { |
There was a problem hiding this comment.
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 () { |
There was a problem hiding this comment.
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 () { |
There was a problem hiding this comment.
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 🤔
There was a problem hiding this 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 APIAlternatively, 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 tonull
.Apply this diff:
}); - - + it('should handle token retrieval errors gracefully', async function () {
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
⛔ 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 viathis.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
isundefined
when not in token data, but the existing member flow (lines 226-267 inCheckoutSessionEventService.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:
- Removing this test if it's not testing actual behavior
- Updating the test to reflect that token data isn't used for existing members
- 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.
// 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)); | ||
} |
There was a problem hiding this comment.
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.
There was a problem hiding this 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 togetTokenDataStub
, 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 thebefore
hook if the module doesn't need to be reloaded for each test.
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 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 intest/unit/server/services/stripe/services/webhooks/CheckoutSessionEventService.test.js
include cases for extracting and handlingnewsletters
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 intest/e2e-api/members/create-stripe-checkout-session.test.js
verifies that newsletters are included in the token; andtest/unit/server/services/members/members-api/controllers/RouterController.test.js
asserts thattokenData.newsletters
is passed correctly.
// 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' |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 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.
There was a problem hiding this 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
📒 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 insuccess_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 remainsundefined
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
, bothattribution
andnewsletters
are set tonull
, 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
), bothattribution
andnewsletters
are set tonull
. 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 newslettersundefined
(field absent) = use system defaultsThe test correctly verifies both scenarios and properly resets stubs between them, ensuring the service preserves this distinction when creating new members.
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 | ||
}); |
There was a problem hiding this comment.
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.
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.
There was a problem hiding this 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.
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