const sinon = require('sinon');
const assert = require('node:assert/strict');
const errors = require('@tryghost/errors');

// @ts-ignore - Intentionally ignoring TypeScript errors for tests
const RouterController = require('../../../../../../../core/server/services/members/members-api/controllers/router-controller');

describe('RouterController', function () {
    let offersAPI;
    let paymentsService;
    let tiersService;
    let stripeAPIService;
    let labsService;
    let getPaymentLinkSpy;
    let getDonationLinkSpy;
    let settingsCache;
    let settingsHelpers;
    let emailAddressService;
    let urlUtils;

    beforeEach(async function () {
        // Mock emailAddressService for inbox links sender address transformation
        emailAddressService = {
            getMembersSupportAddress: sinon.stub().returns('noreply@example.com')
        };

        // Default urlUtils used by createCheckoutSession to validate return URLs
        urlUtils = {
            getSiteUrl: sinon.stub().returns('https://example.com/')
        };

        getPaymentLinkSpy = sinon.spy();
        getDonationLinkSpy = sinon.spy();
        tiersService = {
            api: {
                read: sinon.stub().resolves({
                    id: 'tier_123'
                })
            }
        };

        paymentsService = {
            getPaymentLink: getPaymentLinkSpy,
            stripeAPIService: {
                configured: true
            },
            getDonationPaymentLink: getDonationLinkSpy
        };

        offersAPI = {
            getOffer: sinon.stub().resolves({
                id: 'offer_123',
                tier: {
                    id: 'tier_123'
                }
            }),
            findOne: sinon.stub().resolves({
                related: () => {
                    return {
                        query: sinon.stub().returns({
                            fetchOne: sinon.stub().resolves({})
                        }),
                        toJSON: sinon.stub().returns([]),
                        fetch: sinon.stub().resolves({
                            toJSON: sinon.stub().returns({})
                        })
                    };
                },
                toJSON: sinon.stub().returns({})
            }),
            edit: sinon.stub().resolves({
                attributes: {},
                _previousAttributes: {}
            })
        };

        stripeAPIService = {
            configured: true
        };
        labsService = {
            isSet: sinon.stub().returns(true)
        };
        settingsCache = {
            get: sinon.stub().withArgs('all_blocked_email_domains').returns(['spam.xyz'])
        };
        settingsHelpers = {
            getMembersSupportAddress: sinon.stub().returns('noreply@example.com'),
            arePaidMembersEnabled: sinon.stub().returns(true),
            areDonationsEnabled: sinon.stub().returns(true)
        };
    });

    afterEach(function () {
        sinon.restore();
    });

    describe('createCheckoutSession', function (){
        it('passes offer metadata to payment link method', async function (){
            const routerController = new RouterController({
                tiersService,
                paymentsService,
                offersAPI,
                stripeAPIService,
                labsService,
                settingsCache,
                settingsHelpers,
                urlUtils,
                emailAddressService
            });

            await routerController.createCheckoutSession({
                body: {
                    offerId: 'offer_123'
                }
            }, {
                writeHead: () => {},
                end: () => {}
            });

            sinon.assert.calledOnce(getPaymentLinkSpy);

            // Payment link is called with the offer id in metadata
            sinon.assert.calledWith(getPaymentLinkSpy, sinon.match({
                metadata: {offer: 'offer_123'}
            }));
        });

        it('parses newsletters from the request body', async function () {
            const newslettersServiceStub = {
                getAll: sinon.stub()
            };
            newslettersServiceStub.getAll.resolves([
                {id: 'abc123', name: 'Newsletter 1', status: 'active'},
                {id: 'def456', name: 'Newsletter 2', status: 'active'}
            ]);
            const routerController = new RouterController({
                tiersService,
                paymentsService,
                offersAPI,
                stripeAPIService,
                labsService,
                settingsCache,
                settingsHelpers,
                urlUtils,
                newslettersService: newslettersServiceStub,
                emailAddressService
            });
            const newsletters = [
                {id: 'abc123', name: 'Newsletter 1'},
                {id: 'def456', name: 'Newsletter 2'}
            ];
            const newslettersString = JSON.stringify(newsletters);
            const req = {
                body: {
                    tierId: 'tier_123',
                    cadence: 'month',
                    metadata: {
                        newsletters: newslettersString
                    }
                }
            };

            await routerController.createCheckoutSession(req, {
                writeHead: () => {},
                end: () => {}
            });

            const expectedNewsletters = JSON.stringify([{id: 'abc123'}, {id: 'def456'}]);

            sinon.assert.calledOnce(getPaymentLinkSpy);

            sinon.assert.calledWith(getPaymentLinkSpy, sinon.match({
                metadata: {
                    newsletters: expectedNewsletters
                }
            }));
        });

        it('sets ghostSignupContext to has_precheckout_magic_link when checkout creates a signup magic link', async function () {
            const magicLinkService = {
                getMagicLink: sinon.stub().resolves('https://example.com/members/?token=abc123&action=signup')
            };
            const memberRepository = {
                get: sinon.stub().resolves(null)
            };
            const routerController = new RouterController({
                tiersService,
                paymentsService,
                offersAPI,
                stripeAPIService,
                labsService,
                settingsCache,
                settingsHelpers,
                urlUtils,
                magicLinkService,
                memberRepository
            });

            await routerController.createCheckoutSession({
                body: {
                    tierId: 'tier_123',
                    cadence: 'month',
                    customerEmail: 'new-member@example.com',
                    successUrl: 'https://example.com/paid-success',
                    cancelUrl: 'https://example.com/cancel',
                    metadata: {}
                }
            }, {
                writeHead: () => {},
                end: () => {}
            });

            sinon.assert.calledOnce(magicLinkService.getMagicLink);
            sinon.assert.calledWith(getPaymentLinkSpy, sinon.match({
                successUrl: 'https://example.com/members/?token=abc123&action=signup',
                metadata: {
                    ghostSignupContext: 'has_precheckout_magic_link'
                }
            }));
        });

        it('sets ghostSignupContext to already_authenticated for authenticated members', async function () {
            const member = {
                get: sinon.stub().withArgs('status').returns('free')
            };
            const tokenService = {
                decodeToken: sinon.stub().resolves({sub: 'member@example.com'})
            };
            const memberRepository = {
                get: sinon.stub().resolves(member)
            };
            const routerController = new RouterController({
                tiersService,
                paymentsService,
                offersAPI,
                stripeAPIService,
                labsService,
                settingsCache,
                settingsHelpers,
                urlUtils,
                tokenService,
                memberRepository
            });

            await routerController.createCheckoutSession({
                body: {
                    tierId: 'tier_123',
                    cadence: 'month',
                    identity: 'identity-token',
                    successUrl: 'https://example.com/paid-success',
                    cancelUrl: 'https://example.com/cancel',
                    metadata: {}
                }
            }, {
                writeHead: () => {},
                end: () => {}
            });

            sinon.assert.calledWith(getPaymentLinkSpy, sinon.match({
                metadata: {
                    ghostSignupContext: 'already_authenticated'
                }
            }));
        });

        it('sets ghostSignupContext to needs_magic_link_email when there is no member context or customer email', async function () {
            const routerController = new RouterController({
                tiersService,
                paymentsService,
                offersAPI,
                stripeAPIService,
                labsService,
                settingsCache,
                settingsHelpers,
                urlUtils
            });

            await routerController.createCheckoutSession({
                body: {
                    tierId: 'tier_123',
                    cadence: 'month',
                    successUrl: 'https://example.com/paid-success',
                    cancelUrl: 'https://example.com/cancel',
                    metadata: {}
                }
            }, {
                writeHead: () => {},
                end: () => {}
            });

            sinon.assert.calledWith(getPaymentLinkSpy, sinon.match({
                metadata: {
                    ghostSignupContext: 'needs_magic_link_email'
                }
            }));
        });

        describe('_getSubscriptionCheckoutData', function () {
            it('returns a BadRequestError if both offerId and tierId are missing', async function () {
                const routerController = new RouterController({
                    tiersService,
                    paymentsService,
                    offersAPI,
                    stripeAPIService,
                    labsService,
                    settingsCache,
                    settingsHelpers
                });

                try {
                    await routerController._getSubscriptionCheckoutData({body: {}});
                    assert.fail('Expected function to throw BadRequestError');
                } catch (error) {
                    assert(error instanceof errors.BadRequestError, 'Error should be an instance of BadRequestError');
                    assert.equal(error.context, 'Expected offerId or tierId, received none');
                }
            });

            it('returns a BadRequestError if both offerId and tierId are provided', async function () {
                const routerController = new RouterController({
                    tiersService,
                    paymentsService,
                    offersAPI,
                    stripeAPIService,
                    labsService,
                    settingsCache,
                    settingsHelpers
                });

                try {
                    await routerController._getSubscriptionCheckoutData({tierId: 'tier_123', offerId: 'offer_123'});
                    assert.fail('Expected function to throw BadRequestError');
                } catch (error) {
                    assert(error instanceof errors.BadRequestError, 'Error should be an instance of BadRequestError');
                    assert.equal(error.context, 'Expected offerId or tierId, received both');
                }
            });

            it('returns a BadRequestError if tierId is provided wihout a cadence', async function () {
                const routerController = new RouterController({
                    tiersService,
                    paymentsService,
                    offersAPI,
                    stripeAPIService,
                    labsService,
                    settingsCache,
                    settingsHelpers
                });

                try {
                    await routerController._getSubscriptionCheckoutData({tierId: 'tier_123'});
                    assert.fail('Expected function to throw BadRequestError');
                } catch (error) {
                    assert(error instanceof errors.BadRequestError, 'Error should be an instance of BadRequestError');
                    assert.equal(error.context, 'Expected cadence to be "month" or "year", received undefined');
                }
            });

            it('returns a BadRequestError if tierId is provided wihout a valid cadence', async function () {
                const routerController = new RouterController({
                    tiersService,
                    paymentsService,
                    offersAPI,
                    stripeAPIService,
                    labsService,
                    settingsCache,
                    settingsHelpers
                });

                try {
                    await routerController._getSubscriptionCheckoutData({tierId: 'tier_123', cadence: 'day'});
                    assert.fail('Expected function to throw BadRequestError');
                } catch (error) {
                    assert(error instanceof errors.BadRequestError, 'Error should be an instance of BadRequestError');
                    assert.equal(error.context, 'Expected cadence to be "month" or "year", received "day"');
                }
            });

            it('returns a BadRequestError if offer is not found by offerId', async function () {
                offersAPI = {
                    getOffer: sinon.stub().resolves(null)
                };

                const routerController = new RouterController({
                    tiersService,
                    paymentsService,
                    offersAPI,
                    stripeAPIService,
                    labsService,
                    settingsCache,
                    settingsHelpers
                });

                try {
                    await routerController._getSubscriptionCheckoutData({offerId: 'invalid'});

                    assert.fail('Expected function to throw BadRequestError');
                } catch (error) {
                    assert(error instanceof errors.BadRequestError, 'Error should be an instance of BadRequestError');
                    assert.equal(error.context, 'Offer with id "invalid" not found');
                }
            });

            it('returns a BadRequestError if tier is not found by tierId', async function () {
                tiersService = {
                    api: {
                        read: sinon.stub().resolves(null)
                    }
                };

                const routerController = new RouterController({
                    tiersService,
                    paymentsService,
                    offersAPI,
                    stripeAPIService,
                    labsService,
                    settingsCache,
                    settingsHelpers
                });

                try {
                    await routerController._getSubscriptionCheckoutData({tierId: 'invalid', cadence: 'year'});

                    assert.fail('Expected function to throw BadRequestError');
                } catch (error) {
                    assert(error instanceof errors.BadRequestError, 'Error should be an instance of BadRequestError');
                    assert.equal(error.context, 'Tier with id "invalid" not found');
                }
            });

            it('returns a BadRequestError if fetching tier by tierId throws', async function () {
                tiersService = {
                    api: {
                        read: sinon.stub().rejects(new Error('Fail to fetch tier'))
                    }
                };

                const routerController = new RouterController({
                    tiersService,
                    paymentsService,
                    offersAPI,
                    stripeAPIService,
                    labsService,
                    settingsCache,
                    settingsHelpers
                });

                try {
                    await routerController._getSubscriptionCheckoutData({tierId: 'invalid', cadence: 'year'});

                    assert.fail('Expected function to throw BadRequestError');
                } catch (error) {
                    assert(error instanceof errors.BadRequestError, 'Error should be an instance of BadRequestError');
                    assert.equal(error.context, 'Tier with id "invalid" not found');
                }
            });
        });
        describe('_createDonationCheckoutSession', function () {
            it('accepts requests with a personalNote included', async function () {
                const routerController = new RouterController({
                    tiersService,
                    paymentsService,
                    offersAPI,
                    stripeAPIService,
                    labsService,
                    settingsCache,
                    settingsHelpers,
                    urlUtils,
                    memberAttributionService: {
                        getAttribution: sinon.stub().resolves({})
                    }
                });

                await routerController.createCheckoutSession({
                    body: {
                        type: 'donation',
                        successUrl: 'https://example.com/?type=success',
                        cancelUrl: 'https://example.com/?type=cancel',
                        personalNote: 'SVP leave a note here',
                        metadata: {
                            test: 'hello',
                            urlHistory: [
                                {
                                    path: 'https://example.com/',
                                    time: Date.now(),
                                    referrerMedium: null,
                                    referrerSource: 'ghost-explore',
                                    referrerUrl: 'https://example.com/blog/'
                                }
                            ]
                        }
                    }
                }, {
                    writeHead: () => {},
                    end: () => {}
                });
                sinon.assert.calledOnce(getDonationLinkSpy);

                sinon.assert.calledWith(getDonationLinkSpy, sinon.match({
                    successUrl: 'https://example.com/?type=success',
                    cancelUrl: 'https://example.com/?type=cancel',
                    personalNote: 'SVP leave a note here',
                    metadata: {
                        test: 'hello'
                    }
                }));
            });
            it('accepts requests without a personalNote included', async function () {
                const routerController = new RouterController({
                    tiersService,
                    paymentsService,
                    offersAPI,
                    stripeAPIService,
                    labsService,
                    settingsCache,
                    settingsHelpers,
                    urlUtils,
                    memberAttributionService: {
                        getAttribution: sinon.stub().resolves({})
                    }
                });

                await routerController.createCheckoutSession({
                    body: {
                        type: 'donation',
                        successUrl: 'https://example.com/?type=success',
                        cancelUrl: 'https://example.com/?type=cancel',
                        metadata: {
                            test: 'hello',
                            urlHistory: [
                                {
                                    path: 'https://example.com/',
                                    time: Date.now(),
                                    referrerMedium: null,
                                    referrerSource: 'ghost-explore',
                                    referrerUrl: 'https://example.com/blog/'
                                }
                            ]
                        }
                    }
                }, {
                    writeHead: () => {},
                    end: () => {}
                });
                sinon.assert.calledOnce(getDonationLinkSpy);

                sinon.assert.calledWith(getDonationLinkSpy, sinon.match({
                    successUrl: 'https://example.com/?type=success',
                    cancelUrl: 'https://example.com/?type=cancel',
                    personalNote: '',
                    metadata: {
                        test: 'hello'
                    }
                }));
            });
            it('silently discards too-long personal notes', async function () {
                const routerController = new RouterController({
                    tiersService,
                    paymentsService,
                    offersAPI,
                    stripeAPIService,
                    labsService,
                    settingsCache,
                    settingsHelpers,
                    urlUtils,
                    memberAttributionService: {
                        getAttribution: sinon.stub().resolves({})
                    }
                });

                await routerController.createCheckoutSession({
                    body: {
                        type: 'donation',
                        successUrl: 'https://example.com/?type=success',
                        cancelUrl: 'https://example.com/?type=cancel',
                        personalNote: 'a'.repeat(1000),
                        metadata: {
                            test: 'hello',
                            urlHistory: [
                                {
                                    path: 'https://example.com/',
                                    time: Date.now(),
                                    referrerMedium: null,
                                    referrerSource: 'ghost-explore',
                                    referrerUrl: 'https://example.com/blog/'
                                }
                            ]
                        }
                    }
                }, {
                    writeHead: () => {},
                    end: () => {}
                });
                sinon.assert.calledOnce(getDonationLinkSpy);
                sinon.assert.calledWith(getDonationLinkSpy, sinon.match({
                    successUrl: 'https://example.com/?type=success',
                    cancelUrl: 'https://example.com/?type=cancel',
                    personalNote: '',
                    metadata: {
                        test: 'hello'
                    }
                }));
            });
            it('silently discards invalid personal notes', async function () {
                const routerController = new RouterController({
                    tiersService,
                    paymentsService,
                    offersAPI,
                    stripeAPIService,
                    labsService,
                    settingsCache,
                    settingsHelpers,
                    urlUtils,
                    memberAttributionService: {
                        getAttribution: sinon.stub().resolves({})
                    }
                });

                await routerController.createCheckoutSession({
                    body: {
                        type: 'donation',
                        successUrl: 'https://example.com/?type=success',
                        cancelUrl: 'https://example.com/?type=cancel',
                        personalNote: {hey: 'look! an object!'},
                        metadata: {
                            test: 'hello',
                            urlHistory: [
                                {
                                    path: 'https://example.com/',
                                    time: Date.now(),
                                    referrerMedium: null,
                                    referrerSource: 'ghost-explore',
                                    referrerUrl: 'https://example.com/blog/'
                                }
                            ]
                        }
                    }
                }, {
                    writeHead: () => {},
                    end: () => {}
                });
                sinon.assert.calledOnce(getDonationLinkSpy);
                sinon.assert.calledWith(getDonationLinkSpy, sinon.match({
                    successUrl: 'https://example.com/?type=success',
                    cancelUrl: 'https://example.com/?type=cancel',
                    personalNote: '',
                    metadata: {
                        test: 'hello'
                    }
                }));
            });
            it('strips any html from the personal note', async function () {
                const routerController = new RouterController({
                    tiersService,
                    paymentsService,
                    offersAPI,
                    stripeAPIService,
                    labsService,
                    settingsCache,
                    settingsHelpers,
                    urlUtils,
                    memberAttributionService: {
                        getAttribution: sinon.stub().resolves({})
                    }
                });

                await routerController.createCheckoutSession({
                    body: {
                        type: 'donation',
                        successUrl: 'https://example.com/?type=success',
                        cancelUrl: 'https://example.com/?type=cancel',
                        personalNote: 'Leave a <a href="ghost.org">note</a> here',
                        metadata: {
                            test: 'hello',
                            urlHistory: [
                                {
                                    path: 'https://example.com/',
                                    time: Date.now(),
                                    referrerMedium: null,
                                    referrerSource: 'ghost-explore',
                                    referrerUrl: 'https://example.com/blog/'
                                }
                            ]
                        }
                    }
                }, {
                    writeHead: () => {},
                    end: () => {}
                });
                sinon.assert.calledOnce(getDonationLinkSpy);
                sinon.assert.calledWith(getDonationLinkSpy, sinon.match({
                    successUrl: 'https://example.com/?type=success',
                    cancelUrl: 'https://example.com/?type=cancel',
                    personalNote: 'Leave a note here',
                    metadata: {
                        test: 'hello'
                    }
                }));
            });

            it('throws DisabledFeatureError when donations are not enabled', async function () {
                const routerController = new RouterController({
                    tiersService,
                    paymentsService,
                    offersAPI,
                    stripeAPIService,
                    labsService,
                    settingsCache,
                    settingsHelpers: {
                        ...settingsHelpers,
                        areDonationsEnabled: sinon.stub().returns(false)
                    },
                    urlUtils,
                    memberAttributionService: {
                        getAttribution: sinon.stub().resolves({})
                    }
                });

                try {
                    await routerController.createCheckoutSession({
                        body: {
                            type: 'donation',
                            successUrl: 'https://example.com/?type=success',
                            cancelUrl: 'https://example.com/?type=cancel',
                            metadata: {}
                        }
                    }, {
                        writeHead: () => {},
                        end: () => {}
                    });
                    assert.fail('Should have thrown');
                } catch (error) {
                    assert(error instanceof errors.DisabledFeatureError);
                    sinon.assert.notCalled(getDonationLinkSpy);
                }
            });
        });

        describe('gift checkout', function () {
            let getGiftLinkSpy;

            beforeEach(function () {
                getGiftLinkSpy = sinon.stub().resolves('https://checkout.stripe.com/gift');
                paymentsService.getGiftPaymentLink = getGiftLinkSpy;
            });

            function createGiftController(overrides = {}) {
                return new RouterController({
                    tiersService,
                    paymentsService,
                    offersAPI,
                    stripeAPIService,
                    labsService,
                    settingsCache,
                    settingsHelpers,
                    memberRepository: {get: sinon.stub().resolves(null)},
                    urlUtils: {getSiteUrl: sinon.stub().returns('https://example.com/')},
                    memberAttributionService: {getAttribution: sinon.stub().resolves({})},
                    emailAddressService,
                    ...overrides
                });
            }

            const mockRes = {writeHead: () => {}, end: () => {}};

            function paidTierService(price = 5000) {
                return {
                    api: {
                        read: sinon.stub().resolves({
                            id: {toHexString: () => 'tier_123'},
                            status: 'active',
                            getPrice: sinon.stub().returns(price)
                        })
                    }
                };
            }

            it('calls getGiftPaymentLink with correct options', async function () {
                const controller = createGiftController({tiersService: paidTierService()});

                await controller.createCheckoutSession({
                    body: {type: 'gift', tierId: 'tier_123', cadence: 'month', metadata: {}}
                }, mockRes);

                sinon.assert.calledOnce(getGiftLinkSpy);
                sinon.assert.calledWith(getGiftLinkSpy, sinon.match({
                    successUrl: 'https://example.com/',
                    cancelUrl: 'https://example.com/'
                }));
            });

            it('uses cancelUrl from the request body when provided', async function () {
                const controller = createGiftController({tiersService: paidTierService()});

                await controller.createCheckoutSession({
                    body: {
                        type: 'gift',
                        tierId: 'tier_123',
                        cadence: 'month',
                        metadata: {},
                        cancelUrl: 'https://example.com/post/#/portal/gift'
                    }
                }, mockRes);

                sinon.assert.calledOnce(getGiftLinkSpy);
                sinon.assert.calledWith(getGiftLinkSpy, sinon.match({
                    successUrl: 'https://example.com/',
                    cancelUrl: 'https://example.com/post/#/portal/gift'
                }));
            });

            it('passes customerEmail through for logged-out gift checkout', async function () {
                const controller = createGiftController({tiersService: paidTierService()});

                await controller.createCheckoutSession({
                    body: {
                        type: 'gift',
                        tierId: 'tier_123',
                        cadence: 'month',
                        customerEmail: 'jamie@example.com',
                        metadata: {}
                    }
                }, mockRes);

                sinon.assert.calledOnce(getGiftLinkSpy);
                sinon.assert.calledWith(getGiftLinkSpy, sinon.match({
                    email: 'jamie@example.com',
                    isAuthenticated: false
                }));
            });

            it('rejects when offerId is provided', async function () {
                const controller = createGiftController();

                try {
                    await controller.createCheckoutSession({
                        body: {type: 'gift', offerId: 'offer_123', metadata: {}}
                    }, mockRes);
                    assert.fail('Should have thrown');
                } catch (error) {
                    assert(error instanceof errors.BadRequestError);
                    assert.equal(error.context, 'Offers cannot be applied to gift subscriptions');
                }
            });

            it('throws DisabledFeatureError when paid members are not enabled', async function () {
                const controller = createGiftController({
                    tiersService: paidTierService(),
                    settingsHelpers: {
                        ...settingsHelpers,
                        arePaidMembersEnabled: sinon.stub().returns(false)
                    }
                });

                try {
                    await controller.createCheckoutSession({
                        body: {type: 'gift', tierId: 'tier_123', cadence: 'month', metadata: {}}
                    }, mockRes);
                    assert.fail('Should have thrown');
                } catch (error) {
                    assert(error instanceof errors.DisabledFeatureError);
                    sinon.assert.notCalled(getGiftLinkSpy);
                }
            });

            it('does not block paid members from purchasing gifts', async function () {
                const controller = createGiftController({
                    tiersService: paidTierService(),
                    tokenService: {decodeToken: sinon.stub().resolves({sub: 'member@example.com'})},
                    memberRepository: {
                        get: sinon.stub().resolves({
                            id: 'member_123',
                            get: sinon.stub().returns('paid'),
                            related: sinon.stub().returns({
                                query: sinon.stub().returns({
                                    fetch: sinon.stub().resolves([])
                                })
                            })
                        })
                    }
                });

                await controller.createCheckoutSession({
                    body: {type: 'gift', tierId: 'tier_123', cadence: 'month', identity: 'valid-token', metadata: {}}
                }, mockRes);

                sinon.assert.calledOnce(getGiftLinkSpy);
            });
        });

        describe('return URL validation', function () {
            // Any successUrl/cancelUrl posted to the public checkout endpoint that
            // doesn't point back to the configured site is discarded before being
            // forwarded to Stripe, so the downstream Stripe service falls back to its
            // built-in same-origin defaults.

            const mockRes = {writeHead: () => {}, end: () => {}};

            function createSubscriptionController(overrides = {}) {
                return new RouterController({
                    tiersService,
                    paymentsService,
                    offersAPI,
                    stripeAPIService,
                    labsService,
                    settingsCache,
                    settingsHelpers,
                    urlUtils,
                    memberAttributionService: {getAttribution: sinon.stub().resolves({})},
                    ...overrides
                });
            }

            function createGiftReturnController(overrides = {}) {
                return new RouterController({
                    tiersService: {
                        api: {
                            read: sinon.stub().resolves({
                                id: {toHexString: () => 'tier_123'},
                                status: 'active',
                                getPrice: sinon.stub().returns(5000)
                            })
                        }
                    },
                    paymentsService,
                    offersAPI,
                    stripeAPIService,
                    labsService,
                    settingsCache,
                    settingsHelpers,
                    memberRepository: {get: sinon.stub().resolves(null)},
                    memberAttributionService: {getAttribution: sinon.stub().resolves({})},
                    urlUtils,
                    ...overrides
                });
            }

            describe('subscription checkout', function () {
                it('passes through same-origin successUrl and cancelUrl unchanged', async function () {
                    const controller = createSubscriptionController();

                    await controller.createCheckoutSession({
                        body: {
                            tierId: 'tier_123',
                            cadence: 'month',
                            successUrl: 'https://example.com/welcome?foo=bar',
                            cancelUrl: 'https://example.com/checkout-cancelled',
                            metadata: {}
                        }
                    }, mockRes);

                    sinon.assert.calledWith(getPaymentLinkSpy, sinon.match({
                        successUrl: 'https://example.com/welcome?foo=bar',
                        cancelUrl: 'https://example.com/checkout-cancelled'
                    }));
                });

                it('drops cross-origin cancelUrl', async function () {
                    const controller = createSubscriptionController();

                    await controller.createCheckoutSession({
                        body: {
                            tierId: 'tier_123',
                            cadence: 'month',
                            successUrl: 'https://example.com/welcome',
                            cancelUrl: 'https://external.example/steal',
                            metadata: {}
                        }
                    }, mockRes);

                    sinon.assert.calledWith(getPaymentLinkSpy, sinon.match({
                        successUrl: 'https://example.com/welcome',
                        cancelUrl: undefined
                    }));
                });

                it('drops cross-origin successUrl', async function () {
                    const controller = createSubscriptionController();

                    await controller.createCheckoutSession({
                        body: {
                            tierId: 'tier_123',
                            cadence: 'month',
                            successUrl: 'https://external.example/welcome',
                            cancelUrl: 'https://example.com/cancel',
                            metadata: {}
                        }
                    }, mockRes);

                    sinon.assert.calledWith(getPaymentLinkSpy, sinon.match({
                        successUrl: undefined,
                        cancelUrl: 'https://example.com/cancel'
                    }));
                });

                it('drops URLs that share a hostname suffix but a different origin', async function () {
                    const controller = createSubscriptionController();

                    await controller.createCheckoutSession({
                        body: {
                            tierId: 'tier_123',
                            cadence: 'month',
                            // example.com.external.example would pass a naive .includes/startsWith check
                            successUrl: 'https://example.com.external.example/welcome',
                            cancelUrl: 'https://examplecom.example/cancel',
                            metadata: {}
                        }
                    }, mockRes);

                    sinon.assert.calledWith(getPaymentLinkSpy, sinon.match({
                        successUrl: undefined,
                        cancelUrl: undefined
                    }));
                });

                it('drops malformed return URLs', async function () {
                    const controller = createSubscriptionController();

                    await controller.createCheckoutSession({
                        body: {
                            tierId: 'tier_123',
                            cadence: 'month',
                            successUrl: 'not a url',
                            cancelUrl: '//external.example/cancel',
                            metadata: {}
                        }
                    }, mockRes);

                    sinon.assert.calledWith(getPaymentLinkSpy, sinon.match({
                        successUrl: undefined,
                        cancelUrl: undefined
                    }));
                });

                it('passes undefined when successUrl/cancelUrl are missing', async function () {
                    const controller = createSubscriptionController();

                    await controller.createCheckoutSession({
                        body: {
                            tierId: 'tier_123',
                            cadence: 'month',
                            metadata: {}
                        }
                    }, mockRes);

                    sinon.assert.calledWith(getPaymentLinkSpy, sinon.match({
                        successUrl: undefined,
                        cancelUrl: undefined
                    }));
                });

                it('respects the site subpath when validating return URLs', async function () {
                    const subpathUrlUtils = {
                        getSiteUrl: sinon.stub().returns('https://example.com/blog/')
                    };
                    const controller = createSubscriptionController({urlUtils: subpathUrlUtils});

                    await controller.createCheckoutSession({
                        body: {
                            tierId: 'tier_123',
                            cadence: 'month',
                            // Same origin but outside the /blog/ subpath
                            successUrl: 'https://example.com/welcome',
                            // Inside the subpath
                            cancelUrl: 'https://example.com/blog/cancelled',
                            metadata: {}
                        }
                    }, mockRes);

                    sinon.assert.calledWith(getPaymentLinkSpy, sinon.match({
                        successUrl: undefined,
                        cancelUrl: 'https://example.com/blog/cancelled'
                    }));
                });

                it('accepts subpath URLs without a trailing slash as equivalent to the subpath', async function () {
                    const subpathUrlUtils = {
                        getSiteUrl: sinon.stub().returns('https://example.com/blog/')
                    };
                    const controller = createSubscriptionController({urlUtils: subpathUrlUtils});

                    await controller.createCheckoutSession({
                        body: {
                            tierId: 'tier_123',
                            cadence: 'month',
                            // No trailing slash — should be treated as equivalent to /blog/
                            successUrl: 'https://example.com/blog',
                            // /blogger is *not* the same as /blog/ — must be rejected
                            cancelUrl: 'https://example.com/blogger/oops',
                            metadata: {}
                        }
                    }, mockRes);

                    sinon.assert.calledWith(getPaymentLinkSpy, sinon.match({
                        successUrl: 'https://example.com/blog',
                        cancelUrl: undefined
                    }));
                });
            });

            describe('donation checkout', function () {
                it('drops cross-origin successUrl and cancelUrl', async function () {
                    const controller = createSubscriptionController();

                    await controller.createCheckoutSession({
                        body: {
                            type: 'donation',
                            successUrl: 'https://external.example/win',
                            cancelUrl: 'https://external.example/oops',
                            metadata: {}
                        }
                    }, mockRes);

                    sinon.assert.calledWith(getDonationLinkSpy, sinon.match({
                        successUrl: undefined,
                        cancelUrl: undefined
                    }));
                });

                it('passes through same-origin URLs unchanged', async function () {
                    const controller = createSubscriptionController();

                    await controller.createCheckoutSession({
                        body: {
                            type: 'donation',
                            successUrl: 'https://example.com/donate/thanks',
                            cancelUrl: 'https://example.com/donate/cancel',
                            metadata: {}
                        }
                    }, mockRes);

                    sinon.assert.calledWith(getDonationLinkSpy, sinon.match({
                        successUrl: 'https://example.com/donate/thanks',
                        cancelUrl: 'https://example.com/donate/cancel'
                    }));
                });
            });

            describe('gift checkout', function () {
                let giftLinkSpy;

                beforeEach(function () {
                    giftLinkSpy = sinon.stub().resolves('https://checkout.stripe.com/gift');
                    paymentsService.getGiftPaymentLink = giftLinkSpy;
                });

                it('falls back to the site URL when cancelUrl is cross-origin (Stripe requires cancel_url for gifts)', async function () {
                    const controller = createGiftReturnController();

                    await controller.createCheckoutSession({
                        body: {
                            type: 'gift',
                            tierId: 'tier_123',
                            cadence: 'month',
                            cancelUrl: 'https://external.example/cancel',
                            metadata: {}
                        }
                    }, mockRes);

                    sinon.assert.calledOnce(giftLinkSpy);
                    sinon.assert.calledWith(giftLinkSpy, sinon.match({
                        successUrl: 'https://example.com/',
                        cancelUrl: 'https://example.com/'
                    }));
                });

                it('passes same-origin cancelUrl through unchanged', async function () {
                    const controller = createGiftReturnController();

                    await controller.createCheckoutSession({
                        body: {
                            type: 'gift',
                            tierId: 'tier_123',
                            cadence: 'month',
                            cancelUrl: 'https://example.com/post/#/portal/gift',
                            metadata: {}
                        }
                    }, mockRes);

                    sinon.assert.calledOnce(giftLinkSpy);
                    sinon.assert.calledWith(giftLinkSpy, sinon.match({
                        successUrl: 'https://example.com/',
                        cancelUrl: 'https://example.com/post/#/portal/gift'
                    }));
                });
            });
        });

        it('adds welcomePageUrl to response for authenticated members when tier has welcomePageURL', async function () {
            const routerController = new RouterController({
                tiersService: {
                    api: {
                        read: sinon.stub().resolves({
                            id: 'tier_123',
                            welcomePageURL: '/welcome-page/'
                        })
                    }
                },
                paymentsService: {
                    getPaymentLink: sinon.stub().resolves('https://example.com/checkout/')
                },
                tokenService: {
                    decodeToken: sinon.stub().resolves({sub: 'test@example.com'})
                },
                memberRepository: {
                    get: sinon.stub().resolves({
                        email: 'test@example.com',
                        get: sinon.stub().returns('free'),
                        related: sinon.stub().returns({
                            query: sinon.stub().returns({
                                fetch: sinon.stub().resolves([])
                            })
                        })
                    })
                },
                urlUtils: {
                    getSiteUrl: sinon.stub().returns('https://example.com/')
                },
                memberAttributionService: {
                    getAttribution: sinon.stub().resolves({})
                },
                settingsHelpers
            });

            const req = {
                body: {
                    tierId: 'tier_123',
                    cadence: 'month',
                    successUrl: 'https://example.com/success',
                    cancelUrl: 'https://example.com/cancel',
                    identity: 'identity-token',
                    metadata: {}
                }
            };

            const res = {
                writeHead: sinon.stub(),
                end: sinon.stub()
            };

            await routerController.createCheckoutSession(req, res);

            sinon.assert.calledOnce(res.end);
            const responseBody = JSON.parse(res.end.firstCall.args[0]);
            assert.equal(responseBody.welcomePageUrl, '/welcome-page/');
        });

        it('does not add welcomePageUrl to response when tier has no welcomePageURL', async function () {
            const routerController = new RouterController({
                tiersService: {
                    api: {
                        read: sinon.stub().resolves({
                            id: 'tier_123'
                            // No welcomePageURL
                        })
                    }
                },
                paymentsService: {
                    getPaymentLink: sinon.stub().resolves('https://example.com/checkout/')
                },
                tokenService: {
                    decodeToken: sinon.stub().resolves({sub: 'test@example.com'})
                },
                memberRepository: {
                    get: sinon.stub().resolves({
                        email: 'test@example.com',
                        get: sinon.stub().returns('free'),
                        related: sinon.stub().returns({
                            query: sinon.stub().returns({
                                fetch: sinon.stub().resolves([])
                            })
                        })
                    })
                },
                urlUtils: {
                    getSiteUrl: sinon.stub().returns('https://example.com/')
                },
                memberAttributionService: {
                    getAttribution: sinon.stub().resolves({})
                }
            });

            const req = {
                body: {
                    tierId: 'tier_123',
                    cadence: 'month',
                    successUrl: 'https://example.com/success',
                    cancelUrl: 'https://example.com/cancel',
                    identity: 'identity-token',
                    metadata: {}
                }
            };

            const res = {
                writeHead: sinon.stub(),
                end: sinon.stub()
            };

            await routerController.createCheckoutSession(req, res);

            sinon.assert.calledOnce(res.end);
            const responseBody = JSON.parse(res.end.firstCall.args[0]);
            assert.equal(responseBody.welcomePageUrl, undefined);
        });

        afterEach(function () {
            sinon.restore();
        });
    });

    describe('createCheckoutSetupSession return URL validation', function () {
        // The setup session endpoint requires an authenticated identity token, but the
        // successUrl/cancelUrl values still come from the request body. We validate
        // their origin server-side and drop any URL that doesn't point back to the site.

        let createSetupSessionStub;
        let mockReq;
        let mockRes;

        function createController() {
            createSetupSessionStub = sinon.stub().resolves({id: 'cs_test_setup'});
            const memberStub = {
                related: sinon.stub().returns({
                    fetch: sinon.stub().resolves({models: []})
                })
            };
            return new RouterController({
                stripeAPIService: {
                    createCheckoutSetupSession: createSetupSessionStub,
                    getCustomerForMemberCheckoutSession: sinon.stub().resolves({id: 'cus_test'}),
                    getPublicKey: sinon.stub().returns('pk_test')
                },
                tokenService: {decodeToken: sinon.stub().resolves({sub: 'member@example.com'})},
                memberRepository: {get: sinon.stub().resolves(memberStub)},
                urlUtils
            });
        }

        beforeEach(function () {
            mockReq = body => ({body: {identity: 'valid-token', ...body}});
            mockRes = {writeHead: sinon.stub(), end: sinon.stub()};
        });

        it('passes same-origin successUrl and cancelUrl through unchanged', async function () {
            const controller = createController();

            await controller.createCheckoutSetupSession(mockReq({
                successUrl: 'https://example.com/account/billing?ok=1',
                cancelUrl: 'https://example.com/account'
            }), mockRes);

            sinon.assert.calledWith(createSetupSessionStub, sinon.match.any, sinon.match({
                successUrl: 'https://example.com/account/billing?ok=1',
                cancelUrl: 'https://example.com/account'
            }));
        });

        it('drops cross-origin successUrl and cancelUrl', async function () {
            const controller = createController();

            await controller.createCheckoutSetupSession(mockReq({
                successUrl: 'https://external.example/success',
                cancelUrl: 'https://external.example/cancel'
            }), mockRes);

            sinon.assert.calledWith(createSetupSessionStub, sinon.match.any, sinon.match({
                successUrl: undefined,
                cancelUrl: undefined
            }));
        });

        it('passes undefined when URLs are missing', async function () {
            const controller = createController();

            await controller.createCheckoutSetupSession(mockReq({}), mockRes);

            sinon.assert.calledWith(createSetupSessionStub, sinon.match.any, sinon.match({
                successUrl: undefined,
                cancelUrl: undefined
            }));
        });
    });

    describe('createBillingPortalSession return URL validation', function () {
        let createPortalSessionStub;
        let mockReq;
        let mockRes;

        function createController() {
            createPortalSessionStub = sinon.stub().resolves({url: 'https://billing.stripe.com/'});
            const memberStub = {
                related: sinon.stub().returns({
                    fetch: sinon.stub().resolves({models: []})
                })
            };
            return new RouterController({
                stripeAPIService: {
                    createBillingPortalSession: createPortalSessionStub,
                    getCustomerForMemberCheckoutSession: sinon.stub().resolves({id: 'cus_test'})
                },
                tokenService: {decodeToken: sinon.stub().resolves({sub: 'member@example.com'})},
                memberRepository: {get: sinon.stub().resolves(memberStub)},
                settingsCache: {
                    get: sinon.stub().withArgs('stripe_billing_portal_configuration_id').returns(null)
                },
                urlUtils
            });
        }

        beforeEach(function () {
            mockReq = body => ({body: {identity: 'valid-token', ...body}});
            mockRes = {writeHead: sinon.stub(), end: sinon.stub()};
        });

        it('passes same-origin returnUrl through unchanged', async function () {
            const controller = createController();

            await controller.createBillingPortalSession(mockReq({
                returnUrl: 'https://example.com/account'
            }), mockRes);

            sinon.assert.calledWith(createPortalSessionStub, sinon.match.any, sinon.match({
                returnUrl: 'https://example.com/account'
            }));
        });

        it('drops cross-origin returnUrl', async function () {
            const controller = createController();

            await controller.createBillingPortalSession(mockReq({
                returnUrl: 'https://external.example/return'
            }), mockRes);

            sinon.assert.calledWith(createPortalSessionStub, sinon.match.any, sinon.match({
                returnUrl: undefined
            }));
        });

        it('passes undefined when returnUrl is missing', async function () {
            const controller = createController();

            await controller.createBillingPortalSession(mockReq({}), mockRes);

            sinon.assert.calledWith(createPortalSessionStub, sinon.match.any, sinon.match({
                returnUrl: undefined
            }));
        });
    });

    describe('sendMagicLink', function () {
        describe('newsletters', function () {
            let req, res, sendEmailWithMagicLinkStub;

            const createRouterController = (deps = {}) => {
                return new RouterController({
                    allowSelfSignup: sinon.stub().returns(true),
                    memberAttributionService: {
                        getAttribution: sinon.stub().resolves({})
                    },
                    sendEmailWithMagicLink: sendEmailWithMagicLinkStub,
                    settingsCache,
                    settingsHelpers,
                    emailAddressService,
                    ...deps
                });
            };

            beforeEach(function () {
                req = {
                    body: {
                        email: 'jamie@example.com',
                        emailType: 'signup'
                    },
                    get: sinon.stub()
                };
                res = {
                    writeHead: sinon.stub(),

                    end: sinon.stub()
                };
                sendEmailWithMagicLinkStub = sinon.stub().resolves();
            });

            it('adds specified newsletters to the tokenData', async function () {
                const newsletters = [
                    {
                        id: 'abc123',
                        name: 'Newsletter 1',
                        status: 'active'
                    },
                    {
                        id: 'def456',
                        name: 'Newsletter 2',
                        status: 'active'
                    },
                    {
                        id: 'ghi789',
                        name: 'Newsletter 3',
                        status: 'active'
                    }
                ];

                req.body.newsletters = newsletters.map(newsletter => ({name: newsletter.name}));

                const newsletterNames = newsletters.map(newsletter => newsletter.name);
                const newsletterNamesFilter = newsletterNames.map(newsletter => `'${newsletter.replace(/("|')/g, '\\$1')}'`);
                const newslettersServiceStub = {
                    getAll: sinon.stub()
                };

                newslettersServiceStub.getAll
                    .withArgs({
                        filter: `name:[${newsletterNamesFilter}]`,
                        columns: ['id','name','status']
                    })
                    .resolves(newsletters);

                const controller = createRouterController({
                    newslettersService: newslettersServiceStub
                });

                await controller.sendMagicLink(req, res);

                assert.equal(res.writeHead.calledOnceWith(201), true);
                assert.equal(res.end.calledOnceWith('{}'), true);

                sinon.assert.calledOnce(sendEmailWithMagicLinkStub);
                assert.deepEqual(sendEmailWithMagicLinkStub.args[0][0].tokenData.newsletters, [
                    {id: newsletters[0].id},
                    {id: newsletters[1].id},
                    {id: newsletters[2].id}
                ]);
            });

            it('validates specified newsletters', async function () {
                const INVALID_NEWSLETTER_NAME = 'abc123';

                req.body.newsletters = [
                    {name: INVALID_NEWSLETTER_NAME}
                ];

                const newslettersServiceStub = {
                    getAll: sinon.stub()
                };

                newslettersServiceStub.getAll
                    .withArgs({
                        filter: `name:['${INVALID_NEWSLETTER_NAME}']`,
                        columns: ['id','name','status']
                    })
                    .resolves([]);

                const controller = createRouterController({
                    newslettersService: newslettersServiceStub
                });

                await assert.rejects(
                    controller.sendMagicLink(req, res),
                    {message: `Cannot subscribe to invalid newsletters ${INVALID_NEWSLETTER_NAME}`}
                );
            });

            it('validates archived newsletters', async function () {
                const newsletters = [
                    {
                        id: 'abc123',
                        name: 'Newsletter 1',
                        status: 'active'
                    },
                    {
                        id: 'def456',
                        name: 'Newsletter 2',
                        status: 'archived'
                    },
                    {
                        id: 'ghi789',
                        name: 'Newsletter 3',
                        status: 'active'
                    }
                ];

                req.body.newsletters = newsletters.map(newsletter => ({name: newsletter.name}));

                const newsletterNames = newsletters.map(newsletter => `'${newsletter.name}'`);
                const newslettersServiceStub = {
                    getAll: sinon.stub()
                };

                newslettersServiceStub.getAll
                    .withArgs({
                        filter: `name:[${newsletterNames}]`,
                        columns: ['id', 'name','status']
                    })
                    .resolves(newsletters);

                const controller = createRouterController({
                    newslettersService: newslettersServiceStub
                });

                await assert.rejects(
                    controller.sendMagicLink(req, res),
                    {message: `Cannot subscribe to archived newsletters Newsletter 2`}
                );
            });
        });

        describe('gift token forwarding', function () {
            let req, res, sendEmailWithMagicLinkStub, memberRepositoryStub;

            const createRouterController = (deps = {}) => {
                return new RouterController({
                    allowSelfSignup: sinon.stub().returns(true),
                    memberAttributionService: {
                        getAttribution: sinon.stub().resolves({})
                    },
                    sendEmailWithMagicLink: sendEmailWithMagicLinkStub,
                    settingsCache,
                    settingsHelpers,
                    emailAddressService,
                    memberRepository: memberRepositoryStub,
                    ...deps
                });
            };

            beforeEach(function () {
                req = {
                    body: {
                        email: 'jamie@example.com',
                        emailType: 'subscribe',
                        giftToken: 'gift-token-123'
                    },
                    get: sinon.stub()
                };
                res = {
                    writeHead: sinon.stub(),
                    end: sinon.stub()
                };
                sendEmailWithMagicLinkStub = sinon.stub().resolves({});
                memberRepositoryStub = {
                    get: sinon.stub().resolves({
                        id: 'member_1'
                    })
                };
            });

            it('forwards giftToken for signup and subscribe flows', async function () {
                const controller = createRouterController();

                await controller.sendMagicLink(req, res);

                sinon.assert.calledOnce(sendEmailWithMagicLinkStub);
                assert.equal(sendEmailWithMagicLinkStub.firstCall.args[0].tokenData.giftToken, 'gift-token-123');
            });

            it('forwards giftToken for signin flows', async function () {
                req.body.emailType = 'signin';
                const controller = createRouterController();

                await controller.sendMagicLink(req, res);

                sinon.assert.calledOnce(sendEmailWithMagicLinkStub);
                assert.equal(sendEmailWithMagicLinkStub.firstCall.args[0].tokenData.giftToken, 'gift-token-123');
            });
        });

        describe('honeypot', function () {
            let req, res, sendEmailWithMagicLinkStub;

            const createRouterController = (deps = {}) => {
                return new RouterController({
                    allowSelfSignup: sinon.stub().returns(true),
                    memberAttributionService: {
                        getAttribution: sinon.stub().resolves({})
                    },
                    sendEmailWithMagicLink: sendEmailWithMagicLinkStub,
                    settingsCache,
                    settingsHelpers,
                    emailAddressService,
                    ...deps
                });
            };

            beforeEach(function () {
                req = {
                    body: {
                        email: 'jamie@example.com',
                        emailType: 'signup'
                    },
                    get: sinon.stub()
                };
                res = {
                    writeHead: sinon.stub(),
                    end: sinon.stub()
                };
                sendEmailWithMagicLinkStub = sinon.stub().resolves();
            });

            it('Sends emails when honeypot is not filled', async function () {
                const controller = createRouterController();

                await controller.sendMagicLink(req, res);
                sinon.assert.calledOnce(sendEmailWithMagicLinkStub);
            });

            it('Does not send emails when honeypot is filled', async function () {
                const controller = createRouterController();

                req.body.honeypot = 'filled!';

                await controller.sendMagicLink(req, res);
                assert.equal(sendEmailWithMagicLinkStub.notCalled, true);
            });
        });

        describe('OTC response handling', function () {
            let req, res, handleSigninStub, routerController;

            beforeEach(function () {
                req = {
                    body: {
                        email: 'test@example.com',
                        emailType: 'signin'
                    },
                    get: sinon.stub()
                };
                res = {
                    writeHead: sinon.stub(),
                    end: sinon.stub()
                };
                handleSigninStub = sinon.stub();

                routerController = new RouterController({
                    allowSelfSignup: sinon.stub().returns(true),
                    memberAttributionService: {
                        getAttribution: sinon.stub().resolves({})
                    },
                    labsService: {
                        isSet: sinon.stub().returns(false)
                    },
                    settingsCache: {
                        get: sinon.stub().returns([])
                    },
                    offersAPI: {},
                    paymentsService: {},
                    memberRepository: {},
                    StripePrice: {},
                    magicLinkService: {},
                    stripeAPIService: {},
                    tokenService: {},
                    sendEmailWithMagicLink: sinon.stub(),
                    settingsHelpers,
                    newslettersService: {},
                    sentry: {},
                    urlUtils: {},
                    emailAddressService
                });

                routerController._handleSignin = handleSigninStub;
            });

            it('should return otc_ref when otcRef exists', async function () {
                handleSigninStub.resolves({otcRef: 'test-token-123'});

                await routerController.sendMagicLink(req, res);

                sinon.assert.calledWith(res.writeHead, 201, {'Content-Type': 'application/json'});
                sinon.assert.calledWith(res.end, JSON.stringify({otc_ref: 'test-token-123'}));
            });

            it('should not return otc_ref when no otcRef', async function () {
                handleSigninStub.resolves({otcRef: null});

                await routerController.sendMagicLink(req, res);

                sinon.assert.calledWith(res.writeHead, 201);
                sinon.assert.calledWith(res.end, '{}');
            });

            it('should not return otc_ref when otcRef is undefined', async function () {
                handleSigninStub.resolves({});

                await routerController.sendMagicLink(req, res);

                sinon.assert.calledWith(res.writeHead, 201);
                sinon.assert.calledWith(res.end, '{}');
            });
        });
    });

    describe('_generateSuccessUrl', function () {
        let urlUtilsStub;

        beforeEach(function () {
            urlUtilsStub = {
                getSiteUrl: sinon.stub().returns('https://example.com/')
            };
        });

        it('returns original success URL when welcomePageURL is not set', function () {
            const routerController = new RouterController({
                urlUtils: urlUtilsStub
            });

            const originalUrl = 'https://example.com/success';
            const result = routerController._generateSuccessUrl(originalUrl, null);

            assert.equal(result, originalUrl);
        });

        it('returns welcome page URL with success parameters when welcomePageURL is set', function () {
            const routerController = new RouterController({
                urlUtils: urlUtilsStub
            });

            const originalUrl = 'https://example.com/success';
            const welcomePageURL = '/welcome-paid-members/';
            const result = routerController._generateSuccessUrl(originalUrl, welcomePageURL);

            assert.equal(result, 'https://example.com/welcome-paid-members/?success=true&action=signup');
        });

        it('handles absolute URLs in welcomePageURL', function () {
            const routerController = new RouterController({
                urlUtils: urlUtilsStub
            });

            const originalUrl = 'https://example.com/success';
            const welcomePageURL = 'https://external-site.com/welcome';
            const result = routerController._generateSuccessUrl(originalUrl, welcomePageURL);

            assert.equal(result, 'https://external-site.com/welcome?success=true&action=signup');
        });

        it('returns original URL if welcomePageURL is invalid', function () {
            const routerController = new RouterController({
                urlUtils: urlUtilsStub
            });

            const originalUrl = 'https://example.com/success';
            // Using a URL that would throw an error when trying to create a URL object
            const welcomePageURL = 'http://invalid-url:-with-bad-port';
            const result = routerController._generateSuccessUrl(originalUrl, welcomePageURL);

            assert.equal(result, originalUrl);
        });
    });

    describe('_validateNewsletters', function () {
        let newslettersServiceStub;
        let routerController;
        beforeEach(function () {
            newslettersServiceStub = {
                getAll: sinon.stub()
            };
            newslettersServiceStub.getAll.resolves([
                {id: 'abc123', name: 'Newsletter 1', status: 'active'},
                {id: 'def456', name: 'Newsletter 2', status: 'active'},
                {id: 'ghi789', name: 'Newsletter 3', status: 'active'}
            ]);
            routerController = new RouterController({
                tiersService,
                paymentsService,
                offersAPI,
                stripeAPIService,
                labsService,
                settingsCache,
                newslettersService: newslettersServiceStub,
                emailAddressService
            });
        });

        it('validates newsletters', async function () {
            const requestedNewsletters = [
                {name: 'Newsletter 1'},
                {name: 'Newsletter 2'},
                {name: 'Newsletter 3'}
            ];
            const result = await routerController._validateNewsletters(requestedNewsletters);
            assert.deepEqual(result, [
                {id: 'abc123'},
                {id: 'def456'},
                {id: 'ghi789'}
            ]);
        });

        it('returns undefined if newsletters is an empty array', async function () {
            const requestedNewsletters = [];
            const result = await routerController._validateNewsletters(requestedNewsletters);
            assert.equal(result, undefined);
        });

        it('returns undefined if newsletters is undefined', async function () {
            const requestedNewsletters = undefined;
            const result = await routerController._validateNewsletters(requestedNewsletters);
            assert.equal(result, undefined);
        });

        it('returns undefined if any newsletter is missing a name', async function () {
            const requestedNewsletters = [
                {name: 'Newsletter 1'},
                {id: 'def456'}
            ];
            const result = await routerController._validateNewsletters(requestedNewsletters);
            assert.equal(result, undefined);
        });

        it('throws an error if an invalid newsletter is provided', async function () {
            const requestedNewsletters = [
                {name: 'Newsletter 1'},
                {name: 'Newsletter 2'},
                {name: 'Newsletter 3'},
                {name: 'fake newsletter'}
            ];
            try {
                await routerController._validateNewsletters(requestedNewsletters);
                assert.fail('Expected function to throw BadRequestError');
            } catch (error) {
                assert(error instanceof errors.BadRequestError, 'Error should be an instance of BadRequestError');
                assert.equal(error.message, 'Cannot subscribe to invalid newsletters fake newsletter');
            }
        });

        it('throws an error if an archived newsletter is provided', async function () {
            newslettersServiceStub.getAll.resolves([
                {id: 'abc123', name: 'Newsletter 1', status: 'active'},
                {id: 'def456', name: 'Newsletter 2', status: 'archived'},
                {id: 'ghi789', name: 'Newsletter 3', status: 'active'}
            ]);

            const requestedNewsletters = [
                {name: 'Newsletter 1'},
                {name: 'Newsletter 2'},
                {name: 'Newsletter 3'}
            ];

            try {
                await routerController._validateNewsletters(requestedNewsletters);
                assert.fail('Expected function to throw BadRequestError');
            } catch (error) {
                assert(error instanceof errors.BadRequestError, 'Error should be an instance of BadRequestError');
                assert.equal(error.message, 'Cannot subscribe to archived newsletters Newsletter 2');
            }
        });
    });

    describe('verifyOTC', function () {
        let routerController;
        let mockMagicLinkService;
        let mockUrlUtils;
        let req;
        let res;

        const OTC_TEST_CONSTANTS = {
            VALID_OTC: '123456',
            INVALID_OTC_5_DIGITS: '12345',
            INVALID_OTC_NON_DIGITS: '12345a',
            TOKEN_ID: 'test-token-id',
            TOKEN_VALUE: 'test-token-value',
            OTC_VERIFICATION_HASH: 'c3784e7545cd61c87b34b9bd6d7b840c1225679c74fbc04bc07302a7a1c6aed4',
            SITE_URL: 'http://example.com',
            MEMBERS_URL: 'http://example.com/members/'
        };

        beforeEach(function () {
            mockMagicLinkService = {
                tokenProvider: {
                    verifyOTC: sinon.stub(),
                    getTokenByRef: sinon.stub(),
                    createOTCVerificationHash: sinon.stub().returns(OTC_TEST_CONSTANTS.OTC_VERIFICATION_HASH)
                },
                getSigninURL: sinon.stub().returns(OTC_TEST_CONSTANTS.MEMBERS_URL)
            };

            mockUrlUtils = {
                urlFor: sinon.stub().returns(OTC_TEST_CONSTANTS.MEMBERS_URL),
                getSiteUrl: sinon.stub().returns(OTC_TEST_CONSTANTS.SITE_URL)
            };

            routerController = new RouterController({
                offersAPI,
                paymentsService,
                tiersService,
                memberRepository: {},
                StripePrice: {},
                allowSelfSignup: () => true,
                magicLinkService: mockMagicLinkService,
                stripeAPIService,
                tokenService: {},
                memberAttributionService: {},
                sendEmailWithMagicLink: sinon.stub(),
                labsService,
                newslettersService: {},
                sentry: undefined,
                urlUtils: mockUrlUtils
            });

            req = {
                body: {
                    otc: OTC_TEST_CONSTANTS.VALID_OTC,
                    otcRef: OTC_TEST_CONSTANTS.TOKEN_ID
                },
                get: sinon.stub()
            };

            res = {
                json: sinon.stub()
            };
        });

        describe('input validation', function () {
            it('should throw BadRequestError when otc is missing', async function () {
                req.body.otc = undefined;

                await assert.rejects(
                    routerController.verifyOTC(req, res),
                    {code: 'OTC_VERIFICATION_MISSING_PARAMS'}
                );
            });

            it('should throw BadRequestError when otcRef is missing', async function () {
                req.body.otcRef = undefined;

                await assert.rejects(
                    routerController.verifyOTC(req, res),
                    {code: 'OTC_VERIFICATION_MISSING_PARAMS'}
                );
            });

            it('should throw BadRequestError for invalid otc formats', async function () {
                const invalidOtcs = [
                    OTC_TEST_CONSTANTS.INVALID_OTC_5_DIGITS,
                    OTC_TEST_CONSTANTS.INVALID_OTC_NON_DIGITS
                ];

                for (const invalid of invalidOtcs) {
                    req.body.otc = invalid;
                    mockMagicLinkService.tokenProvider.getTokenByRef.resolves(OTC_TEST_CONSTANTS.TOKEN_VALUE);
                    mockMagicLinkService.tokenProvider.verifyOTC.resolves(false);

                    await assert.rejects(
                        routerController.verifyOTC(req, res),
                        {code: 'INVALID_OTC'}
                    );
                }
            });

            it('should accept valid 6-digit OTC', async function () {
                req.body.otc = OTC_TEST_CONSTANTS.VALID_OTC;

                mockMagicLinkService.tokenProvider.verifyOTC.resolves(true);
                mockMagicLinkService.tokenProvider.getTokenByRef.resolves(OTC_TEST_CONSTANTS.TOKEN_VALUE);

                await routerController.verifyOTC(req, res);

                sinon.assert.calledOnce(res.json);
                const responseData = res.json.firstCall.args[0];
                assert(responseData.redirectUrl);
            });
        });

        describe('token provider integration', function () {
            beforeEach(function () {
                req.body.otc = OTC_TEST_CONSTANTS.VALID_OTC;
                req.body.otcRef = OTC_TEST_CONSTANTS.TOKEN_ID;
            });

            it('should throw BadRequestError when tokenProvider is missing', async function () {
                mockMagicLinkService.tokenProvider = undefined;

                await assert.rejects(
                    routerController.verifyOTC(req, res),
                    {code: 'OTC_NOT_SUPPORTED'}
                );
            });

            it('should throw BadRequestError when verifyOTC method is missing', async function () {
                mockMagicLinkService.tokenProvider.verifyOTC = undefined;

                await assert.rejects(
                    routerController.verifyOTC(req, res),
                    {code: 'OTC_NOT_SUPPORTED'}
                );
            });

            it('should throw BadRequestError when verifyOTC returns false', async function () {
                mockMagicLinkService.tokenProvider.getTokenByRef.resolves(OTC_TEST_CONSTANTS.TOKEN_VALUE);
                mockMagicLinkService.tokenProvider.verifyOTC.resolves(false);

                await assert.rejects(
                    routerController.verifyOTC(req, res),
                    {code: 'INVALID_OTC'}
                );
            });

            it('should throw BadRequestError when getTokenByRef returns null', async function () {
                mockMagicLinkService.tokenProvider.verifyOTC.resolves(true);
                mockMagicLinkService.tokenProvider.getTokenByRef.resolves(null);

                await assert.rejects(
                    routerController.verifyOTC(req, res),
                    {code: 'INVALID_OTC'}
                );
            });

            it('should handle tokenProvider.getTokenByRef throwing error', async function () {
                mockMagicLinkService.tokenProvider.verifyOTC.resolves(true);
                mockMagicLinkService.tokenProvider.getTokenByRef.rejects(new Error('Database error'));

                await assert.rejects(
                    routerController.verifyOTC(req, res),
                    Error
                );
            });

            it('should throw BadRequestError when getSigninURL returns null', async function () {
                mockMagicLinkService.tokenProvider.verifyOTC.resolves(true);
                mockMagicLinkService.tokenProvider.getTokenByRef.resolves(OTC_TEST_CONSTANTS.TOKEN_VALUE);
                mockMagicLinkService.getSigninURL.returns(null);

                await assert.rejects(
                    routerController.verifyOTC(req, res),
                    {code: 'OTC_VERIFICATION_FAILED'}
                );
            });
        });

        describe('success flow integration', function () {
            beforeEach(function () {
                req.body.otc = OTC_TEST_CONSTANTS.VALID_OTC;
                req.body.otcRef = OTC_TEST_CONSTANTS.TOKEN_ID;
                mockMagicLinkService.tokenProvider.verifyOTC.resolves(true);
                mockMagicLinkService.tokenProvider.getTokenByRef.resolves(OTC_TEST_CONSTANTS.TOKEN_VALUE);
            });

            it('should return success response with valid inputs', async function () {
                // use a fake time so we get a stable OTC verification hash for our mocked token/secret
                sinon.useFakeTimers(new Date('2021-01-01'));
                const TIMESTAMP_AND_OTC_VERIFICATION_HASH = `1609459200:${OTC_TEST_CONSTANTS.OTC_VERIFICATION_HASH}`;

                await routerController.verifyOTC(req, res);

                sinon.assert.calledOnce(res.json);
                const responseData = res.json.firstCall.args[0];

                assert(responseData.redirectUrl);
                assert.equal(responseData.redirectUrl, OTC_TEST_CONSTANTS.MEMBERS_URL);

                sinon.assert.calledWith(
                    mockMagicLinkService.getSigninURL,
                    OTC_TEST_CONSTANTS.TOKEN_VALUE,
                    'signin',
                    null,
                    TIMESTAMP_AND_OTC_VERIFICATION_HASH
                );
            });
        });

        describe('referer passthrough', function () {
            beforeEach(function () {
                req.body.otc = OTC_TEST_CONSTANTS.VALID_OTC;
                req.body.otcRef = OTC_TEST_CONSTANTS.TOKEN_ID;
                mockMagicLinkService.tokenProvider.verifyOTC.resolves(true);
                mockMagicLinkService.tokenProvider.getTokenByRef.resolves(OTC_TEST_CONSTANTS.TOKEN_VALUE);
                req.get = sinon.stub();
                sinon.useFakeTimers(new Date('2021-01-01'));
            });

            async function assertReferrerPassedToGetSigninURL(expectedReferrer) {
                await routerController.verifyOTC(req, res);
                const referrerArg = mockMagicLinkService.getSigninURL.getCall(0).args[2];
                assert.equal(referrerArg, expectedReferrer);
            }

            it('should pass referer header', async function () {
                req.get.withArgs('referer').returns('https://example.com/page');
                await assertReferrerPassedToGetSigninURL('https://example.com/page');
            });

            it('should prioritize redirect body parameter', async function () {
                req.body.redirect = 'https://example.com/custom-redirect';
                req.get.withArgs('referer').returns('https://example.com/other-page');
                await assertReferrerPassedToGetSigninURL('https://example.com/custom-redirect');
            });

            it('should pass null when autoRedirect is false', async function () {
                req.body.autoRedirect = false;
                req.get.withArgs('referer').returns('https://example.com/page');
                await assertReferrerPassedToGetSigninURL(null);
            });

            it('should fallback to referer when redirect URL is invalid', async function () {
                req.body.redirect = 'invalid-url';
                req.get.withArgs('referer').returns('https://example.com/page');
                await assertReferrerPassedToGetSigninURL('https://example.com/page');
            });

            it('should pass null when no referer or redirect', async function () {
                req.get.withArgs('referer').returns(undefined);
                await assertReferrerPassedToGetSigninURL(null);
            });

            it('should pass null when autoRedirect false overrides redirect', async function () {
                req.body.autoRedirect = false;
                req.body.redirect = 'https://example.com/custom-redirect';
                req.get.withArgs('referer').returns('https://example.com/page');
                await assertReferrerPassedToGetSigninURL(null);
            });
        });
    });

    describe('getMemberOffers', function () {
        let mockOffersAPI;
        let tokenService;
        let mockStripePrice;
        let res;
        let responseData;

        function createMockSubscription({
            id = 'sub_123',
            status = 'active',
            offerId = null,
            trialEndAt = null,
            discountStart = null,
            discountEnd = null,
            startDate = null,
            currentPeriodEnd = new Date('2025-06-01T00:00:00.000Z'),
            cancelAtPeriodEnd = false
        } = {}) {
            return {
                id,
                get: sinon.stub().callsFake((key) => {
                    const values = {
                        status,
                        offer_id: offerId,
                        trial_end_at: trialEndAt,
                        discount_start: discountStart,
                        discount_end: discountEnd,
                        start_date: startDate,
                        current_period_end: currentPeriodEnd,
                        cancel_at_period_end: cancelAtPeriodEnd
                    };
                    return values[key] ?? null;
                }),
                related: sinon.stub().withArgs('stripePrice').returns(mockStripePrice)
            };
        }

        function createMockMember(subscriptions) {
            return {
                get: sinon.stub().returns('paid'),
                related: sinon.stub().withArgs('stripeSubscriptions').returns({
                    models: Array.isArray(subscriptions) ? subscriptions : [subscriptions]
                })
            };
        }

        function createRouterController(overrides = {}) {
            return new RouterController({
                offersAPI: mockOffersAPI,
                memberRepository: {
                    get: sinon.stub().resolves(createMockMember(overrides.subscriptions || createMockSubscription()))
                },
                tokenService,
                stripeAPIService: {configured: true},
                ...overrides
            });
        }

        beforeEach(function () {
            mockOffersAPI = {
                listOffersAvailableToSubscription: sinon.stub().resolves([]),
                getOffer: sinon.stub().resolves(null)
            };

            tokenService = {
                decodeToken: sinon.stub().resolves({sub: 'test@example.com'})
            };

            const mockProduct = {
                id: 'product_123'
            };

            const mockStripeProduct = {
                id: 'stripe_product_123',
                related: sinon.stub().withArgs('product').returns(mockProduct)
            };

            mockStripePrice = {
                id: 'price_123',
                get: sinon.stub().withArgs('interval').returns('month'),
                related: sinon.stub().withArgs('stripeProduct').returns(mockStripeProduct)
            };

            responseData = null;
            res = {
                writeHead: sinon.stub(),
                end: sinon.stub().callsFake((data) => {
                    responseData = JSON.parse(data);
                })
            };
        });

        it('returns empty offers when offersAPI throws an error', async function () {
            const offersAPIWithError = {
                listOffersAvailableToSubscription: sinon.stub().rejects(new Error('Database error'))
            };

            const routerController = createRouterController({
                offersAPI: offersAPIWithError
            });

            await routerController.getMemberOffers({
                body: {identity: 'valid-token'}
            }, res);

            assert.deepEqual(responseData, {offers: []});
            sinon.assert.calledOnce(offersAPIWithError.listOffersAvailableToSubscription);
        });

        it('returns empty offers when subscription has an active discount', async function () {
            mockOffersAPI.getOffer.resolves({id: 'existing_offer_123', duration: 'forever'});

            const routerController = createRouterController({
                subscriptions: createMockSubscription({
                    offerId: 'existing_offer_123',
                    discountStart: new Date('2026-01-01')
                    // discountEnd: null means forever
                })
            });

            await routerController.getMemberOffers({
                body: {identity: 'valid-token'}
            }, res);

            assert.deepEqual(responseData, {offers: []});
            sinon.assert.notCalled(mockOffersAPI.listOffersAvailableToSubscription);
        });

        it('returns offers when subscription has an expired discount', async function () {
            const mockOffer = {id: 'retention_offer'};
            mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]);
            mockOffersAPI.getOffer.resolves({id: 'expired_offer_123', duration: 'once'});

            const pastDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000);
            const routerController = createRouterController({
                subscriptions: createMockSubscription({
                    offerId: 'expired_offer_123',
                    discountStart: new Date('2025-01-01'),
                    discountEnd: pastDate
                })
            });

            await routerController.getMemberOffers({
                body: {identity: 'valid-token'}
            }, res);

            assert.deepEqual(responseData, {offers: [mockOffer]});
            sinon.assert.calledOnce(mockOffersAPI.listOffersAvailableToSubscription);
        });

        it('returns offers when a one-month repeating signup offer no longer applies to the next payment', async function () {
            const mockOffer = {id: 'retention_offer'};
            mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]);
            mockOffersAPI.getOffer.resolves({
                id: 'expiring_repeating_offer',
                duration: 'repeating',
                duration_in_months: 1
            });

            const routerController = createRouterController({
                subscriptions: createMockSubscription({
                    offerId: 'expiring_repeating_offer',
                    startDate: new Date('2025-05-01T00:00:00.000Z'),
                    discountStart: new Date('2025-05-01T00:00:00.000Z'),
                    discountEnd: new Date('2025-06-01T00:00:00.000Z'),
                    currentPeriodEnd: new Date('2025-06-01T00:00:00.000Z')
                })
            });

            await routerController.getMemberOffers({
                body: {identity: 'valid-token'}
            }, res);

            assert.deepEqual(responseData, {offers: [mockOffer]});
            sinon.assert.calledOnce(mockOffersAPI.listOffersAvailableToSubscription);
        });

        it('returns offers when subscription has expired once offer (legacy data, no discount_start)', async function () {
            const mockOffer = {id: 'retention_offer'};
            mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]);
            mockOffersAPI.getOffer.resolves({duration: 'once'});

            const routerController = createRouterController({
                subscriptions: createMockSubscription({
                    offerId: 'expired_once_offer'
                    // No discountStart — legacy data
                })
            });

            await routerController.getMemberOffers({
                body: {identity: 'valid-token'}
            }, res);

            assert.deepEqual(responseData, {offers: [mockOffer]});
            sinon.assert.calledOnce(mockOffersAPI.listOffersAvailableToSubscription);
        });

        it('returns empty offers when member has multiple active subscriptions', async function () {
            const routerController = createRouterController({
                subscriptions: [
                    createMockSubscription({id: 'sub_123'}),
                    createMockSubscription({id: 'sub_456'})
                ]
            });

            await routerController.getMemberOffers({
                body: {identity: 'valid-token'}
            }, res);

            assert.deepEqual(responseData, {offers: []});
            sinon.assert.notCalled(mockOffersAPI.listOffersAvailableToSubscription);
        });

        it('returns empty offers when subscription has an active trial period', async function () {
            const futureDate = new Date();
            futureDate.setDate(futureDate.getDate() + 7);

            const routerController = createRouterController({
                subscriptions: createMockSubscription({trialEndAt: futureDate})
            });

            await routerController.getMemberOffers({
                body: {identity: 'valid-token'}
            }, res);

            assert.deepEqual(responseData, {offers: []});
            sinon.assert.notCalled(mockOffersAPI.listOffersAvailableToSubscription);
        });

        it('returns offers when subscription trial has ended', async function () {
            const mockOffer = {id: 'offer_123'};
            mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]);

            const pastDate = new Date();
            pastDate.setDate(pastDate.getDate() - 7);

            const routerController = createRouterController({
                subscriptions: createMockSubscription({trialEndAt: pastDate})
            });

            await routerController.getMemberOffers({
                body: {identity: 'valid-token'}
            }, res);

            assert.deepEqual(responseData, {offers: [mockOffer]});
            sinon.assert.calledOnce(mockOffersAPI.listOffersAvailableToSubscription);
        });

        it('returns offers when subscription has no trial period', async function () {
            const mockOffer = {id: 'offer_123'};
            mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]);

            const routerController = createRouterController({
                subscriptions: createMockSubscription({trialEndAt: null})
            });

            await routerController.getMemberOffers({
                body: {identity: 'valid-token'}
            }, res);

            assert.deepEqual(responseData, {offers: [mockOffer]});
            sinon.assert.calledOnce(mockOffersAPI.listOffersAvailableToSubscription);
        });
    });
});
