diff --git a/apps/portal/package.json b/apps/portal/package.json index 1e64028dff4..df1783783ca 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.68.31", + "version": "2.68.32", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", diff --git a/apps/portal/src/components/pages/gift-page.js b/apps/portal/src/components/pages/gift-page.js index 23bc3c45b72..ff4c1642882 100644 --- a/apps/portal/src/components/pages/gift-page.js +++ b/apps/portal/src/components/pages/gift-page.js @@ -12,24 +12,35 @@ import useCardTilt from '../../utils/use-card-tilt'; /* eslint-disable i18next/no-literal-string */ export const GiftPageStyles = ` +@property --shine-angle { + syntax: ''; + inherits: false; + initial-value: 243.43deg; +} + .gh-portal-popup-container.full-size.gift, .gh-portal-popup-container.full-size.giftSuccess, .gh-portal-popup-container.full-size.giftRedemption { padding: 0; } -/* Close icon sits over the brand-coloured right panel, so override the - default grey from Frame.styles.js to a translucent white. */ +.gh-portal-popup-container.full-size.gift .gh-portal-closeicon-container, +.gh-portal-popup-container.full-size.giftSuccess .gh-portal-closeicon-container, +.gh-portal-popup-container.full-size.giftRedemption .gh-portal-closeicon-container { + top: 32px; + right: 32px; +} + .gh-portal-popup-container.full-size.gift .gh-portal-closeicon, .gh-portal-popup-container.full-size.giftSuccess .gh-portal-closeicon, .gh-portal-popup-container.full-size.giftRedemption .gh-portal-closeicon { - color: rgba(255, 255, 255, 0.7); + color: rgba(255, 255, 255, 0.5); } .gh-portal-popup-container.full-size.gift .gh-portal-closeicon:hover, .gh-portal-popup-container.full-size.giftSuccess .gh-portal-closeicon:hover, .gh-portal-popup-container.full-size.giftRedemption .gh-portal-closeicon:hover { - color: rgba(255, 255, 255, 0.95); + color: rgba(255, 255, 255, 0.8); } .gh-portal-content.gift, @@ -56,11 +67,8 @@ export const GiftPageStyles = ` padding: 64px 48px 128px; } -/* On the selection page only, the inner content's vertical position is - locked once on first paint by useLayoutEffect (so tier switches push the - CTA down rather than re-centering). flex-start lets that JS-applied - margin-top do the actual centering math. Other gift pages keep the - default flex centering. */ +/* Selection page only: useLayoutEffect locks the inner's vertical + position; flex-start lets that JS-applied margin-top do the centering. */ .gh-portal-content.gift .gh-portal-gift-checkout-left { align-items: flex-start; } @@ -117,10 +125,6 @@ export const GiftPageStyles = ` gap: 8px; } -/* Each tier renders as an accordion item: the radio button row sits at top, - and the benefits accordion expands below when selected. The border, radius - and selected-state styling live on the wrapper so the benefits feel like - they belong to the same card. */ .gh-portal-gift-checkout-tier-item { background: var(--white); border: 1px solid var(--grey11); @@ -193,15 +197,12 @@ export const GiftPageStyles = ` color: var(--grey0); } -/* Accordion wrapper inside each tier — collapses and expands its benefit - list using the grid-template-rows trick, matching the 0.3s ease used by - the right-side details toggle. */ .gh-portal-gift-checkout-tier-benefits { display: grid; grid-template-rows: 0fr; transition: grid-template-rows 0.3s ease; - /* Clip the grid item: even with min-height: 0, the inner's padding still - renders ~20px tall, which Chrome leaks the first benefit through. */ + /* overflow + padding-free inner: Chrome leaks the first benefit through + a 0fr track if the grid item has padding (min-size includes padding). */ overflow: hidden; } @@ -210,11 +211,6 @@ export const GiftPageStyles = ` } .gh-portal-gift-checkout-tier-benefits-inner { - /* min-height: 0 overrides the default min-height: auto on grid items. - Padding on this element contributes to the grid track's min-size - (Chrome resolves 0fr to the inner padding-bottom otherwise), so we - keep the inner padding-free and put the spacing on the benefits - list instead. */ min-height: 0; overflow: hidden; } @@ -246,13 +242,11 @@ export const GiftPageStyles = ` flex-shrink: 0; } -/* Inside the brand-coloured right panel, lighten benefit text + checkmark - so they read against the saturated background. The checkmark SVG uses a - hard-coded stroke so we override it explicitly rather than via color. */ .gh-portal-gift-checkout-right-panel .gh-portal-gift-checkout-benefit { color: rgba(255, 255, 255, 0.85); } +/* Checkmark SVG hard-codes its stroke so it can't be themed via color. */ .gh-portal-gift-checkout-right-panel .gh-portal-gift-checkout-benefit svg path { stroke: rgba(255, 255, 255, 0.85); } @@ -261,11 +255,6 @@ export const GiftPageStyles = ` border-radius: 999px; } -/* Sticky wrapper around the CTA: keeps the button visible at the bottom of - the viewport when the left column is long enough to scroll, and adds a - white→transparent fade at the top so scrolling content tucks under it - instead of cutting against the button edge. - Same pattern as .gh-portal-btn-container.sticky in frame.styles.js. */ .gh-portal-gift-checkout-cta-wrapper { position: sticky; bottom: 0; @@ -292,17 +281,16 @@ export const GiftPageStyles = ` overflow-y: auto; } -/* Inner brand-coloured panel that holds the card. Inset 12px from top, right - and bottom of the right column (no left inset — flush with the column - boundary), with 32px rounded corners. */ .gh-portal-gift-checkout-right-panel { flex: 1; display: flex; align-items: center; justify-content: center; - background: var(--brandcolor); + background: + linear-gradient(180deg, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0) 100%), + var(--brandcolor); border-radius: 32px; - padding: 64px 48px 128px; + padding: 64px 48px 64px; } .gh-portal-gift-checkout-card-stack { @@ -310,12 +298,9 @@ export const GiftPageStyles = ` flex-direction: column; align-items: center; width: 100%; - max-width: 440px; + max-width: 280px; } -/* Wraps the card so we can tilt it forward when "Gift details" is expanded, - making the benefits feel like they're sliding out from behind the card. - Composes cleanly with the cursor-driven tilt applied to the card itself. */ .gh-portal-gift-checkout-card-frame { width: 100%; transform-style: preserve-3d; @@ -374,77 +359,111 @@ export const GiftPageStyles = ` overflow: hidden; } -/* Card is split into two zones via a hard-stop gradient: the brand-coloured - top (~70%) holds the duration + tier in white text, the white bottom (~30%) - holds the site icon + name. flex-direction: column-reverse so the existing - site → meta DOM order maps to bottom → top visually. */ .gh-portal-gift-checkout-card { position: relative; width: 100%; - max-width: 440px; - aspect-ratio: 1.7 / 1; - background: linear-gradient(to bottom, color-mix(in srgb, var(--brandcolor) 70%, var(--white)) 75%, var(--white) 75%); + max-width: 280px; + aspect-ratio: 1 / 1.45; + background: + linear-gradient(var(--shine-angle, 243.43deg), rgba(255, 255, 255, 0) 3.94%, rgba(255, 255, 255, 0.31) 49.99%, rgba(255, 255, 255, 0) 95.16%), + linear-gradient(0deg, rgba(255, 255, 255, 0.07), rgba(255, 255, 255, 0.07)), + var(--brandcolor); border-radius: 24px; - padding: 28px 28px 20px; box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4), 0 24px 48px rgba(var(--blackrgb), 0.08), 0 4px 12px rgba(var(--blackrgb), 0.04); display: flex; - flex-direction: column-reverse; - justify-content: space-between; + flex-direction: column; overflow: hidden; transform-style: preserve-3d; will-change: transform; } -/* Soft orb glow layer on the brand surface. Sits behind the content - (z-index 0) and is clipped to the brand area only (bottom: 25%) so it - doesn't bleed into the white strip. */ .gh-portal-gift-checkout-card::after { content: ''; position: absolute; top: 0; left: 0; right: 0; - bottom: 25%; + bottom: 0; background-image: url("${giftCardOrbUrl}"); - background-size: 100% auto; - background-position: 100px 100%; + background-size: 120% auto; + background-position: -60% -180%; background-repeat: no-repeat; pointer-events: none; z-index: 0; - opacity: 0.4; + opacity: 0.2; } -/* Make sure the card content paints above the orb glow. */ .gh-portal-gift-checkout-card > * { position: relative; z-index: 1; } -/* Lanyard slot — small semi-transparent pill at the top center of the card, - so the brand-coloured top reads as an ID-badge surface. */ .gh-portal-gift-checkout-card::before { content: ''; position: absolute; - top: 16px; + top: 20px; left: 50%; width: 56px; - height: 8px; - border-radius: 4px; - background: rgba(255, 255, 255, 0.25); + height: 12px; + border-radius: 12px; + background: rgba(0, 0, 0, 0.35); + box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.4), 0 1px 0 rgba(255, 255, 255, 0.18); transform: translateX(-50%); z-index: 2; } +.gh-portal-gift-checkout-card-meta { + padding: 56px 28px 0; +} + +.gh-portal-gift-checkout-card-duration { + font-size: 2.8rem; + font-weight: 600; + color: var(--white); + letter-spacing: -0.01em; + line-height: 1.1; +} + +.gh-portal-gift-checkout-card-tier { + margin-top: 6px; + font-size: 1.5rem; + color: var(--white); + line-height: 1.3; +} + +.gh-portal-gift-checkout-card-details { + margin-top: auto; + padding: 0 28px 24px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.gh-portal-gift-checkout-card-detail-label { + font-size: 1.2rem; + color: rgba(255, 255, 255, 0.8); + margin-bottom: -5px; +} + +.gh-portal-gift-checkout-card-detail-value { + font-size: 1.3rem; + font-weight: 500; + color: var(--white); +} + .gh-portal-gift-checkout-card-site { + background: var(--white); + padding: 16px 28px; display: flex; align-items: center; + justify-content: center; gap: 8px; } .gh-portal-gift-checkout-card-site-icon { - width: 24px; - height: 24px; + width: 22px; + height: 22px; object-fit: cover; } @@ -455,21 +474,6 @@ export const GiftPageStyles = ` color: var(--grey0); } -.gh-portal-gift-checkout-card-duration { - font-size: 2.6rem; - font-weight: 600; - color: var(--white); - letter-spacing: -0.01em; - line-height: 1.1; -} - -.gh-portal-gift-checkout-card-tier { - margin-top: 6px; - font-size: 1.4rem; - color: rgba(255, 255, 255, 0.8); - line-height: 1.3; -} - @media (max-width: 880px) { .gh-portal-gift-checkout { @@ -477,8 +481,6 @@ export const GiftPageStyles = ` min-height: 0; } - /* Drop sticky/100vh sizing — on mobile the right column should sit naturally - above the left content, taking only the height it needs. */ .gh-portal-gift-checkout-right { order: -1; position: static; @@ -555,6 +557,21 @@ function getDurationLabel(selectedInterval) { return selectedInterval === 'month' ? '1 month' : '1 year'; } +const GIFT_EXPIRY_DAYS = 365; + +export function getPreviewGiftExpiresAt(fromDate = new Date()) { + const date = new Date(fromDate); + date.setDate(date.getDate() + GIFT_EXPIRY_DAYS); + return date; +} + +export function formatGiftExpiresAt(date) { + const d = date instanceof Date ? date : new Date(date); + const day = d.getDate(); + const month = d.toLocaleDateString('en-US', {month: 'short'}); + return `${day} ${month}, ${d.getFullYear()}`; +} + const GiftPage = () => { const {site, brandColor, action, doAction} = useContext(AppContext); const [selectedInterval, setSelectedInterval] = useState(null); @@ -732,16 +749,22 @@ const GiftPage = () => {
+
+
{getDurationLabel(activeInterval)}
+
{`${activeProduct.name} membership`}
+
+
+
+
Expires
+
{formatGiftExpiresAt(getPreviewGiftExpiresAt())}
+
+
{siteIcon && ( )} {siteTitle}
-
-
{getDurationLabel(activeInterval)}
-
{`${activeProduct.name} membership`}
-
diff --git a/apps/portal/src/components/pages/gift-redemption-page.js b/apps/portal/src/components/pages/gift-redemption-page.js index 84c92176021..24dfdb82440 100644 --- a/apps/portal/src/components/pages/gift-redemption-page.js +++ b/apps/portal/src/components/pages/gift-redemption-page.js @@ -9,6 +9,7 @@ import {getGiftDurationLabel, getGiftRedemptionErrorMessage} from '../../utils/g import {t} from '../../utils/i18n'; import {hasGiftSubscriptions, removePortalLinkFromUrl} from '../../utils/helpers'; import useCardTilt from '../../utils/use-card-tilt'; +import {formatGiftExpiresAt, getPreviewGiftExpiresAt} from './gift-page'; export const GiftRedemptionStyles = ` .gh-portal-gift-redemption-form { @@ -200,17 +201,31 @@ const GiftRedemptionPage = () => {
+
+
{getGiftDurationLabel(gift)}
+ {/* eslint-disable-next-line i18next/no-literal-string -- copy not yet finalised */} +
{`${gift.tier.name} membership`}
+
+
+ {name.trim() && ( +
+ {/* eslint-disable-next-line i18next/no-literal-string -- copy not yet finalised */} +
Name
+
{name.trim()}
+
+ )} +
+ {/* eslint-disable-next-line i18next/no-literal-string -- copy not yet finalised */} +
Expires
+
{formatGiftExpiresAt(gift.expires_at || getPreviewGiftExpiresAt())}
+
+
{siteIcon && ( )} {siteTitle}
-
-
{getGiftDurationLabel(gift)}
- {/* eslint-disable-next-line i18next/no-literal-string -- copy not yet finalised */} -
{`${gift.tier.name} membership`}
-
diff --git a/apps/portal/src/components/pages/gift-success-page.js b/apps/portal/src/components/pages/gift-success-page.js index f402aa9e0b8..309d8264223 100644 --- a/apps/portal/src/components/pages/gift-success-page.js +++ b/apps/portal/src/components/pages/gift-success-page.js @@ -5,6 +5,7 @@ import copyTextToClipboard from '../../utils/copy-to-clipboard'; import {getAvailableProducts} from '../../utils/helpers'; import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg'; import useCardTilt from '../../utils/use-card-tilt'; +import {formatGiftExpiresAt, getPreviewGiftExpiresAt} from './gift-page'; // TODO: wrap strings with t() once copy is finalised /* eslint-disable i18next/no-literal-string */ @@ -150,18 +151,26 @@ const GiftSuccessPage = () => {
-
- {siteIcon && ( - - )} - {siteTitle} -
{tier && cadence && (
{getDurationLabel(cadence)}
{`${tier.name} membership`}
)} + {cadence && ( +
+
+
Expires
+
{formatGiftExpiresAt(getPreviewGiftExpiresAt())}
+
+
+ )} +
+ {siteIcon && ( + + )} + {siteTitle} +
diff --git a/apps/portal/src/components/pages/magic-link-page.js b/apps/portal/src/components/pages/magic-link-page.js index 56a5c0a01b0..640cd394f0e 100644 --- a/apps/portal/src/components/pages/magic-link-page.js +++ b/apps/portal/src/components/pages/magic-link-page.js @@ -8,6 +8,7 @@ import {ReactComponent as CheckmarkIcon} from '../../images/icons/checkmark.svg' import {isIos} from '../../utils/is-ios'; import {t} from '../../utils/i18n'; import {getGiftDurationLabel} from '../../utils/gift-redemption-notification'; +import {formatGiftExpiresAt, getPreviewGiftExpiresAt} from './gift-page'; const ChevronIcon = () => (
+
+
{getGiftDurationLabel(gift)}
+ {/* eslint-disable-next-line i18next/no-literal-string -- copy not yet finalised */} +
{`${gift.tier?.name} membership`}
+
+
+ {submittedName && ( +
+ {/* eslint-disable-next-line i18next/no-literal-string -- copy not yet finalised */} +
Name
+
{submittedName}
+
+ )} +
+ {/* eslint-disable-next-line i18next/no-literal-string -- copy not yet finalised */} +
Expires
+
{formatGiftExpiresAt(gift.expires_at || getPreviewGiftExpiresAt())}
+
+
{siteIcon && ( )} {siteTitle}
-
-
{getGiftDurationLabel(gift)}
- {/* eslint-disable-next-line i18next/no-literal-string -- copy not yet finalised */} -
{`${gift.tier?.name} membership`}
-
diff --git a/apps/portal/src/utils/use-card-tilt.js b/apps/portal/src/utils/use-card-tilt.js index 9b1f9241be1..ca716a59054 100644 --- a/apps/portal/src/utils/use-card-tilt.js +++ b/apps/portal/src/utils/use-card-tilt.js @@ -13,7 +13,7 @@ import {useCallback, useRef} from 'react'; * sensitive than Chrome to long transitions overlapping rapid style updates, * so without this it feels like the tilt is debounced. */ -export default function useCardTilt({maxTilt = 3, trackTransition = 'transform 80ms linear', restTransition = 'transform 400ms ease-out'} = {}) { +export default function useCardTilt({maxTilt = 3, shineSwing = 10, trackTransition = 'transform 80ms linear, --shine-angle 80ms linear', restTransition = 'transform 400ms ease-out, --shine-angle 400ms ease-out'} = {}) { const cardRef = useRef(null); const rafIdRef = useRef(null); const targetRef = useRef({x: 0, y: 0}); @@ -29,11 +29,13 @@ export default function useCardTilt({maxTilt = 3, trackTransition = 'transform 8 const {x, y} = targetRef.current; card.style.transition = trackTransition; card.style.transform = `perspective(1200px) rotateX(${-y * maxTilt}deg) rotateY(${x * maxTilt}deg)`; + card.style.setProperty('--shine-angle', `${243.43 + x * shineSwing}deg`); } else { card.style.transition = restTransition; card.style.transform = ''; + card.style.removeProperty('--shine-angle'); } - }, [maxTilt, trackTransition, restTransition]); + }, [maxTilt, shineSwing, trackTransition, restTransition]); const schedule = useCallback(() => { if (rafIdRef.current === null) { diff --git a/ghost/core/core/server/api/endpoints/users.js b/ghost/core/core/server/api/endpoints/users.js index 540a4a6edcc..dbfec917230 100644 --- a/ghost/core/core/server/api/endpoints/users.js +++ b/ghost/core/core/server/api/endpoints/users.js @@ -51,7 +51,7 @@ async function rotateSessionForSelfPasswordChange(frame, user) { }); } -async function fetchOrCreatePersonalToken(userId) { +async function fetchOrCreateStaffToken(userId) { const token = await models.ApiKey.findOne({user_id: userId}, {}); if (!token) { @@ -276,7 +276,7 @@ const controller = { } }, - readToken: { + readStaffToken: { headers: { cacheInvalidate: false }, @@ -293,11 +293,11 @@ const controller = { permissions: permissionOnlySelf, query(frame) { const targetId = getTargetId(frame); - return fetchOrCreatePersonalToken(targetId); + return fetchOrCreateStaffToken(targetId); } }, - regenerateToken: { + regenerateStaffToken: { headers: { cacheInvalidate: false }, @@ -314,7 +314,7 @@ const controller = { permissions: permissionOnlySelf, async query(frame) { const targetId = getTargetId(frame); - const model = await fetchOrCreatePersonalToken(targetId); + const model = await fetchOrCreateStaffToken(targetId); return models.ApiKey.refreshSecret(model.toJSON(), Object.assign({}, {id: model.id})); } } diff --git a/ghost/core/core/server/api/endpoints/utils/serializers/output/users.js b/ghost/core/core/server/api/endpoints/utils/serializers/output/users.js index d2ea1c8f5a8..271b54ea10c 100644 --- a/ghost/core/core/server/api/endpoints/utils/serializers/output/users.js +++ b/ghost/core/core/server/api/endpoints/utils/serializers/output/users.js @@ -32,16 +32,16 @@ module.exports = { }; }, - readToken(model, apiConfig, frame) { - debug('readToken'); + readStaffToken(model, apiConfig, frame) { + debug('readStaffToken'); frame.response = { apiKey: model.toJSON(frame.options) }; }, - regenerateToken(model, apiConfig, frame) { - debug('regenerateToken'); + regenerateStaffToken(model, apiConfig, frame) { + debug('regenerateStaffToken'); frame.response = { apiKey: model.toJSON(frame.options) diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index 2c796cc10f2..7489cfb4fe1 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -96,12 +96,12 @@ module.exports = function apiRoutes() { router.get('/users/slug/:slug', mw.authAdminApi, http(api.users.read)); // NOTE: We don't expose any email addresses via the public api. router.get('/users/email/:email', mw.authAdminApi, http(api.users.read)); - router.get('/users/:id/token', mw.authAdminApi, http(api.users.readToken)); + router.get('/users/:id/token', mw.authAdminApi, http(api.users.readStaffToken)); router.put('/users/password', mw.authAdminApi, http(api.users.changePassword)); router.put('/users/owner', mw.authAdminApi, http(api.users.transferOwnership)); router.put('/users/:id', mw.authAdminApi, http(api.users.edit)); - router.put('/users/:id/token', mw.authAdminApi, http(api.users.regenerateToken)); + router.put('/users/:id/token', mw.authAdminApi, http(api.users.regenerateStaffToken)); router.delete('/users/:id', mw.authAdminApi, http(api.users.destroy)); // ## Tags diff --git a/ghost/core/test/unit/server/services/comments/comments-service-emails.test.js b/ghost/core/test/unit/server/services/comments/comments-service-emails.test.js index 6b80accf295..58fe38013d2 100644 --- a/ghost/core/test/unit/server/services/comments/comments-service-emails.test.js +++ b/ghost/core/test/unit/server/services/comments/comments-service-emails.test.js @@ -35,4 +35,145 @@ describe('Comments Service: CommentsServiceEmails', function () { assert.equal(result, 'https://example.com/my-post/#ghost-comments-456'); }); }); + + // Characterisation tests for the three notification entry points. These + // exercise the full public path so that future changes to `getPostUrl`'s + // signature have to migrate every call site coherently — historically + // line 161 of comments-service-emails.js was missed when the helper was + // refactored, and the helper-only test above did not catch it. + describe('public notification methods invoke getPostUrl', function () { + const POST_URL_FOR_COMMENT = 'https://example.com/my-post/#ghost-comments-comment-id'; + + function makeModel(attrs, related = {}) { + return { + id: attrs.id, + attributes: attrs, + get: key => attrs[key], + related: name => related[name] + }; + } + + function makeAuthor(attrs) { + return makeModel(Object.assign({comment_notifications: true}, attrs)); + } + + function buildHarness(opts = {}) { + const post = makeModel( + Object.assign({id: 'post-id', title: 'My Post'}, opts.post), + { + authors: opts.authors !== undefined ? opts.authors : [ + makeAuthor({email: 'author@example.com', slug: 'author'}) + ] + } + ); + const member = makeModel({ + id: 'member-id', + name: 'Reader', + email: 'reader@example.com', + expertise: '' + }); + const owner = makeModel({email: 'owner@example.com', slug: 'owner'}); + const comment = makeModel({ + id: 'comment-id', + post_id: 'post-id', + member_id: 'member-id', + html: '

hi

', + created_at: new Date('2026-04-01T00:00:00Z') + }); + + const Post = {findOne: sinon.stub().resolves(post)}; + const Member = {findOne: sinon.stub().resolves(member)}; + const User = {getOwnerUser: sinon.stub().resolves(owner)}; + const Comment = {findOne: sinon.stub()}; + + const renderStub = sinon.stub().resolves({html: 'h', text: 't'}); + const mailerSendStub = sinon.stub().resolves(); + + // Stub `getUrlByResourceId` with a withArgs match: the URL only + // resolves when the post's id is passed. Anything else returns + // undefined, so a regression that drops `post.id` somewhere + // between notifyX and the URL service surfaces as a bad URL + // landing in templateData. + const getUrlByResourceIdStub = sinon.stub().returns(undefined); + getUrlByResourceIdStub.withArgs('post-id', sinon.match.any) + .returns('https://example.com/my-post/'); + const urlService = {getUrlByResourceId: getUrlByResourceIdStub}; + + const instance = new CommentsServiceEmails({ + config: {}, + logging: {warn: sinon.stub()}, + models: {Post, Member, User, Comment}, + mailer: {send: mailerSendStub}, + settingsCache: {get: sinon.stub().returns('Test Site')}, + settingsHelpers: {getMembersSupportAddress: () => 'support@example.com'}, + urlService, + urlUtils: { + getSiteUrl: () => 'https://example.com/', + urlFor: () => 'https://example.com/ghost/', + urlJoin: (...parts) => parts.join('') + }, + labs: {isSet: sinon.stub().returns(false)} + }); + + instance.commentsServiceEmailRenderer.renderEmailTemplate = renderStub; + const getPostUrlSpy = sinon.spy(instance, 'getPostUrl'); + + return {instance, post, comment, getPostUrlSpy, renderStub, urlService}; + } + + it('notifyPostAuthors: passes the right post arg into getPostUrl and threads the URL into templateData', async function () { + const {instance, post, comment, getPostUrlSpy, renderStub, urlService} = buildHarness(); + + await instance.notifyPostAuthors(comment); + + // The signature on `main` is (postId, commentId). When this test + // was written the call site at line 51 passes the string id; the + // upcoming HKG-1761 migration will rewrite it to pass the post + // model. Either way, the URL must thread through to templateData. + sinon.assert.calledOnce(getPostUrlSpy); + const [postArg, commentIdArg] = getPostUrlSpy.firstCall.args; + assert.equal(commentIdArg, 'comment-id'); + // postArg is either the post model or its id; both yield the same URL + assert.equal(typeof postArg === 'string' ? postArg : postArg && postArg.id, post.id); + + // End-to-end pin: getUrlByResourceId must receive the post's id, + // not whatever shape happens to render to a string. The stub + // only returns the canonical URL for 'post-id', so a regression + // that drops the id somewhere in the chain surfaces here. + sinon.assert.calledWith(urlService.getUrlByResourceId, 'post-id'); + + sinon.assert.calledOnce(renderStub); + const [, templateData] = renderStub.firstCall.args; + assert.equal(templateData.postUrl, POST_URL_FOR_COMMENT); + }); + + // notifyParentCommentAuthor is intentionally not exercised here: the + // function reaches into emailService.renderer.createUnsubscribeUrl, + // which is only populated after the email-service module is + // initialised, requiring far more setup than the regression scope + // justifies. notifyPostAuthors above and notifyReport below cover + // the same `getPostUrl` call-shape contract. + + it('notifyReport: threads the post URL through to templateData (regression for the missed line-161 migration)', async function () { + const {instance, post, comment, getPostUrlSpy, renderStub, urlService} = buildHarness(); + + await instance.notifyReport(comment, {name: 'Reporter', email: 'reporter@example.com'}); + + // The bug we want to catch: if the helper signature changes + // (postId → post) but this notifyReport call site is not + // migrated, getPostUrl is invoked with a string and produces + // a malformed URL. Pin the URL here so a future regression + // surfaces immediately. + sinon.assert.calledOnce(getPostUrlSpy); + const [postArg, commentIdArg] = getPostUrlSpy.firstCall.args; + assert.equal(commentIdArg, 'comment-id'); + assert.equal(typeof postArg === 'string' ? postArg : postArg && postArg.id, post.id); + + sinon.assert.calledWith(urlService.getUrlByResourceId, 'post-id'); + + sinon.assert.calledOnce(renderStub); + const [, templateData] = renderStub.firstCall.args; + assert.equal(templateData.postUrl, POST_URL_FOR_COMMENT); + }); + }); }); diff --git a/ghost/core/test/unit/server/services/indexnow.test.js b/ghost/core/test/unit/server/services/indexnow.test.js index 5165d765211..177c48fbe5d 100644 --- a/ghost/core/test/unit/server/services/indexnow.test.js +++ b/ghost/core/test/unit/server/services/indexnow.test.js @@ -9,6 +9,7 @@ const events = require('../../../../core/server/lib/common/events'); const settingsCache = require('../../../../core/shared/settings-cache'); const labs = require('../../../../core/shared/labs'); const logging = require('@tryghost/logging'); +const urlService = require('../../../../core/server/services/url'); describe('IndexNow', function () { let eventStub; @@ -401,4 +402,40 @@ describe('IndexNow', function () { assert.equal((key === null), true); }); }); + + // Pin which URL ping() actually sends. The earlier `ping()` block above + // uses nock to intercept the HTTP request but never inspects the + // `?url=...` query parameter; that's the exact value a future change to + // the url-service call shape (e.g. swapping the legacy id-based method + // for a resource-based facade method) could regress without anyone + // noticing. + describe('ping() URL output', function () { + const ping = indexnow.__get__('ping'); + const POST_URL = 'https://my-blog.example/some-post/'; + let requestStub; + let resetIndexNow; + + beforeEach(function () { + sinon.stub(urlService, 'getUrlByResourceId').returns(POST_URL); + + requestStub = sinon.stub().resolves({statusCode: 200}); + resetIndexNow = indexnow.__set__('request', requestStub); + + settingsCacheStub.withArgs('indexnow_api_key').returns('a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4'); + }); + + afterEach(function () { + resetIndexNow(); + }); + + it('passes the post URL into the IndexNow request', async function () { + const post = {id: 'abc', slug: 'some-post', type: 'post'}; + + await ping(post); + + sinon.assert.calledOnce(requestStub); + const indexNowUrl = new URL(requestStub.firstCall.args[0]); + assert.equal(indexNowUrl.searchParams.get('url'), POST_URL); + }); + }); });