Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ const BetaGiftRedemptionPage = () => {
const { action, brandColor, doAction, member, pageData, site } = useContext(AppContext);
const gift = pageData?.gift;
const isLoggedIn = !!member;
const [name, setName] = useState(member?.name || gift?.recipient_name || '');
const [name, setName] = useState(gift?.recipient_name || member?.name || '');
const [email, setEmail] = useState(member?.email || '');
const [errors, setErrors] = useState({});
const [showDetails, setShowDetails] = useState(false);
Expand All @@ -68,7 +68,7 @@ const BetaGiftRedemptionPage = () => {
useEffect(() => {
// Prefill with the recipient name the buyer entered, so the gift card
// is personal before the recipient types anything.
setName(member?.name || gift?.recipient_name || '');
setName(gift?.recipient_name || member?.name || '');
setEmail(member?.email || '');
setErrors({});
}, [member?.email, member?.name, gift?.recipient_name]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,24 @@ describe.each([
});

describe('BetaGiftRedemptionPage', () => {
test('shows the intended recipient name when a different member is logged in', () => {
const personalizedGift = {
...gift,
buyer_name: 'Morgan',
recipient_name: 'Taylor',
};
const { getByText, queryByText } = renderGiftRedemptionPage(BetaGiftRedemptionPage, {
member: member.free,
pageData: {
token: 'gift-token-123',
gift: personalizedGift,
},
});

expect(getByText('Taylor')).toBeInTheDocument();
expect(queryByText(member.free.name)).not.toBeInTheDocument();
});

test('presents the buyer details and prefills the intended recipient name', () => {
const personalizedGift = {
...gift,
Expand Down
15 changes: 15 additions & 0 deletions ghost/core/core/frontend/services/rendering/renderer.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,14 @@ const messages = {
* @param {Object} data
*/
module.exports = function renderer(req, res, data) {
// CASE: client hung up while we fetched data. Rendering into a dead socket is
// wasted work that lengthens the queue under load. 499 = client closed request.
if (res.destroyed && !res.writableEnded) {
debug('Client gone before render, skipping: ' + req.originalUrl);
res.statusCode = 499;
return;
}

// Set response context
setContext(req, res, data);

Expand Down Expand Up @@ -44,6 +52,13 @@ module.exports = function renderer(req, res, data) {
return req.next(err);
}

// The render itself can take seconds; the client may have gone in the meantime.
if (res.destroyed && !res.writableEnded) {
debug('Client gone during render, discarding: ' + req.originalUrl);
res.statusCode = 499;
return;
}

// CASE: a {{#get}} or {{#collection}} helper aborted during rendering, so the
// page contains fallback content — cap public caching at 60s so the broken page recovers quickly.
if (res.locals?.degradedRender) {
Expand Down
1 change: 1 addition & 0 deletions ghost/core/core/server/ghost-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import type { Promisable } from 'type-fest';
import type * as express from 'express';
import type * as http from 'node:http';
import { promisify } from 'node:util';
import assert from 'node:assert';

type ServerConfig = {
host: string;
Expand Down
2 changes: 1 addition & 1 deletion ghost/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
"lint:frontend": "eslint 'core/frontend/**/*.js' --cache",
"lint:test": "eslint 'test/**/*.js' --cache",
"lint:code": "pnpm run '/^lint:(server|shared|frontend)$/'",
"lint:types": "eslint '**/*.ts' --cache && tsc --noEmit",
"lint:types": "eslint '**/*.ts' --cache && tsc --noEmit && tsc --noEmit -p test/tsconfig.json",
"lint": "pnpm run '/^lint:(server|shared|frontend|test|types)$/'"
},
"dependencies": {
Expand Down
9 changes: 9 additions & 0 deletions ghost/core/test/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
/* Vitest's globals live here only, so they can't mask a missing import in core source. */
"extends": "../tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["node", "vitest/globals"]
},
"include": ["**/*.ts", "../bin/**/*.d.ts", "../core/**/*.d.ts", "../types/**/*.d.ts"]
}
52 changes: 52 additions & 0 deletions ghost/core/test/unit/frontend/services/rendering/renderer.test.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
const assert = require('node:assert/strict');
const sinon = require('sinon');
const renderer = require('../../../../../core/frontend/services/rendering/renderer');

Expand All @@ -14,6 +15,9 @@ describe('Renderer', function () {
res = {
locals: {},
routerOptions: {},
// an open response: both are always booleans on a real ServerResponse
destroyed: false,
writableEnded: false,
// pre-set so templates.setTemplate returns early
_template: 'index',
render: sinon.stub().callsArgWith(2, null, '<html></html>'),
Expand Down Expand Up @@ -88,4 +92,52 @@ describe('Renderer', function () {
sinon.assert.calledOnce(req.next);
sinon.assert.notCalled(res.send);
});

it('skips the render when the client hung up before it started', function () {
res.destroyed = true;
res.writableEnded = false;

renderer(req, res, {});

sinon.assert.notCalled(res.render);
sinon.assert.notCalled(res.send);
assert.equal(res.statusCode, 499);
});

it('discards the html when the client hung up during the render', function () {
res.render = sinon.stub().callsFake(function (template, data, callback) {
res.destroyed = true;
res.writableEnded = false;
callback(null, '<html></html>');
});

renderer(req, res, {});

sinon.assert.calledOnce(res.render);
sinon.assert.notCalled(res.send);
assert.equal(res.statusCode, 499);
});

it('does not treat an already-sent response as a disconnect', function () {
res.destroyed = true;
res.writableEnded = true;

renderer(req, res, {});

sinon.assert.calledOnceWithExactly(res.send, '<html></html>');
});

it('forwards render errors even when the client has hung up', function () {
req.next = sinon.spy();
res.render = sinon.stub().callsFake(function (template, data, callback) {
res.destroyed = true;
callback(new Error('render failed'));
});

renderer(req, res, {});

// a broken template is worth logging whether or not anyone is still listening
sinon.assert.calledOnce(req.next);
sinon.assert.notCalled(res.send);
});
});
5 changes: 2 additions & 3 deletions ghost/core/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,7 @@
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
"types": [
"node",
"vitest/globals"
"node"
] /* Specify type package names to be included without being referenced in a source file. */,
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
Expand Down Expand Up @@ -102,5 +101,5 @@
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
},
"include": ["bin/**/*.ts", "core/**/*.ts", "test/**/*.ts", "types/**/*.d.ts"]
"include": ["bin/**/*.ts", "core/**/*.ts", "types/**/*.d.ts"]
}
Loading