diff --git a/.env.example b/.env.example index 8001b9354cf..3b6e8ef7ed2 100644 --- a/.env.example +++ b/.env.example @@ -9,12 +9,7 @@ # Debug level to pass to Ghost # DEBUG= -# App flags to pass to the dev command -## Run `yarn dev --show-flags` to see all available app flags - -# GHOST_DEV_APP_FLAGS= - -# Stripe keys - used to forward Stripe webhooks to the Ghost instance in `dev.js` script +# Stripe keys - used to forward Stripe webhooks to Ghost ## Stripe Secret Key: sk_test_******* # STRIPE_SECRET_KEY= ## Stripe Publishable Key: pk_test_******* diff --git a/.github/scripts/dev-with-tinybird.js b/.github/scripts/dev-with-tinybird.js deleted file mode 100755 index f05422aa2e0..00000000000 --- a/.github/scripts/dev-with-tinybird.js +++ /dev/null @@ -1,147 +0,0 @@ -#!/usr/bin/env node - -const {spawn, execSync} = require('child_process'); -const fs = require('fs'); -const path = require('path'); - -// Check if analytics containers are running -function checkAnalyticsRunning() { - try { - const output = execSync('docker ps --format "{{.Names}}"', {encoding: 'utf8'}); - return output.includes('ghost-tinybird-local'); - } catch (error) { - return false; - } -} - -// Extract Tinybird configuration from Docker volume -function extractTinybirdConfig() { - try { - console.log('📊 Extracting Tinybird configuration from Docker...'); - - // Wait for tb-cli to complete if needed - try { - execSync('docker wait ghost-tb-cli', {encoding: 'utf8', stdio: 'pipe'}); - } catch (e) { - // Container might have already completed - } - - // Extract configuration from shared volume - const config = execSync( - 'docker run --rm -v ghost_shared-config:/mnt/shared-config alpine cat /mnt/shared-config/.env.tinybird', - {encoding: 'utf8', stdio: 'pipe'} - ); - - if (!config) { - throw new Error('Could not read Tinybird configuration'); - } - - // Parse the configuration - const lines = config.split('\n').filter(line => line.includes('=')); - const configObj = {}; - - lines.forEach(line => { - const [key, ...valueParts] = line.split('='); - configObj[key] = valueParts.join('='); - }); - - if (!configObj.TINYBIRD_WORKSPACE_ID || !configObj.TINYBIRD_ADMIN_TOKEN) { - throw new Error('Invalid Tinybird configuration'); - } - - return configObj; - } catch (error) { - console.error('❌ Failed to extract Tinybird configuration:', error.message); - console.error(' Make sure Docker analytics containers are running properly'); - process.exit(1); - } -} - -// Setup Tinybird environment variables -function setupTinybirdEnv() { - const config = extractTinybirdConfig(); - - // Set environment variables for the child process - const tinybirdEnv = { - tinybird__workspaceId: config.TINYBIRD_WORKSPACE_ID, - tinybird__adminToken: config.TINYBIRD_ADMIN_TOKEN, - tinybird__stats__endpoint: 'http://localhost:7181', - tinybird__stats__endpointBrowser: 'http://localhost:7181', - tinybird__tracker__endpoint: 'http://localhost:3000/api/v1/page_hit', - TINYBIRD_TRACKER_TOKEN: config.TINYBIRD_TRACKER_TOKEN - }; - - console.log('✅ Tinybird configuration loaded'); - return tinybirdEnv; -} - -// Main function -async function main() { - // Check if we should use Tinybird - const useTinybird = process.argv.includes('--tinybird') || process.env.GHOST_USE_TINYBIRD === 'true'; - - let extraEnv = {}; - - if (useTinybird) { - console.log('🚀 Starting Ghost with Tinybird analytics...\n'); - - // Check if analytics containers are running - if (!checkAnalyticsRunning()) { - console.log('📦 Analytics containers not running, starting them...'); - try { - execSync('docker compose --profile analytics up -d --wait', { - stdio: 'inherit' - }); - console.log('✅ Analytics containers started\n'); - } catch (error) { - console.error('❌ Failed to start analytics containers'); - process.exit(1); - } - } else { - console.log('✅ Analytics containers already running\n'); - } - - // Setup Tinybird environment - extraEnv = setupTinybirdEnv(); - } - - // Get the original dev script arguments - const devScriptPath = path.join(__dirname, 'dev.js'); - const devArgs = process.argv.slice(2).filter(arg => arg !== '--tinybird'); - - console.log('\n🏃 Starting Ghost development server...\n'); - - // Spawn the original dev script with Tinybird environment - const child = spawn('node', [devScriptPath, ...devArgs], { - stdio: 'inherit', - env: { - ...process.env, - ...extraEnv - } - }); - - child.on('error', (error) => { - console.error('Failed to start:', error); - process.exit(1); - }); - - child.on('exit', (code) => { - process.exit(code); - }); -} - -// Handle process termination -process.on('SIGINT', () => { - console.log('\n👋 Shutting down...'); - process.exit(0); -}); - -process.on('SIGTERM', () => { - console.log('\n👋 Shutting down...'); - process.exit(0); -}); - -main().catch(error => { - console.error('Error:', error); - process.exit(1); -}); \ No newline at end of file diff --git a/.github/scripts/dev.js b/.github/scripts/dev.js deleted file mode 100644 index 5150bc9830e..00000000000 --- a/.github/scripts/dev.js +++ /dev/null @@ -1,317 +0,0 @@ -const path = require('path'); -const util = require('util'); -const exec = util.promisify(require('child_process').exec); -const debug = require('debug')('ghost:dev'); - -const chalk = require('chalk'); -const concurrently = require('concurrently'); - - -debug('loading config'); -const config = require('../../ghost/core/core/shared/config/loader').loadNconf({ - customConfigPath: path.join(__dirname, '../../ghost/core') -}); -debug('config loaded'); - -debug('loading live reload base url'); -const liveReloadBaseUrl = config.getSubdir() || '/ghost/'; -debug('live reload base url loaded'); - -debug('loading site url'); -const siteUrl = config.getSiteUrl(); -debug('site url loaded'); - -// Pass flags using GHOST_DEV_APP_FLAGS env var or --flag -debug('loading app flags') -const availableAppFlags = { - 'show-flags': 'Show available app flags, then exit', - stripe: 'Run `stripe listen` to forward Stripe webhooks to the Ghost instance', - all: 'Run all apps', - ghost: 'Run only Ghost', - admin: 'Run only Admin', - 'browser-tests': 'Run browser tests', - announcementBar: 'Run Announcement Bar', - announcementbar: 'Run Announcement Bar', - 'announcement-bar': 'Run Announcement Bar', - portal: 'Run Portal', - signup: 'Run Signup Form', - search: 'Run Sodo Search', - lexical: 'Use your local instance of the Lexical editor running in a separate process', - comments: 'Run Comments UI', - https: 'Serve apps using HTTPS', - offline: 'Run in offline mode (no Stripe webhooks will be forwarded)' -} - -// Split args on '--' separator to separate app flags from pass-through args -const doubleDashIndex = process.argv.lastIndexOf('--'); -const devArgs = doubleDashIndex === -1 ? process.argv : process.argv.slice(0, doubleDashIndex); -const passThroughArgs = doubleDashIndex === -1 ? [] : process.argv.slice(doubleDashIndex + 1); - -const DASH_DASH_ARGS = devArgs.filter(a => a.startsWith('--')).map(a => a.slice(2)); -const ENV_ARGS = process.env.GHOST_DEV_APP_FLAGS?.split(',') || []; -const GHOST_APP_FLAGS = [...ENV_ARGS, ...DASH_DASH_ARGS].filter(flag => flag.trim().length > 0); - -// Format pass-through args for command usage -const PASS_THROUGH_FLAGS = passThroughArgs.join(' '); - -function showAvailableAppFlags() { - console.log(chalk.blue('App flags can be enabled by setting the GHOST_DEV_APP_FLAGS environment variable to a comma separated list of flags.')); - console.log(chalk.blue('Alternatively, flags can be passed directly to `yarn dev`, i.e. `yarn dev --portal')); - console.log(chalk.blue('Note: the `yarn docker:dev` command only supports the GHOST_DEV_APP_FLAGS environment variable, as --flags cannot be passed to the docker container.\n')); - console.log(chalk.blue('Available app flags:')); - for (const [flag, description] of Object.entries(availableAppFlags)) { - console.log(chalk.blue(` ${flag}: ${description}`)); - } -} - -if (GHOST_APP_FLAGS.includes('show-flags')) { - showAvailableAppFlags(); - process.exit(0); -} - -// Check for invalid flags -debug('checking for invalid flags', GHOST_APP_FLAGS); -const invalidFlags = GHOST_APP_FLAGS.filter(flag => !Object.keys(availableAppFlags).includes(flag)); -if (invalidFlags.length > 0) { - console.error(chalk.red(`Error: Invalid app flag(s): ${invalidFlags.join(', ')}`)); - showAvailableAppFlags(); - process.exit(1); -} -debug('invalid flags check passed'); - - -debug('app flags loaded'); - -debug('loading commands'); -let commands = []; - -const COMMAND_GHOST = { - name: 'ghost', - command: 'nx run ghost:dev', - prefixColor: 'blue', - env: { - // In development mode, we allow self-signed certificates (for sending webmentions and oembeds) - NODE_TLS_REJECT_UNAUTHORIZED: '0', - } -}; - -const COMMAND_ADMIN = { - name: 'admin', - command: `nx run ghost-admin:dev --live-reload-base-url=${liveReloadBaseUrl} --live-reload-port=4201`, - prefixColor: 'green', - env: {} -}; - -const COMMAND_BROWSERTESTS = { - name: 'browser-tests', - command: `nx run ghost:test:browser${PASS_THROUGH_FLAGS ? ` -- ${PASS_THROUGH_FLAGS}`: ''}`, - prefixColor: 'blue', - env: {} -}; - -const adminXApps = '@tryghost/admin-x-settings,@tryghost/activitypub,@tryghost/posts,@tryghost/stats'; - -const COMMANDS_ADMINX = [{ - name: 'adminXDeps', - command: 'while [ 1 ]; do nx watch --projects=apps/admin-x-design-system,apps/admin-x-framework,apps/shade,apps/stats -- nx run \\$NX_PROJECT_NAME:build; done', - prefixColor: '#C72AF7', - env: {} -}, { - name: 'adminX', - command: `nx run-many --projects=${adminXApps} --parallel=${adminXApps.length} --targets=dev`, - prefixColor: '#C72AF7', - env: {} -}]; - -if (GHOST_APP_FLAGS.includes('ghost')) { - commands = [COMMAND_GHOST]; -} else if (GHOST_APP_FLAGS.includes('admin')) { - commands = [COMMAND_ADMIN, ...COMMANDS_ADMINX]; -} else if (GHOST_APP_FLAGS.includes('browser-tests')) { - commands = [COMMAND_BROWSERTESTS]; -} else { - commands = [COMMAND_GHOST, COMMAND_ADMIN, ...COMMANDS_ADMINX]; -} - -if (GHOST_APP_FLAGS.includes('portal') || GHOST_APP_FLAGS.includes('all')) { - commands.push({ - name: 'portal', - command: 'nx run @tryghost/portal:dev', - prefixColor: 'magenta', - env: {} - }); - - if (GHOST_APP_FLAGS.includes('https')) { - // Safari needs HTTPS for it to work - // To make this work, you'll need a CADDY server running in front - // Note the port is different because of this extra layer. Use the following Caddyfile: - // https://localhost:4176 { - // reverse_proxy http://localhost:4175 - // } - - COMMAND_GHOST.env['portal__url'] = 'https://localhost:4176/portal.min.js'; - } else { - COMMAND_GHOST.env['portal__url'] = 'http://localhost:4175/portal.min.js'; - } -} - -if (GHOST_APP_FLAGS.includes('signup') || GHOST_APP_FLAGS.includes('all')) { - commands.push({ - name: 'signup-form', - command: GHOST_APP_FLAGS.includes('signup') ? 'nx run @tryghost/signup-form:dev' : 'nx run @tryghost/signup-form:preview', - prefixColor: 'magenta', - env: {} - }); - COMMAND_GHOST.env['signupForm__url'] = 'http://localhost:6174/signup-form.min.js'; -} - -if (GHOST_APP_FLAGS.includes('announcement-bar') || GHOST_APP_FLAGS.includes('announcementBar') || GHOST_APP_FLAGS.includes('announcementbar') || GHOST_APP_FLAGS.includes('all')) { - commands.push({ - name: 'announcement-bar', - command: 'nx run @tryghost/announcement-bar:dev', - prefixColor: '#DC9D00', - env: {} - }); - COMMAND_GHOST.env['announcementBar__url'] = 'http://localhost:4177/announcement-bar.min.js'; -} - -if (GHOST_APP_FLAGS.includes('search') || GHOST_APP_FLAGS.includes('all')) { - commands.push({ - name: 'search', - command: 'nx run @tryghost/sodo-search:dev', - prefixColor: '#23de43', - env: {} - }); - COMMAND_GHOST.env['sodoSearch__url'] = 'http://localhost:4178/sodo-search.min.js'; - COMMAND_GHOST.env['sodoSearch__styles'] = 'http://localhost:4178/main.css'; -} - -if (GHOST_APP_FLAGS.includes('lexical')) { - if (GHOST_APP_FLAGS.includes('https')) { - // Safari needs HTTPS for it to work - // To make this work, you'll need a CADDY server running in front - // Note the port is different because of this extra layer. Use the following Caddyfile: - // https://localhost:41730 { - // reverse_proxy http://127.0.0.1:4173 - // } - - COMMAND_ADMIN.env['EDITOR_URL'] = 'https://localhost:41730/'; - } else { - COMMAND_ADMIN.env['EDITOR_URL'] = 'http://localhost:4173/'; - } -} - -if (GHOST_APP_FLAGS.includes('comments') || GHOST_APP_FLAGS.includes('all')) { - if (GHOST_APP_FLAGS.includes('https')) { - // Safari needs HTTPS for it to work - // To make this work, you'll need a CADDY server running in front - // Note the port is different because of this extra layer. Use the following Caddyfile: - // https://localhost:7174 { - // reverse_proxy http://127.0.0.1:7173 - // } - COMMAND_GHOST.env['comments__url'] = 'https://localhost:7174/comments-ui.min.js'; - } else { - COMMAND_GHOST.env['comments__url'] = 'http://localhost:7173/comments-ui.min.js'; - } - - commands.push({ - name: 'comments', - command: 'nx run @tryghost/comments-ui:dev', - prefixColor: '#E55137', - env: {} - }); -} - -async function handleStripe() { - if (GHOST_APP_FLAGS.includes('stripe') || GHOST_APP_FLAGS.includes('all')) { - debug('stripe flag found'); - if (GHOST_APP_FLAGS.includes('offline') || GHOST_APP_FLAGS.includes('browser-tests')) { - debug('offline or browser-tests flag found, skipping stripe'); - return; - } - debug('stripe flag found, proceeding'); - - console.log('Fetching Stripe webhook secret...'); - let stripeSecret; - const stripeSecretKey = process.env.STRIPE_SECRET_KEY; - const apiKeyFlag = stripeSecretKey ? `--api-key ${stripeSecretKey}` : ''; - try { - debug('fetching stripe secret'); - const stripeListenCommand = `stripe listen --print-secret ${apiKeyFlag}`; - debug('stripe listen command', stripeListenCommand); - stripeSecret = await Promise.race([ - exec(stripeListenCommand), - new Promise((_, reject) => setTimeout(() => reject(new Error('Stripe listen command timed out after 5 seconds')), 5000)) - ]); - debug('stripe secret fetched'); - } catch (err) { - console.error('Failed to fetch Stripe secret token. Please ensure either STRIPE_SECRET_KEY is set or you are logged in to the Stripe CLI by running `stripe login`.'); - console.error(err); - process.exit(1); - } - - if (!stripeSecret || !stripeSecret.stdout) { - debug('no stripe secret found'); - console.error('No Stripe secret was present'); - console.error('Please ensure either STRIPE_SECRET_KEY is set or you are logged in to Stripe CLI by running `stripe login`.'); - return; - } - - COMMAND_GHOST.env['WEBHOOK_SECRET'] = stripeSecret.stdout.trim(); - commands.push({ - name: 'stripe', - command: `stripe listen --forward-to ${siteUrl}members/webhooks/stripe/ ${apiKeyFlag}`, - prefixColor: 'yellow', - env: {} - }); - } -} - -(async () => { - debug('starting with commands', commands); - debug('handling stripe'); - await handleStripe(); - debug('stripe handled'); - - if (!commands.length) { - debug('no commands provided'); - console.log(`No commands provided`); - process.exit(0); - } - debug('at least one command provided'); - - debug('resetting nx'); - await exec("yarn nx reset --onlyDaemon"); - debug('nx reset'); - await exec("yarn nx daemon --start"); - debug('nx daemon started'); - - // Wait for daemon to be fully ready by verifying it responds - debug('verifying daemon is ready'); - await new Promise(resolve => setTimeout(resolve, 200)); - await exec("yarn nx daemon --version"); - debug('daemon verified ready'); - - console.log(`Running projects: ${commands.map(c => chalk.green(c.name)).join(', ')}`); - - debug('creating concurrently promise'); - const {result} = concurrently(commands, { - prefix: 'name', - killOthers: ['failure', 'success'], - successCondition: 'first' - }); - - try { - debug('running commands concurrently'); - await result; - debug('commands completed'); - } catch (err) { - debug('concurrently result error', err); - console.error(); - console.error(chalk.red(`Executing dev command failed:`) + `\n`); - console.error(chalk.red(`If you've recently done a \`yarn main\`, dependencies might be out of sync. Try running \`${chalk.green('yarn fix')}\` to fix this.`)); - console.error(chalk.red(`If not, something else went wrong. Please report this to the Ghost team.`)); - console.error(); - process.exit(1); - } -})(); diff --git a/.github/workflows/cleanup-stripe-test-accounts.yml b/.github/workflows/cleanup-stripe-test-accounts.yml index 3b022645830..2f50a286125 100644 --- a/.github/workflows/cleanup-stripe-test-accounts.yml +++ b/.github/workflows/cleanup-stripe-test-accounts.yml @@ -5,6 +5,15 @@ on: # Run twice daily at 3 AM and 3 PM UTC - cron: '0 3,15 * * *' workflow_dispatch: # Allow manual trigger + inputs: + dry_run: + description: "Preview deletions without deleting accounts" + required: false + default: "true" + type: choice + options: + - "true" + - "false" jobs: cleanup: @@ -15,12 +24,19 @@ jobs: - name: Cleanup old Stripe Connect test accounts env: STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }} + DRY_RUN: ${{ github.event.inputs.dry_run || 'false' }} run: | set -euo pipefail if [ -z "${STRIPE_SECRET_KEY:-}" ]; then echo "STRIPE_SECRET_KEY is not set" exit 1 fi + + DRY_RUN_NORMALIZED=$(echo "${DRY_RUN:-false}" | tr '[:upper:]' '[:lower:]') + if [ "$DRY_RUN_NORMALIZED" = "true" ]; then + echo "Running in dry-run mode (no accounts will be deleted)" + fi + # Delete test accounts older than 24 hours # Accounts are named like: test-{runId}-{parallelIndex}@example.com @@ -28,6 +44,9 @@ jobs: MAX_AGE_SECONDS=$((MAX_AGE_HOURS * 60 * 60)) NOW=$(date +%s) DELETED=0 + WOULD_DELETE=0 + FAILED_DELETES=0 + DELETE_QUEUE="" echo "Fetching Stripe Connect test accounts..." @@ -37,9 +56,15 @@ jobs: while [ "$HAS_MORE" = "true" ]; do if [ -z "$STARTING_AFTER" ]; then - RESPONSE=$(curl -s -u "$STRIPE_SECRET_KEY:" "https://api.stripe.com/v1/accounts?limit=100") + if ! RESPONSE=$(curl -sS -u "$STRIPE_SECRET_KEY:" "https://api.stripe.com/v1/accounts?limit=100"); then + echo "Failed to fetch Stripe accounts" + exit 1 + fi else - RESPONSE=$(curl -s -u "$STRIPE_SECRET_KEY:" "https://api.stripe.com/v1/accounts?limit=100&starting_after=$STARTING_AFTER") + if ! RESPONSE=$(curl -sS -u "$STRIPE_SECRET_KEY:" "https://api.stripe.com/v1/accounts?limit=100&starting_after=$STARTING_AFTER"); then + echo "Failed to fetch Stripe accounts (starting_after=$STARTING_AFTER)" + exit 1 + fi fi # Check for API errors @@ -52,6 +77,7 @@ jobs: # Extract account data - handle null/missing data array gracefully ACCOUNTS=$(echo "$RESPONSE" | jq -c '.data // [] | .[]') HAS_MORE=$(echo "$RESPONSE" | jq -r '.has_more // false') + PAGE_LAST_ID=$(echo "$RESPONSE" | jq -r '.data[-1].id // empty') while IFS= read -r account; do [ -z "$account" ] && continue @@ -60,18 +86,63 @@ jobs: EMAIL=$(echo "$account" | jq -r '.email // ""') CREATED=$(echo "$account" | jq -r '.created') - # Check if this is a test account (matches our naming pattern: test-{runId}-{attempt}-{index}@example.com) + # Check if this is a test account (matches our naming pattern) if [[ "$EMAIL" =~ ^test-.*@example\.com$ ]]; then + if ! [[ "$CREATED" =~ ^[0-9]+$ ]]; then + echo "Skipping $ID ($EMAIL): invalid created timestamp '$CREATED'" + continue + fi + AGE=$((NOW - CREATED)) if [ "$AGE" -gt "$MAX_AGE_SECONDS" ]; then - echo "Deleting $ID ($EMAIL) - age: $((AGE / 3600)) hours" - curl -s -X DELETE -u "$STRIPE_SECRET_KEY:" "https://api.stripe.com/v1/accounts/$ID" > /dev/null - DELETED=$((DELETED + 1)) + DELETE_QUEUE="${DELETE_QUEUE}${ID}|${EMAIL}|$((AGE / 3600))"$'\n' fi fi - - STARTING_AFTER="$ID" done <<< "$ACCOUNTS" + + if [ "$HAS_MORE" = "true" ]; then + if [ -z "$PAGE_LAST_ID" ]; then + echo "Pagination indicated more results, but no last account id was returned" + exit 1 + fi + STARTING_AFTER="$PAGE_LAST_ID" + fi done + while IFS='|' read -r ID EMAIL AGE_HOURS; do + [ -z "${ID:-}" ] && continue + + if [ "$DRY_RUN_NORMALIZED" = "true" ]; then + echo "Would delete $ID ($EMAIL) - age: ${AGE_HOURS} hours" + WOULD_DELETE=$((WOULD_DELETE + 1)) + continue + fi + + echo "Deleting $ID ($EMAIL) - age: ${AGE_HOURS} hours" + if ! DELETE_RESPONSE=$(curl -sS -X DELETE -u "$STRIPE_SECRET_KEY:" "https://api.stripe.com/v1/accounts/$ID"); then + echo "Failed to delete $ID ($EMAIL): request failed" + FAILED_DELETES=$((FAILED_DELETES + 1)) + continue + fi + + DELETE_ERROR=$(echo "$DELETE_RESPONSE" | jq -r '.error.message // empty') + DELETED_FLAG=$(echo "$DELETE_RESPONSE" | jq -r '.deleted // false') + if [ -n "$DELETE_ERROR" ] || [ "$DELETED_FLAG" != "true" ]; then + echo "Failed to delete $ID ($EMAIL): ${DELETE_ERROR:-unexpected response}" + FAILED_DELETES=$((FAILED_DELETES + 1)) + continue + fi + + DELETED=$((DELETED + 1)) + done <<< "$DELETE_QUEUE" + + if [ "$DRY_RUN_NORMALIZED" = "true" ]; then + echo "Dry run complete. Would delete $WOULD_DELETE old test accounts" + exit 0 + fi + echo "Deleted $DELETED old test accounts" + if [ "$FAILED_DELETES" -gt 0 ]; then + echo "Failed to delete $FAILED_DELETES accounts" + exit 1 + fi diff --git a/.vscode/launch.json b/.vscode/launch.json index 698afa15d2b..398e21df654 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -4,64 +4,6 @@ // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ - { - "type": "node", - "request": "launch", - "name": "Backend", - "skipFiles": [ - "/**" - ], - "program": "${workspaceFolder}/.github/scripts/dev.js", - "args": [ - "--ghost" - ], - "autoAttachChildProcesses": true, - "outputCapture": "std", - "console": "integratedTerminal", - }, - { - "type": "node", - "request": "launch", - "name": "Ghost core + Admin", - "skipFiles": [ - "/**" - ], - "program": "${workspaceFolder}/.github/scripts/dev.js", - "autoAttachChildProcesses": true, - "outputCapture": "std", - "console": "integratedTerminal", - }, - { - "type": "node", - "request": "launch", - "name": "Full Dev", - "skipFiles": [ - "/**" - ], - "program": "${workspaceFolder}/.github/scripts/dev.js", - "args": [ - "--all" - ], - "autoAttachChildProcesses": true, - "outputCapture": "std", - "console": "integratedTerminal", - }, - { - "type": "node", - "request": "launch", - "name": "Full Offline Dev", - "skipFiles": [ - "/**" - ], - "program": "${workspaceFolder}/.github/scripts/dev.js", - "args": [ - "--all", - "--offline" - ], - "autoAttachChildProcesses": true, - "outputCapture": "std", - "console": "integratedTerminal", - }, { "args": [ "--require", diff --git a/AGENTS.md b/AGENTS.md index e5923674e90..552f6a91b67 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,8 +45,6 @@ Two categories of apps: yarn # Install dependencies yarn setup # First-time setup (installs deps + submodules) yarn dev # Start development (Docker backend + host frontend dev servers) -yarn dev:legacy # Local dev with legacy admin and without Docker (deprecated) -yarn dev:legacy:debug # yarn dev:legacy with DEBUG=@tryghost*,ghost:* enabled ``` ### Building @@ -86,18 +84,15 @@ cd ghost/admin && yarn lint # Lint Ember admin ### Database ```bash yarn knex-migrator migrate # Run database migrations -yarn reset:data # Reset database with test data (1000 members, 100 posts) -yarn reset:data:empty # Reset database with no data +yarn reset:data # Reset database with test data (1000 members, 100 posts) (requires yarn dev running) +yarn reset:data:empty # Reset database with no data (requires yarn dev running) ``` ### Docker ```bash -yarn docker:build # Build Docker images and delete ephemeral volumes -yarn docker:dev # Start Ghost in Docker with hot reload -yarn docker:shell # Open shell in Ghost container -yarn docker:mysql # Open MySQL CLI -yarn docker:test:unit # Run unit tests in Docker -yarn docker:reset # Reset all Docker volumes (including database) and restart +yarn docker:build # Build Docker images +yarn docker:clean # Stop containers, remove volumes and local images +yarn docker:down # Stop containers ``` ### How yarn dev works @@ -217,7 +212,7 @@ Users requested ability to switch themes for better accessibility - **Legacy:** `admin-x-design-system` (being phased out, avoid for new work) ### Analytics (Tinybird) -- **Local development:** `yarn docker:dev:analytics` (starts Tinybird + MySQL) +- **Local development:** `yarn dev:analytics` (starts Tinybird + MySQL) - **Config:** Add Tinybird config to `ghost/core/config.development.json` - **Scripts:** `ghost/core/core/server/data/tinybird/scripts/` - **Datafiles:** `ghost/core/core/server/data/tinybird/` diff --git a/apps/admin-x-design-system/src/global/form/koenig-editor-base.tsx b/apps/admin-x-design-system/src/global/form/koenig-editor-base.tsx index 21f1769e46b..600c519110f 100644 --- a/apps/admin-x-design-system/src/global/form/koenig-editor-base.tsx +++ b/apps/admin-x-design-system/src/global/form/koenig-editor-base.tsx @@ -6,7 +6,7 @@ import ErrorBoundary from '../error-boundary'; // eslint-disable-next-line @typescript-eslint/no-explicit-any export type FetchKoenigLexical = () => Promise -export type NodeType = 'DEFAULT_NODES' | 'BASIC_NODES' | 'MINIMAL_NODES' | 'EMAIL_NODES'; +export type NodeType = 'DEFAULT_NODES' | 'BASIC_NODES' | 'MINIMAL_NODES' | 'EMAIL_NODES' | 'EMAIL_EDITOR_NODES'; export interface KoenigEditorBaseProps { onBlur?: () => void @@ -126,7 +126,8 @@ export const KoenigWrapper: React.FC = ({ DEFAULT_NODES: koenig.DEFAULT_TRANSFORMERS, BASIC_NODES: koenig.BASIC_TRANSFORMERS, MINIMAL_NODES: koenig.MINIMAL_TRANSFORMERS, - EMAIL_NODES: koenig.EMAIL_TRANSFORMERS + EMAIL_NODES: koenig.EMAIL_TRANSFORMERS, + EMAIL_EDITOR_NODES: koenig.EMAIL_TRANSFORMERS }; const defaultNodes = nodes || 'DEFAULT_NODES'; diff --git a/apps/admin-x-design-system/src/global/popover.tsx b/apps/admin-x-design-system/src/global/popover.tsx index 7e35ae50551..c6b1b72855b 100644 --- a/apps/admin-x-design-system/src/global/popover.tsx +++ b/apps/admin-x-design-system/src/global/popover.tsx @@ -40,7 +40,7 @@ const Popover: React.FC = ({ {trigger} - {children} diff --git a/apps/admin-x-settings/package.json b/apps/admin-x-settings/package.json index 5e175371a04..9c9009810ab 100644 --- a/apps/admin-x-settings/package.json +++ b/apps/admin-x-settings/package.json @@ -44,7 +44,7 @@ "@tryghost/i18n": "0.0.0", "@tryghost/kg-unsplash-selector": "0.3.12", "@tryghost/limit-service": "1.4.1", - "@tryghost/nql": "0.12.8", + "@tryghost/nql": "0.12.10", "@tryghost/timezone-data": "0.4.12", "react": "18.3.1", "react-dom": "18.3.1", diff --git a/apps/admin-x-settings/src/components/settings/general/user-detail-modal.tsx b/apps/admin-x-settings/src/components/settings/general/user-detail-modal.tsx index feb5708535a..ed9debda825 100644 --- a/apps/admin-x-settings/src/components/settings/general/user-detail-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/general/user-detail-modal.tsx @@ -282,7 +282,7 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => { title: 'Are you sure you want to delete this user?', prompt: ( <> -

{_user.name || _user.email} will be permanently deleted and all their posts will be automatically assigned to the {owner.name}.

+

{_user.name || _user.email} will be permanently deleted and all their posts will be automatically assigned to {owner.name}.

To make these easy to find in the future, each post will be given an internal tag of #{user.slug}

), @@ -377,6 +377,15 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => { }); } + menuItems.push({ + id: 'view-user-activity', + label: 'View user activity', + onClick: () => { + mainModal.remove(); + updateRoute(`history/view/${formState.id}`); + } + }); + if (formState.id !== currentUser.id && ( (hasAdminAccess(currentUser) && !isOwnerUser(user)) || (isEditorUser(currentUser) && isAuthorOrContributor(user)) @@ -384,29 +393,21 @@ const UserDetailModalContent: React.FC<{user: User}> = ({user}) => { let suspendUserLabel = formState.status === 'inactive' ? 'Un-suspend user' : 'Suspend user'; menuItems.push({ - id: 'delete-user', - label: 'Delete user', - onClick: () => { - confirmDelete(user, {owner: ownerUser}); - } - }, { id: 'suspend-user', label: suspendUserLabel, onClick: () => { confirmSuspend(formState); } + }, { + id: 'delete-user', + label: 'Delete user', + destructive: true, + onClick: () => { + confirmDelete(user, {owner: ownerUser}); + } }); } - menuItems.push({ - id: 'view-user-activity', - label: 'View user activity', - onClick: () => { - mainModal.remove(); - updateRoute(`history/view/${formState.id}`); - } - }); - const noCoverButtonClasses = 'rounded text-sm flex flex-nowrap items-center justify-center px-3 h-8 transition-all cursor-pointer font-medium border border-grey-300 bg-transparent text-black dark:border-grey-800 dark:text-white'; const coverButtonClasses = 'flex flex-nowrap items-center justify-center px-3 h-8 opacity-80 hover:opacity-100 bg-[rgba(0,0,0,0.75)] rounded text-sm text-white transition-all cursor-pointer font-medium nowrap'; diff --git a/apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx b/apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx index 9e8c4f63699..5c7d0999479 100644 --- a/apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx +++ b/apps/admin-x-settings/src/components/settings/membership/member-emails/member-email-editor.tsx @@ -1,11 +1,11 @@ import React, {useCallback} from 'react'; -import {KoenigEditorBase, type KoenigInstance, LoadingIndicator, type NodeType} from '@tryghost/admin-x-design-system'; +import useFeatureFlag from '../../../../hooks/use-feature-flag'; +import {KoenigEditorBase, type KoenigInstance, LoadingIndicator} from '@tryghost/admin-x-design-system'; import {cn} from '@tryghost/shade'; export interface MemberEmailsEditorProps { value?: string; placeholder?: string; - nodes?: NodeType; singleParagraph?: boolean; className?: string; onChange?: (value: string) => void; @@ -14,11 +14,11 @@ export interface MemberEmailsEditorProps { const MemberEmailsEditor: React.FC = ({ value, placeholder, - nodes = 'EMAIL_NODES', singleParagraph = false, className, onChange }) => { + const welcomeEmailEditorEnabled = useFeatureFlag('welcomeEmailEditor'); const baseEditorStyles = cn( // Base typography 'text-[1.6rem] leading-[1.6] tracking-[-0.01em]', @@ -60,20 +60,39 @@ const MemberEmailsEditor: React.FC = ({
} - nodes={nodes} - placeholder={placeholder} + nodes={welcomeEmailEditorEnabled ? 'EMAIL_EDITOR_NODES' : 'EMAIL_NODES'} + placeholder={placeholder} singleParagraph={singleParagraph} onChange={handleChange} > {(koenig: KoenigInstance) => ( <> - - + + + + + {welcomeEmailEditorEnabled && ( + <> + + + + + + + + {/* TODO: we need to wire up card config to enable snippets */} + {/* */} + {/* TODO: we need to wire up a fileUploader prop + fileUploadHook to enable files+images */} + {/* */} + {/* */} + + )} + )} diff --git a/apps/admin-x-settings/src/main-content.tsx b/apps/admin-x-settings/src/main-content.tsx index ec58ad46342..1a3f2209abc 100644 --- a/apps/admin-x-settings/src/main-content.tsx +++ b/apps/admin-x-settings/src/main-content.tsx @@ -14,7 +14,7 @@ const Page: React.FC<{children: ReactNode}> = ({children}) => {
-
+
{children}
; diff --git a/apps/admin/package.json b/apps/admin/package.json index 82da3936493..db452023315 100644 --- a/apps/admin/package.json +++ b/apps/admin/package.json @@ -15,7 +15,7 @@ "@tryghost/activitypub": "*", "@tryghost/admin-x-framework": "*", "@tryghost/admin-x-settings": "*", - "@tryghost/koenig-lexical": "1.7.12", + "@tryghost/koenig-lexical": "1.7.13", "@tryghost/posts": "*", "@tryghost/shade": "*", "@tryghost/stats": "*", diff --git a/apps/portal/package.json b/apps/portal/package.json index aca281ed1f0..56a4de7ed5e 100644 --- a/apps/portal/package.json +++ b/apps/portal/package.json @@ -1,6 +1,6 @@ { "name": "@tryghost/portal", - "version": "2.64.4", + "version": "2.64.5", "license": "MIT", "repository": "https://github.com/TryGhost/Ghost", "author": "Ghost Foundation", diff --git a/compose.object-storage.yml b/compose.object-storage.yml deleted file mode 100644 index fcc84d82213..00000000000 --- a/compose.object-storage.yml +++ /dev/null @@ -1,64 +0,0 @@ -services: - ghost: - extends: - file: compose.yml - service: ghost - profiles: [object-storage] - environment: - - storage__media__adapter=S3Storage - - storage__media__staticFileURLPrefix=content/media - - storage__files__adapter=S3Storage - - storage__files__staticFileURLPrefix=content/files - - storage__S3Storage__bucket=ghost-dev - - storage__S3Storage__region=us-east-1 - - storage__S3Storage__tenantPrefix=ab/ab1234567890abcdef1234567890abcd - - storage__S3Storage__forcePathStyle=true - - storage__S3Storage__cdnUrl=http://127.0.0.1:9000/ghost-dev - - storage__S3Storage__staticFileURLPrefix=content/images - - storage__S3Storage__endpoint=http://minio:9000 - - storage__S3Storage__accessKeyId=minio-user - - storage__S3Storage__secretAccessKey=minio-pass - - urls__media=http://127.0.0.1:9000/ghost-dev/ab/ab1234567890abcdef1234567890abcd - - urls__files=http://127.0.0.1:9000/ghost-dev/ab/ab1234567890abcdef1234567890abcd - depends_on: - minio: - condition: service_healthy - minio-setup: - condition: service_completed_successfully - - minio: - profiles: [object-storage] - image: minio/minio:RELEASE.2024-12-13T22-19-12Z - container_name: ghost-minio - command: server /data --console-address ':9001' - restart: always - environment: - - MINIO_ROOT_USER=minio-user - - MINIO_ROOT_PASSWORD=minio-pass - ports: - - '9000:9000' - - '9001:9001' - volumes: - - minio-data:/data - healthcheck: - test: ['CMD', 'curl', '-f', 'http://localhost:9000/minio/health/ready'] - interval: 1s - retries: 120 - - minio-setup: - profiles: [object-storage] - image: minio/mc - entrypoint: ['/bin/sh', '/setup.sh'] - environment: - - MINIO_ROOT_USER=minio-user - - MINIO_ROOT_PASSWORD=minio-pass - - MINIO_BUCKET=ghost-dev - volumes: - - ./docker/minio/setup.sh:/setup.sh:ro - depends_on: - minio: - condition: service_healthy - restart: 'no' - -volumes: - minio-data: {} diff --git a/compose.yml b/compose.yml deleted file mode 100644 index d62cc0280ff..00000000000 --- a/compose.yml +++ /dev/null @@ -1,328 +0,0 @@ -# Development Docker Compose configuration for Ghost Monorepo -# Not intended for production use. See https://github.com/tryghost/ghost-docker for production-ready self-hosting setup. -name: ghost - -# Template to share volumes and environment variable between all services running the same base image -x-service-template: &service-template - volumes: - - .:/home/ghost - - ${SSH_AUTH_SOCK}:/ssh-agent - - ${HOME}/.gitconfig:/root/.gitconfig:ro - - ${HOME}/.yalc:/root/.yalc - - shared-config:/mnt/shared-config:ro - - node_modules_yarn_lock_hash:/home/ghost/.yarnhash:delegated - - node_modules_ghost_root:/home/ghost/node_modules:delegated - - node_modules_ghost_admin:/home/ghost/ghost/admin/node_modules:delegated - - node_modules_ghost_core:/home/ghost/ghost/core/node_modules:delegated - - node_modules_ghost_i18n:/home/ghost/ghost/i18n/node_modules:delegated - - node_modules_e2e:/home/ghost/e2e/node_modules:delegated - - node_modules_apps_activitypub:/home/ghost/apps/activitypub/node_modules:delegated - - node_modules_apps_admin-x-design-system:/home/ghost/apps/admin-x-design-system/node_modules:delegated - - node_modules_apps_admin-x-framework:/home/ghost/apps/admin-x-framework/node_modules:delegated - - node_modules_apps_admin-x-settings:/home/ghost/apps/admin-x-settings/node_modules:delegated - - node_modules_apps_announcement-bar:/home/ghost/apps/announcement-bar/node_modules:delegated - - node_modules_apps_comments-ui:/home/ghost/apps/comments-ui/node_modules:delegated - - node_modules_apps_portal:/home/ghost/apps/portal/node_modules:delegated - - node_modules_apps_posts:/home/ghost/apps/posts/node_modules:delegated - - node_modules_apps_shade:/home/ghost/apps/shade/node_modules:delegated - - node_modules_apps_signup-form:/home/ghost/apps/signup-form/node_modules:delegated - - node_modules_apps_sodo-search:/home/ghost/apps/sodo-search/node_modules:delegated - - node_modules_apps_stats:/home/ghost/apps/stats/node_modules:delegated - environment: - - DEBUG=${DEBUG:-} - - SSH_AUTH_SOCK=/ssh-agent - - NX_DAEMON=${NX_DAEMON:-true} - - GHOST_DEV_IS_DOCKER=true - - GHOST_DEV_APP_FLAGS=${GHOST_DEV_APP_FLAGS:-} - - GHOST_UPSTREAM=${GHOST_UPSTREAM:-} - - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} - - STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY:-} - - STRIPE_ACCOUNT_ID=${STRIPE_ACCOUNT_ID:-} - -services: - server: - <<: *service-template - image: ghost-monorepo:latest - build: - target: development - entrypoint: [ "/home/ghost/docker/development.entrypoint.sh" ] - working_dir: /home/ghost/ghost/core - command: [ "yarn", "dev" ] - ports: - - "2368:2368" - profiles: [ split, all ] - tty: true - depends_on: - mysql: - condition: service_healthy - redis: - condition: service_healthy - tinybird-local: - condition: service_healthy - required: false - analytics: - condition: service_healthy - required: false - tb-cli: - condition: service_completed_successfully - required: false - stripe: - condition: service_healthy - required: false - environment: - - DEBUG=${DEBUG:-} - - SSH_AUTH_SOCK=/ssh-agent - - NX_DAEMON=false - - GHOST_DEV_IS_DOCKER=true - - GHOST_DEV_APP_FLAGS=${GHOST_DEV_APP_FLAGS:-} - - GHOST_UPSTREAM=${GHOST_UPSTREAM:-} - - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} - - STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY:-} - - STRIPE_ACCOUNT_ID=${STRIPE_ACCOUNT_ID:-} - - TB_HOST=${TB_HOST:-http://tinybird-local:7181} - - TB_LOCAL_HOST=${TB_LOCAL_HOST:-tinybird-local} - - tinybird__stats__endpoint=http://tinybird-local:7181 - - tinybird__stats__endpointBrowser=http://localhost:7181 - - tinybird__tracker__endpoint=http://localhost/.ghost/analytics/api/v1/page_hit - - admin: - <<: *service-template - image: ghost-monorepo:latest - entrypoint: [ "/home/ghost/docker/development.entrypoint.sh" ] - working_dir: /home/ghost/ghost/admin - command: [ "yarn", "dev" ] - ports: - - "4200:4200" - - "4201:4201" - profiles: [ split, all ] - tty: true - - admin-apps: - <<: *service-template - image: ghost-monorepo:latest - entrypoint: [ "/home/ghost/docker/development.entrypoint.sh" ] - working_dir: /home/ghost - command: [ "node", "/home/ghost/docker/watch-admin-apps.js" ] - profiles: [ split, all ] - tty: true - restart: always - environment: - - CHOKIDAR_USEPOLLING=true - - CHOKIDAR_INTERVAL=1000 - - FORCE_COLOR=1 - - caddy: - image: caddy:latest - container_name: ghost-caddy - profiles: [ split, all ] - ports: - - "80:80" - - "443:443" - volumes: - - ./docker/caddy/Caddyfile:/etc/caddy/Caddyfile:ro - - caddy_data:/data - environment: - - ANALYTICS_PROXY_TARGET=${ANALYTICS_PROXY_TARGET:-analytics:3000} - restart: always - - ghost: - <<: *service-template - image: ghost-monorepo:latest - build: - target: development - entrypoint: [ "/home/ghost/docker/development.entrypoint.sh" ] - command: [ "yarn", "dev" ] - ports: - - "2368:2368" # Ghost - - "4200:4200" # Admin - - "4201:4201" # Admin tests - - "4175:4175" # Portal - - "4176:4176" # Portal HTTPS - - "4177:4177" # Announcement bar - - "4178:4178" # Search - - "6174:6174" # Signup form - - "7173:7173" # Comments - - "7174:7174" # Comments HTTPS - profiles: [ ghost, all] - tty: true - depends_on: - mysql: - condition: service_healthy - redis: - condition: service_healthy - - mysql: - image: mysql:8.4.5 - container_name: ghost-mysql - command: --innodb-buffer-pool-size=1G --innodb-log-buffer-size=500M --innodb-change-buffer-max-size=50 --innodb-flush-log-at-trx_commit=0 --innodb-flush-method=O_DIRECT - ports: - - 3306:3306 - environment: - MYSQL_ROOT_PASSWORD: root - MYSQL_DATABASE: ghost - restart: always - volumes: - - ./docker/mysql-preload:/docker-entrypoint-initdb.d - - mysql-data:/var/lib/mysql - healthcheck: - test: mysql -uroot -proot ghost -e 'select 1' - interval: 1s - retries: 120 - - redis: - image: redis:7.0 - container_name: ghost-redis - ports: - - 6379:6379 - restart: always - volumes: - - redis-data:/data - healthcheck: - test: - - CMD - - redis-cli - - --raw - - incr - - ping - interval: 1s - retries: 120 - - analytics: - profiles: [ analytics, all ] - image: ghost/traffic-analytics:1.0.44 - platform: linux/amd64 - command: ["node", "--enable-source-maps", "dist/server.js"] - entrypoint: [ "/app/entrypoint.sh" ] - container_name: ghost-analytics - ports: - - "3000:3000" - healthcheck: - # Simpler: use Node's global fetch (Node 18+) - test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000').then(r=>process.exit(r.status<500?0:1)).catch(()=>process.exit(1))\"" ] - interval: 1s - retries: 120 - volumes: - - ./docker/analytics/entrypoint.sh:/app/entrypoint.sh:ro - - shared-config:/mnt/shared-config:ro - environment: - - PROXY_TARGET=http://tinybird-local:7181/v0/events - depends_on: - tinybird-local: - condition: service_healthy - tb-cli: - condition: service_completed_successfully - - tb-cli: - build: - context: . - dockerfile: ./docker/tb-cli/Dockerfile - profiles: [ analytics, all ] - working_dir: /home/tinybird - tty: true - container_name: ghost-tb-cli - environment: - - TB_HOST=http://tinybird-local:7181 - - TB_LOCAL_HOST=tinybird-local - volumes: - - ./ghost/core/core/server/data/tinybird:/home/tinybird - - shared-config:/mnt/shared-config - depends_on: - tinybird-local: - condition: service_healthy - - tinybird-local: - profiles: [ analytics, all ] - image: tinybirdco/tinybird-local:latest - platform: linux/amd64 - ports: - - "7181:7181" - stop_grace_period: 2s - healthcheck: - test: ["CMD", "curl", "-f", "http://localhost:7181/v0/health" ] - interval: 1s - timeout: 5s - retries: 120 - - prometheus: - profiles: [ monitoring, all ] - image: prom/prometheus:v2.55.1 - container_name: ghost-prometheus - ports: - - 9090:9090 - restart: always - volumes: - - ./docker/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml - - grafana: - profiles: [ monitoring, all ] - image: grafana/grafana:8.5.27 - container_name: ghost-grafana - ports: - - 3000:3000 - restart: always - environment: - - GF_AUTH_ANONYMOUS_ENABLED=true - - GF_AUTH_ANONYMOUS_ORG_ROLE=Admin - volumes: - - ./docker/grafana/datasources:/etc/grafana/provisioning/datasources - - ./docker/grafana/dashboard.yml:/etc/grafana/provisioning/dashboards/main.yaml - - ./docker/grafana/dashboards:/var/lib/grafana/dashboards - - pushgateway: - profiles: [ monitoring, all ] - image: prom/pushgateway:v1.11.1 - container_name: ghost-pushgateway - ports: - - 9091:9091 - - mailpit: - image: axllent/mailpit - platform: linux/amd64 - container_name: ghost-mailpit - profiles: [ ghost, split, all ] - ports: - - "1025:1025" # SMTP server - - "8025:8025" # Web interface - restart: always - - stripe: - image: stripe/stripe-cli:latest - container_name: ghost-stripe - profiles: [ stripe, all ] - entrypoint: [ "/entrypoint.sh" ] - volumes: - - ./docker/stripe/entrypoint.sh:/entrypoint.sh:ro - - shared-config:/mnt/shared-config - environment: - - GHOST_URL=http://server:2368 - - STRIPE_SECRET_KEY=${STRIPE_SECRET_KEY:-} - - STRIPE_PUBLISHABLE_KEY=${STRIPE_PUBLISHABLE_KEY:-} - - STRIPE_ACCOUNT_ID=${STRIPE_ACCOUNT_ID:-} - healthcheck: - test: ["CMD", "test", "-f", "/mnt/shared-config/.env.stripe"] - interval: 1s - retries: 120 - -volumes: - mysql-data: {} - redis-data: {} - shared-config: {} - caddy_data: {} - node_modules_yarn_lock_hash: {} - node_modules_ghost_root: {} - node_modules_ghost_admin: {} - node_modules_ghost_core: {} - node_modules_ghost_i18n: {} - node_modules_e2e: {} - node_modules_apps_activitypub: {} - node_modules_apps_admin-x-design-system: {} - node_modules_apps_admin-x-framework: {} - node_modules_apps_admin-x-settings: {} - node_modules_apps_announcement-bar: {} - node_modules_apps_comments-ui: {} - node_modules_apps_portal: {} - node_modules_apps_posts: {} - node_modules_apps_shade: {} - node_modules_apps_signup-form: {} - node_modules_apps_sodo-search: {} - node_modules_apps_stats: {} diff --git a/e2e/package.json b/e2e/package.json index 6e66567e4de..b838966750f 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -12,7 +12,7 @@ "docker:build:ghost": "cd .. && docker build -t ghost-monorepo:latest -f Dockerfile .", "docker:update": "docker compose pull && docker compose up -d --force-recreate", "prepare": "tsc --noEmit", - "pretest": "(test -n \"$GHOST_E2E_SKIP_BUILD\" || test -n \"$CI\") && echo 'Skipping Docker build (GHOST_E2E_SKIP_BUILD or CI is set)' || docker compose -f ../compose.yml build ghost tb-cli", + "pretest": "(test -n \"$GHOST_E2E_SKIP_BUILD\" || test -n \"$CI\") && echo 'Skipping Docker build (GHOST_E2E_SKIP_BUILD or CI is set)' || yarn docker:build:ghost", "test": "playwright test --project=main", "test:analytics": "playwright test --project=analytics", "test:all": "playwright test --project=main --project=analytics", diff --git a/ghost/admin/package.json b/ghost/admin/package.json index c81d40d9f79..43fcac0c8d1 100644 --- a/ghost/admin/package.json +++ b/ghost/admin/package.json @@ -49,10 +49,10 @@ "@tryghost/helpers": "1.1.97", "@tryghost/kg-clean-basic-html": "4.2.13", "@tryghost/kg-converters": "1.1.13", - "@tryghost/koenig-lexical": "1.7.12", + "@tryghost/koenig-lexical": "1.7.13", "@tryghost/limit-service": "1.4.1", "@tryghost/members-csv": "2.0.3", - "@tryghost/nql": "0.12.8", + "@tryghost/nql": "0.12.10", "@tryghost/nql-lang": "0.6.4", "@tryghost/string": "0.2.17", "@tryghost/timezone-data": "0.4.12", diff --git a/ghost/core/core/server/services/members/members-api/controllers/router-controller.js b/ghost/core/core/server/services/members/members-api/controllers/router-controller.js index 6d37f7793e7..a1c93c5fa04 100644 --- a/ghost/core/core/server/services/members/members-api/controllers/router-controller.js +++ b/ghost/core/core/server/services/members/members-api/controllers/router-controller.js @@ -6,6 +6,7 @@ const {BadRequestError, NoPermissionError, UnauthorizedError, DisabledFeatureErr const errors = require('@tryghost/errors'); const {isEmail} = require('@tryghost/validator'); const normalizeEmail = require('../utils/normalize-email'); +const hasActiveOffer = require('../utils/has-active-offer'); const {getInboxLinks} = require('../../../../lib/get-inbox-links'); const {SIGNUP_CONTEXTS} = require('../../../lib/member-signup-contexts'); /** @typedef {import('../../../lib/member-signup-contexts').SignupContext} SignupContext */ @@ -1024,14 +1025,8 @@ module.exports = class RouterController { return sendNoOffersAvailable(); } - // If subscription already has an offer applied (e.g. signup offer), don't show retention offers - if (activeSubscription.get('offer_id')) { - return sendNoOffersAvailable(); - } - - // If subscription is in a trial period (either offer-based or tier-based), don't show retention offers - const trialEndAt = activeSubscription.get('trial_end_at'); - if (trialEndAt && trialEndAt > new Date()) { + // If subscription has an active offer, don't show retention offers + if (await hasActiveOffer(activeSubscription, this._offersAPI)) { return sendNoOffersAvailable(); } diff --git a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js index f967ecba3d8..6f1432fb9cf 100644 --- a/ghost/core/core/server/services/members/members-api/repositories/member-repository.js +++ b/ghost/core/core/server/services/members/members-api/repositories/member-repository.js @@ -9,6 +9,7 @@ const {NotFoundError} = require('@tryghost/errors'); const validator = require('@tryghost/validator'); const crypto = require('crypto'); const addCalendarMonths = require('../utils/add-calendar-months'); +const hasActiveOffer = require('../utils/has-active-offer'); const StartOutboxProcessingEvent = require('../../../outbox/events/start-outbox-processing-event'); const {MEMBER_WELCOME_EMAIL_SLUGS} = require('../../../member-welcome-emails/constants'); const messages = { @@ -32,7 +33,6 @@ const messages = { offerAlreadyRedeemed: 'This offer has already been redeemed on this subscription', subscriptionNotActive: 'Cannot apply offer to an inactive subscription', subscriptionHasOffer: 'Subscription already has an offer applied', - subscriptionInTrial: 'Cannot apply offer to a subscription in a trial period', subscriptionCancelling: 'Cannot apply retention offer to a subscription that is already cancelling' }; @@ -1730,21 +1730,13 @@ module.exports = class MemberRepository { }); } - // Check subscription doesn't already have an offer - if (subscriptionModel.get('offer_id')) { + // Check subscription doesn't already have an active offer + if (await hasActiveOffer(subscriptionModel, this._offersAPI)) { throw new errors.BadRequestError({ message: tpl(messages.subscriptionHasOffer) }); } - // Check subscription is not in a trial period - const trialEndAt = subscriptionModel.get('trial_end_at'); - if (trialEndAt && trialEndAt > new Date()) { - throw new errors.BadRequestError({ - message: tpl(messages.subscriptionInTrial) - }); - } - // Get tier and cadence from subscription const stripePrice = subscriptionModel.related('stripePrice'); const stripeProduct = stripePrice.related('stripeProduct'); diff --git a/ghost/core/core/server/services/members/members-api/utils/has-active-offer.js b/ghost/core/core/server/services/members/members-api/utils/has-active-offer.js new file mode 100644 index 00000000000..a0de4f4680c --- /dev/null +++ b/ghost/core/core/server/services/members/members-api/utils/has-active-offer.js @@ -0,0 +1,60 @@ +/** + * Determines if a subscription currently has an active offer. + * Uses discount_start/discount_end (synced from Stripe) when available, + * falls back to offer duration lookup for legacy data (pre-6.16). + * + * @param {object} subscriptionModel - Bookshelf model for members_stripe_customers_subscriptions + * @param {object} offersAPI - OffersAPI instance with getOffer() + * @returns {Promise} + */ +module.exports = async function hasActiveOffer(subscriptionModel, offersAPI) { + const discountStart = subscriptionModel.get('discount_start'); + const discountEnd = subscriptionModel.get('discount_end'); + const trialEndAt = subscriptionModel.get('trial_end_at'); + + // Check for active Stripe discount (post-6.16 data) + if (discountStart) { + return !discountEnd || new Date(discountEnd) > new Date(); + } + + // Check for active trial (trial/free_months offers) + if (trialEndAt && new Date(trialEndAt) > new Date()) { + return true; + } + + // Fallback: legacy data where discount_start was never populated + const offerId = subscriptionModel.get('offer_id'); + if (!offerId) { + return false; + } + + // Look up the offer to determine if it's still active based on duration + try { + const offer = await offersAPI.getOffer({id: offerId}); + if (!offer) { + return false; + } + + if (offer.duration === 'forever') { + return true; + } + + if (offer.duration === 'once') { + return false; // once = already applied and expired + } + + if (offer.duration === 'repeating' && offer.duration_in_months > 0) { + const startDate = new Date(subscriptionModel.get('start_date')); + const end = new Date(startDate); + + end.setUTCMonth(end.getUTCMonth() + offer.duration_in_months); + + return new Date() < end; + } + } catch (e) { + // If we can't look up the offer, err on the side of blocking + return true; + } + + return false; +}; diff --git a/ghost/core/package.json b/ghost/core/package.json index 0aa8b874574..ddf078e8b3a 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -105,7 +105,7 @@ "@tryghost/mw-error-handler": "1.0.7", "@tryghost/mw-vhost": "1.0.1", "@tryghost/nodemailer": "0.3.48", - "@tryghost/nql": "0.12.8", + "@tryghost/nql": "0.12.10", "@tryghost/parse-email-address": "0.0.0", "@tryghost/pretty-cli": "1.2.47", "@tryghost/prometheus-metrics": "1.0.2", diff --git a/ghost/core/test/e2e-api/admin/comments.test.js b/ghost/core/test/e2e-api/admin/comments.test.js index 8d4b2d0c737..e4b96142c7b 100644 --- a/ghost/core/test/e2e-api/admin/comments.test.js +++ b/ghost/core/test/e2e-api/admin/comments.test.js @@ -1675,14 +1675,16 @@ describe(`Admin Comments API`, function () { html: '

Reported comment

' }); - // Add reports from different members + // Add reports from different members with explicit timestamps to ensure deterministic ordering await models.CommentReport.add({ comment_id: comment.id, - member_id: fixtureManager.get('members', 1).id + member_id: fixtureManager.get('members', 1).id, + created_at: new Date('2023-06-01') }); await models.CommentReport.add({ comment_id: comment.id, - member_id: fixtureManager.get('members', 2).id + member_id: fixtureManager.get('members', 2).id, + created_at: new Date('2023-01-01') }); await adminApi.get(`/comments/${comment.id}/reports/`) @@ -1798,14 +1800,16 @@ describe(`Admin Comments API`, function () { html: '

Liked comment

' }); - // Add likes from different members + // Add likes from different members with explicit timestamps to ensure deterministic ordering await models.CommentLike.add({ comment_id: comment.id, - member_id: fixtureManager.get('members', 1).id + member_id: fixtureManager.get('members', 1).id, + created_at: new Date('2023-06-01') }); await models.CommentLike.add({ comment_id: comment.id, - member_id: fixtureManager.get('members', 2).id + member_id: fixtureManager.get('members', 2).id, + created_at: new Date('2023-01-01') }); await adminApi.get(`/comments/${comment.id}/likes/`) diff --git a/ghost/core/test/e2e-api/admin/members-importer.test.js b/ghost/core/test/e2e-api/admin/members-importer.test.js index 9903e1efa9c..d603ffd1f31 100644 --- a/ghost/core/test/e2e-api/admin/members-importer.test.js +++ b/ghost/core/test/e2e-api/admin/members-importer.test.js @@ -118,7 +118,7 @@ describe('Members Importer API', function () { // assertExists(jsonResponse.meta.stats); // assertExists(jsonResponse.meta.import_label); - // jsonResponse.meta.stats.imported.should.equal(8); + // assert.equal(jsonResponse.meta.stats.imported, 8); // return jsonResponse.meta.import_label; // }) @@ -134,7 +134,7 @@ describe('Members Importer API', function () { // const jsonResponse = res.body; // assertExists(jsonResponse); // assertExists(jsonResponse.members); - // jsonResponse.members.should.have.length(8); + // assert.equal(jsonResponse.members.length, 8); // }) // .then(() => importLabel); // }) @@ -168,7 +168,7 @@ describe('Members Importer API', function () { // const jsonResponse = res.body; // assertExists(jsonResponse); // assertExists(jsonResponse.members); - // jsonResponse.members.should.have.length(0); + // assert.equal(jsonResponse.members.length, 0); // }); // }); // }); diff --git a/ghost/core/test/e2e-api/members/member-offers.test.js b/ghost/core/test/e2e-api/members/member-offers.test.js index f18419d8c5a..509f5da4ca2 100644 --- a/ghost/core/test/e2e-api/members/member-offers.test.js +++ b/ghost/core/test/e2e-api/members/member-offers.test.js @@ -238,7 +238,7 @@ describe('Members API - Member Offers', function () { } }); - it('returns empty offers if subscription already has an offer applied', async function () { + it('returns empty offers if subscription has an active discount', async function () { // Get the paid member's subscription const member = await models.Member.findOne({email: 'paid@test.com'}, { withRelated: [ @@ -273,7 +273,7 @@ describe('Members API - Member Offers', function () { redemption_type: 'retention' }); - // Create a signup offer and apply it to the subscription + // Create a signup offer and apply it with an active discount window const signupOffer = await models.Offer.add({ name: 'Signup Offer', code: 'signup-offer', @@ -289,8 +289,15 @@ describe('Members API - Member Offers', function () { redemption_type: 'signup' }); - // Set the offer_id on the subscription - await subscription.save({offer_id: signupOffer.id}, {patch: true}); + // CASE: Set offer_id AND discount_start/end to simulate an active discount + const now = new Date(); + const discountStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); // 7 days ago + const discountEnd = new Date(now.getTime() + 23 * 24 * 60 * 60 * 1000); // 23 days from now + await subscription.save({ + offer_id: signupOffer.id, + discount_start: discountStart, + discount_end: discountEnd + }, {patch: true}); try { const token = await getIdentityToken('paid@test.com'); @@ -300,16 +307,96 @@ describe('Members API - Member Offers', function () { .body({identity: token}) .expectStatus(200); - // Should not return retention offers if subscription already has an offer + // Should not return retention offers if subscription has an active discount assert.deepEqual(body, {offers: []}); } finally { // Clean up - await subscription.save({offer_id: null}, {patch: true}); + await subscription.save({offer_id: null, discount_start: null, discount_end: null}, {patch: true}); await models.Offer.destroy({id: offer.id}); await models.Offer.destroy({id: signupOffer.id}); } }); + it('returns retention offers if subscription has an expired discount', async function () { + // Get the paid member's subscription + const member = await models.Member.findOne({email: 'paid@test.com'}, { + withRelated: [ + 'stripeSubscriptions', + 'stripeSubscriptions.stripePrice', + 'stripeSubscriptions.stripePrice.stripeProduct', + 'stripeSubscriptions.stripePrice.stripeProduct.product' + ] + }); + + const subscription = member.related('stripeSubscriptions').models[0]; + const stripePrice = subscription.related('stripePrice'); + const stripeProduct = stripePrice.related('stripeProduct'); + const product = stripeProduct.related('product'); + + const tierId = product.id; + const cadence = stripePrice.get('interval'); + + // Create a retention offer + const retentionOffer = await models.Offer.add({ + name: 'Test Retention Expired', + code: 'test-retention-expired', + portal_title: '20% off', + portal_description: 'Stay with us!', + discount_type: 'percent', + discount_amount: 20, + duration: 'once', + interval: cadence, + product_id: null, + currency: null, + active: true, + redemption_type: 'retention' + }); + + // Create a signup offer with an expired discount window + const signupOffer = await models.Offer.add({ + name: 'Expired Signup Offer', + code: 'expired-signup-offer', + portal_title: '10% off', + portal_description: 'Welcome!', + discount_type: 'percent', + discount_amount: 10, + duration: 'once', + interval: cadence, + product_id: tierId, + currency: null, + active: true, + redemption_type: 'signup' + }); + + // CASE: Set offer_id with expired discount_start/end + const now = new Date(); + const discountStart = new Date(now.getTime() - 60 * 24 * 60 * 60 * 1000); // 60 days ago + const discountEnd = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); // 30 days ago + await subscription.save({ + offer_id: signupOffer.id, + discount_start: discountStart, + discount_end: discountEnd + }, {patch: true}); + + try { + const token = await getIdentityToken('paid@test.com'); + + const {body} = await membersAgent + .post('/api/member/offers') + .body({identity: token}) + .expectStatus(200); + + // Should return retention offers since the old discount has expired + assert.equal(body.offers.length, 1); + assert.equal(body.offers[0].id, retentionOffer.id); + } finally { + // Clean up + await subscription.save({offer_id: null, discount_start: null, discount_end: null}, {patch: true}); + await models.Offer.destroy({id: retentionOffer.id}); + await models.Offer.destroy({id: signupOffer.id}); + } + }); + it('returns empty offers if subscription is already set to cancel', async function () { const {subscription} = await getMemberSubscription('paid@test.com'); const stripePrice = subscription.related('stripePrice'); @@ -400,7 +487,7 @@ describe('Members API - Member Offers', function () { .expectStatus(404); }); - it('returns 400 when subscription already has an offer', async function () { + it('returns 400 when subscription has an active discount', async function () { const {subscription} = await getMemberSubscription('paid@test.com'); const stripePrice = subscription.related('stripePrice'); const stripeProduct = stripePrice.related('stripeProduct'); @@ -426,7 +513,7 @@ describe('Members API - Member Offers', function () { redemption_type: 'retention' }); - // Create another offer and apply it to subscription + // Create another offer and apply it with an active discount window const existingOffer = await models.Offer.add({ name: 'Existing Offer', code: 'existing-offer', @@ -442,7 +529,15 @@ describe('Members API - Member Offers', function () { redemption_type: 'signup' }); - await subscription.save({offer_id: existingOffer.id}, {patch: true}); + // CASE: Set offer_id AND discount_start/end to simulate an active discount + const now = new Date(); + const discountStart = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); + const discountEnd = new Date(now.getTime() + 23 * 24 * 60 * 60 * 1000); + await subscription.save({ + offer_id: existingOffer.id, + discount_start: discountStart, + discount_end: discountEnd + }, {patch: true}); try { const token = await getIdentityToken('paid@test.com'); @@ -452,7 +547,7 @@ describe('Members API - Member Offers', function () { .body({identity: token, offer_id: retentionOffer.id}) .expectStatus(400); } finally { - await subscription.save({offer_id: null}, {patch: true}); + await subscription.save({offer_id: null, discount_start: null, discount_end: null}, {patch: true}); await models.Offer.destroy({id: retentionOffer.id}); await models.Offer.destroy({id: existingOffer.id}); } diff --git a/ghost/core/test/e2e-frontend/default-routes.test.js b/ghost/core/test/e2e-frontend/default-routes.test.js index 998e6e39913..6c280ae786b 100644 --- a/ghost/core/test/e2e-frontend/default-routes.test.js +++ b/ghost/core/test/e2e-frontend/default-routes.test.js @@ -7,7 +7,6 @@ // But then again testing real code, rather than mock code, might be more useful... const assert = require('node:assert/strict'); const {assertExists} = require('../utils/assertions'); -const should = require('should'); const sinon = require('sinon'); const supertest = require('supertest'); const moment = require('moment'); @@ -129,7 +128,7 @@ describe('Default Frontend routing', function () { assert(res.text.includes('Start here for a quick overview of everything you need to know')); assert.match(res.text, /]*?>Start here for a quick overview of everything you need to know<\/h1>/); // We should write a single test for this, or encapsulate it as an assertion - // E.g. res.text.should.not.containInvalidUrls() + // E.g. assertDoesNotContainInvalidUrls(res.text) assert(!res.text.includes('__GHOST_URL__')); }); }); @@ -529,7 +528,7 @@ describe('Default Frontend routing', function () { .expect(200) .expect(assertCorrectFrontendHeaders) .expect((res) => { - res.text.should.match('User-agent: *\nDisallow: /'); + assert(res.text.includes('User-agent: *\nDisallow: /')); }); }); }); diff --git a/ghost/core/test/e2e-frontend/helpers/get.test.js b/ghost/core/test/e2e-frontend/helpers/get.test.js index 45fc927817a..35316ea8d25 100644 --- a/ghost/core/test/e2e-frontend/helpers/get.test.js +++ b/ghost/core/test/e2e-frontend/helpers/get.test.js @@ -1,6 +1,5 @@ const assert = require('node:assert/strict'); -const {assertExists} = require('../../utils/assertions'); -const should = require('should'); +const {assertExists, assertObjectMatches} = require('../../utils/assertions'); const sinon = require('sinon'); const testUtils = require('../../utils'); const models = require('../../../core/server/models/index'); @@ -39,8 +38,7 @@ function testPosts(posts, map) { const post = posts.find(p => p.id === postID); assertExists(post); - - post.should.match(expectData); + assertObjectMatches(post, expectData); } } diff --git a/ghost/core/test/e2e-frontend/members.test.js b/ghost/core/test/e2e-frontend/members.test.js index 1c06cc81f5d..3d0945f705a 100644 --- a/ghost/core/test/e2e-frontend/members.test.js +++ b/ghost/core/test/e2e-frontend/members.test.js @@ -1,6 +1,5 @@ const assert = require('node:assert/strict'); const {assertExists} = require('../utils/assertions'); -const should = require('should'); const sinon = require('sinon'); const supertest = require('supertest'); const moment = require('moment'); @@ -232,19 +231,19 @@ describe('Front-end members behavior', function () { const getJsonResponse = getRes.body; assertExists(getJsonResponse); - getJsonResponse.should.have.properties(['email', 'uuid', 'status', 'name', 'newsletters']); + assert('email' in getJsonResponse); + assert('uuid' in getJsonResponse); + assert('status' in getJsonResponse); + assert('name' in getJsonResponse); + assert('newsletters' in getJsonResponse); assert(!('id' in getJsonResponse)); assert.equal(getJsonResponse.newsletters.length, 1); // NOTE: these should be snapshots not code - assert.equal(Object.keys(getJsonResponse.newsletters[0]).length, 5); - getJsonResponse.newsletters[0].should.have.properties([ - 'id', - 'uuid', - 'name', - 'description', - 'sort_order' - ]); + assert.deepEqual( + new Set(Object.keys(getJsonResponse.newsletters[0])), + new Set(['id', 'uuid', 'name', 'description', 'sort_order']) + ); // Can update newsletter subscription const originalNewsletters = getJsonResponse.newsletters; @@ -259,7 +258,11 @@ describe('Front-end members behavior', function () { const jsonResponse = res.body; assertExists(jsonResponse); - jsonResponse.should.have.properties(['email', 'uuid', 'status', 'name', 'newsletters']); + assert('email' in jsonResponse); + assert('uuid' in jsonResponse); + assert('status' in jsonResponse); + assert('name' in jsonResponse); + assert('newsletters' in jsonResponse); assert(!('id' in jsonResponse)); assert.equal(jsonResponse.newsletters.length, 0); @@ -271,18 +274,18 @@ describe('Front-end members behavior', function () { const restoreJsonResponse = resRestored.body; assertExists(restoreJsonResponse); - restoreJsonResponse.should.have.properties(['email', 'uuid', 'status', 'name', 'newsletters']); + assert('email' in restoreJsonResponse); + assert('uuid' in restoreJsonResponse); + assert('status' in restoreJsonResponse); + assert('name' in restoreJsonResponse); + assert('newsletters' in restoreJsonResponse); assert(!('id' in restoreJsonResponse)); assert.equal(restoreJsonResponse.newsletters.length, 1); // @NOTE: this seems like too much exposed information, needs a review - assert.equal(Object.keys(restoreJsonResponse.newsletters[0]).length, 5); - restoreJsonResponse.newsletters[0].should.have.properties([ - 'id', - 'uuid', - 'name', - 'description', - 'sort_order' - ]); + assert.deepEqual( + new Set(Object.keys(restoreJsonResponse.newsletters[0])), + new Set(['id', 'uuid', 'name', 'description', 'sort_order']) + ); assert.equal(restoreJsonResponse.newsletters[0].name, originalNewsletterName); }); @@ -875,37 +878,34 @@ describe('Front-end members behavior', function () { assertExists(memberData); // @NOTE: this should be a snapshot test not code - memberData.should.have.properties([ - 'uuid', - 'email', - 'name', - 'firstname', - 'expertise', - 'avatar_image', - 'subscribed', - 'subscriptions', - 'paid', - 'created_at', - 'enable_comment_notifications', - 'can_comment', - 'commenting', - 'newsletters', - 'email_suppression', - 'unsubscribe_url' - ]); - assert.equal(Object.keys(memberData).length, 16); - assert(!('id' in memberData)); + assert.deepEqual( + new Set(Object.keys(memberData)), + new Set([ + 'uuid', + 'email', + 'name', + 'firstname', + 'expertise', + 'avatar_image', + 'subscribed', + 'subscriptions', + 'paid', + 'created_at', + 'enable_comment_notifications', + 'can_comment', + 'commenting', + 'newsletters', + 'email_suppression', + 'unsubscribe_url' + ]) + ); assert.equal(memberData.newsletters.length, 1); // @NOTE: this should be a snapshot test not code - assert.equal(Object.keys(memberData.newsletters[0]).length, 5); - memberData.newsletters[0].should.have.properties([ - 'id', - 'uuid', - 'name', - 'description', - 'sort_order' - ]); + assert.deepEqual( + new Set(Object.keys(memberData.newsletters[0])), + new Set(['id', 'uuid', 'name', 'description', 'sort_order']) + ); }); it('can read public post content', async function () { diff --git a/ghost/core/test/e2e-frontend/preview-routes.test.js b/ghost/core/test/e2e-frontend/preview-routes.test.js index d3ee8d2751b..8a4e5bf8bdd 100644 --- a/ghost/core/test/e2e-frontend/preview-routes.test.js +++ b/ghost/core/test/e2e-frontend/preview-routes.test.js @@ -4,7 +4,6 @@ // But then again testing real code, rather than mock code, might be more useful... const assert = require('node:assert/strict'); const {assertExists} = require('../utils/assertions'); -const should = require('should'); const sinon = require('sinon'); const supertest = require('supertest'); @@ -22,15 +21,15 @@ function assertCorrectFrontendHeaders(res) { } function assertPaywallRendered(res) { - res.text.should.match(/Before paywall/, 'Content before paywall should be rendered'); - res.text.should.not.match(/After paywall/, 'Content after paywall should not be rendered'); - res.text.should.match(/This post is for/, 'Paywall should be rendered'); + assert.match(res.text, /Before paywall/, 'Content before paywall should be rendered'); + assert.doesNotMatch(res.text, /After paywall/, 'Content after paywall should not be rendered'); + assert.match(res.text, /This post is for/, 'Paywall should be rendered'); } function assertNoPaywallRendered(res) { - res.text.should.match(/Before paywall/, 'Content before paywall should be rendered'); - res.text.should.match(/After paywall/, 'Content after paywall should be rendered'); - res.text.should.not.match(/This post is for/, 'Paywall should not be rendered'); + assert.match(res.text, /Before paywall/, 'Content before paywall should be rendered'); + assert.match(res.text, /After paywall/, 'Content after paywall should be rendered'); + assert.doesNotMatch(res.text, /This post is for/, 'Paywall should not be rendered'); } describe('Frontend Routing: Preview Routes', function () { @@ -74,10 +73,10 @@ describe('Frontend Routing: Preview Routes', function () { assert.equal($('meta[name="description"]').attr('content'), 'meta description for draft post'); // @TODO: use theme from fixtures and don't rely on content/themes/casper - // $('.content .post').length.should.equal(1); - // $('.poweredby').text().should.equal('Proudly published with Ghost'); - // $('body.post-template').length.should.equal(1); - // $('article.post').length.should.equal(1); + // assert.equal($('.content .post').length, 1); + // assert.equal($('.poweredby').text(), 'Proudly published with Ghost'); + // assert.equal($('body.post-template').length, 1); + // assert.equal($('article.post').length, 1); }); }); diff --git a/ghost/core/test/e2e-server/services/member-attribution.test.js b/ghost/core/test/e2e-server/services/member-attribution.test.js index ffadf5cccf3..ed9d213911d 100644 --- a/ghost/core/test/e2e-server/services/member-attribution.test.js +++ b/ghost/core/test/e2e-server/services/member-attribution.test.js @@ -1,6 +1,6 @@ const assert = require('node:assert/strict'); +const {assertObjectMatches} = require('../../utils/assertions'); const {agentProvider, fixtureManager, configUtils} = require('../../utils/e2e-framework'); -const should = require('should'); const models = require('../../../core/server/models'); const urlService = require('../../../core/server/services/url'); const memberAttributionService = require('../../../core/server/services/member-attribution'); @@ -28,18 +28,18 @@ describe('Member Attribution Service', function () { time: Date.now() } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: null, url: subdomainRelative, type: 'url' - })); + }); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: null, url: absoluteUrl, type: 'url', title: subdomainRelative - })); + }); }); it('resolves posts', async function () { @@ -53,20 +53,20 @@ describe('Member Attribution Service', function () { time: Date.now() } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: post.id, url, type: 'post' - })); + }); const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true, withSubdirectory: true}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: post.id, url: absoluteUrl, type: 'post', title: post.get('title') - })); + }); }); it('resolves removed resources', async function () { @@ -84,21 +84,21 @@ describe('Member Attribution Service', function () { ]); // Without subdirectory - attribution.should.match(({ + assertObjectMatches(attribution, { id: post.id, url: urlWithoutSubdirectory, type: 'post' - })); + }); // Unpublish this post await models.Post.edit({status: 'draft'}, {id}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: null, url: absoluteUrl, type: 'url', title: urlWithoutSubdirectory - })); + }); await models.Post.edit({status: 'published'}, {id}); }); @@ -116,20 +116,20 @@ describe('Member Attribution Service', function () { time: Date.now() } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: post.id, url, type: 'page' - })); + }); const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true, withSubdirectory: true}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: post.id, url: absoluteUrl, type: 'page', title: post.get('title') - })); + }); }); it('resolves tags', async function () { @@ -143,20 +143,20 @@ describe('Member Attribution Service', function () { time: Date.now() } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: tag.id, url, type: 'tag' - })); + }); const absoluteUrl = urlService.getUrlByResourceId(tag.id, {absolute: true, withSubdirectory: true}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: tag.id, url: absoluteUrl, type: 'tag', title: tag.get('name') - })); + }); }); it('resolves authors', async function () { @@ -170,20 +170,20 @@ describe('Member Attribution Service', function () { time: Date.now() } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: author.id, url, type: 'author' - })); + }); const absoluteUrl = urlService.getUrlByResourceId(author.id, {absolute: true, withSubdirectory: true}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: author.id, url: absoluteUrl, type: 'author', title: author.get('name') - })); + }); }); }); @@ -207,18 +207,18 @@ describe('Member Attribution Service', function () { time: Date.now() } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: null, url: subdomainRelative, type: 'url' - })); + }); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: null, url: absoluteUrl, type: 'url', title: subdomainRelative - })); + }); }); it('resolves posts', async function () { @@ -238,20 +238,20 @@ describe('Member Attribution Service', function () { ]); // Without subdirectory - attribution.should.match(({ + assertObjectMatches(attribution, { id: post.id, url: urlWithoutSubdirectory, type: 'post' - })); + }); const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true, withSubdirectory: true}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: post.id, url: absoluteUrl, type: 'post', title: post.get('title') - })); + }); }); it('resolves removed resources', async function () { @@ -272,21 +272,21 @@ describe('Member Attribution Service', function () { ]); // Without subdirectory - attribution.should.match(({ + assertObjectMatches(attribution, { id: post.id, url: urlWithoutSubdirectory, type: 'post' - })); + }); // Unpublish this post await models.Post.edit({status: 'draft'}, {id}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: null, url: absoluteUrl, type: 'url', title: urlWithoutSubdirectory - })); + }); }); it('resolves pages', async function () { @@ -303,20 +303,20 @@ describe('Member Attribution Service', function () { time: Date.now() } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: post.id, url: urlWithoutSubdirectory, type: 'page' - })); + }); const absoluteUrl = urlService.getUrlByResourceId(post.id, {absolute: true, withSubdirectory: true}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: post.id, url: absoluteUrl, type: 'page', title: post.get('title') - })); + }); }); it('resolves tags', async function () { @@ -331,20 +331,20 @@ describe('Member Attribution Service', function () { time: Date.now() } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: tag.id, url: urlWithoutSubdirectory, type: 'tag' - })); + }); const absoluteUrl = urlService.getUrlByResourceId(tag.id, {absolute: true, withSubdirectory: true}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: tag.id, url: absoluteUrl, type: 'tag', title: tag.get('name') - })); + }); }); it('resolves authors', async function () { @@ -359,20 +359,20 @@ describe('Member Attribution Service', function () { time: Date.now() } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: author.id, url: urlWithoutSubdirectory, type: 'author' - })); + }); const absoluteUrl = urlService.getUrlByResourceId(author.id, {absolute: true, withSubdirectory: true}); - (await attribution.fetchResource()).should.match(({ + assertObjectMatches(await attribution.fetchResource(), { id: author.id, url: absoluteUrl, type: 'author', title: author.get('name') - })); + }); }); }); }); @@ -392,14 +392,14 @@ describe('Member Attribution Service', function () { referrerUrl: null } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: null, url: '/', type: 'url', referrerSource: 'Ghost Explore', referrerMedium: 'Ghost Network', referrerUrl: null - })); + }); }); it('resolves Portal signup URLs', async function () { @@ -412,14 +412,14 @@ describe('Member Attribution Service', function () { referrerSource: 'casper' } ]); - attribution.should.match(({ + assertObjectMatches(attribution, { id: null, url: '/', type: 'url', referrerSource: 'casper', referrerMedium: null, referrerUrl: null - })); + }); }); }); -}); \ No newline at end of file +}); diff --git a/ghost/core/test/e2e-server/services/stats/mrr-stats-service.test.js b/ghost/core/test/e2e-server/services/stats/mrr-stats-service.test.js index 58c73183e5c..a18d5509ebe 100644 --- a/ghost/core/test/e2e-server/services/stats/mrr-stats-service.test.js +++ b/ghost/core/test/e2e-server/services/stats/mrr-stats-service.test.js @@ -1,7 +1,6 @@ const assert = require('node:assert/strict'); const statsService = require('../../../../core/server/services/stats'); const {agentProvider, fixtureManager, mockManager} = require('../../../utils/e2e-framework'); -require('should'); const {stripeMocker} = require('../../../utils/e2e-framework-mock-manager'); const moment = require('moment'); @@ -175,8 +174,7 @@ describe('MRR Stats Service', function () { await createMemberWithSubscription('month', 2, 'usd', moment(today).toISOString()); const results = await statsService.api.mrr.fetchAllDeltas(); - assert.equal(results.length, 3); - results.should.match([ + assert.deepEqual(results, [ { date: ninetyDaysAgo, delta: 500, diff --git a/ghost/core/test/legacy/api/admin/db.test.js b/ghost/core/test/legacy/api/admin/db.test.js index ea8b2463572..230827a228f 100644 --- a/ghost/core/test/legacy/api/admin/db.test.js +++ b/ghost/core/test/legacy/api/admin/db.test.js @@ -4,7 +4,6 @@ const path = require('path'); const os = require('os'); const fs = require('fs-extra'); const crypto = require('crypto'); -const should = require('should'); const supertest = require('supertest'); const sinon = require('sinon'); const config = require('../../../../core/shared/config'); @@ -373,7 +372,7 @@ describe('DB API', function () { assert.equal(post.get('email_recipient_filter'), 'status:-free'); // Check this post is connected to the imported product - post.relations.tiers.models.map(m => m.id).should.match([product.id]); + assert.deepEqual(post.relations.tiers.models.map(m => m.id), [product.id]); // Check stripe prices const monthlyPrice = await models.StripePrice.findOne({id: product.get('monthly_price_id')}); @@ -475,7 +474,7 @@ describe('DB API (cleaned)', function () { assert.equal(post.get('email_recipient_filter'), 'status:-free'); // Check this post is connected to the imported product - post.relations.tiers.models.map(m => m.id).should.match([product.id]); + assert.deepEqual(post.relations.tiers.models.map(m => m.id), [product.id]); // Check stripe prices const monthlyPrice = await models.StripePrice.findOne({stripe_price_id: 'price_a425520db0'}); diff --git a/ghost/core/test/legacy/models/model-posts.test.js b/ghost/core/test/legacy/models/model-posts.test.js index ac67ac49d6d..00e925ef444 100644 --- a/ghost/core/test/legacy/models/model-posts.test.js +++ b/ghost/core/test/legacy/models/model-posts.test.js @@ -814,7 +814,7 @@ describe('Post Model', function () { assert.equal(createdPost.get('html'), newPostDB.html); assert.equal(createdPost.has('plaintext'), true); assert.match(createdPost.get('plaintext'), /^testing/); - // createdPost.get('slug').should.equal(newPostDB.slug + '-3'); + // assert.equal(createdPost.get('slug'), newPostDB.slug + '-3'); assert.equal((!!createdPost.get('featured')), false); assert.equal((!!createdPost.get('page')), false); diff --git a/ghost/core/test/scripts/browser-test-runner.js b/ghost/core/test/scripts/browser-test-runner.js new file mode 100644 index 00000000000..b1831c76cf6 --- /dev/null +++ b/ghost/core/test/scripts/browser-test-runner.js @@ -0,0 +1,65 @@ +/** + * Browser Test Runner + * + * Starts the frontend dev servers (Portal, Comments UI, etc.) and runs + * Playwright browser tests concurrently. The browser tests expect these + * apps to be available on specific ports. + */ +const concurrently = require('concurrently'); + +// Pass-through args (everything after --) +const doubleDashIndex = process.argv.lastIndexOf('--'); +const passThroughArgs = doubleDashIndex === -1 ? [] : process.argv.slice(doubleDashIndex + 1); +const PASS_THROUGH_FLAGS = passThroughArgs.join(' '); + +// Frontend dev servers needed by browser tests +// These ports are hardcoded in ghost/core/test/e2e-browser/fixtures/ghost-test.js +const commands = [ + { + name: 'browser-tests', + command: `nx run ghost:test:browser${PASS_THROUGH_FLAGS ? ` -- ${PASS_THROUGH_FLAGS}` : ''}`, + prefixColor: 'blue' + }, + { + name: 'portal', + command: 'nx run @tryghost/portal:dev', + prefixColor: 'magenta' + }, + { + name: 'comments', + command: 'nx run @tryghost/comments-ui:dev', + prefixColor: '#E55137' + }, + { + name: 'signup-form', + command: 'nx run @tryghost/signup-form:preview', + prefixColor: 'magenta' + }, + { + name: 'announcement-bar', + command: 'nx run @tryghost/announcement-bar:dev', + prefixColor: '#DC9D00' + }, + { + name: 'search', + command: 'nx run @tryghost/sodo-search:dev', + prefixColor: '#23de43' + } +]; + +(async () => { + // eslint-disable-next-line no-console + console.log(`Starting browser tests with frontend dev servers...`); + + const {result} = concurrently(commands, { + prefix: 'name', + killOthers: ['failure', 'success'], + successCondition: 'first' + }); + + try { + await result; + } catch (err) { + process.exit(1); + } +})(); diff --git a/ghost/core/test/unit/frontend/helpers/cancel-link.test.js b/ghost/core/test/unit/frontend/helpers/cancel-link.test.js index 2fa9175fe4b..1ff5650e8f7 100644 --- a/ghost/core/test/unit/frontend/helpers/cancel-link.test.js +++ b/ghost/core/test/unit/frontend/helpers/cancel-link.test.js @@ -1,6 +1,5 @@ const assert = require('node:assert/strict'); const {assertExists} = require('../../../utils/assertions'); -const should = require('should'); const sinon = require('sinon'); const hbs = require('../../../../core/frontend/services/theme-engine/engine'); const cancel_link = require('../../../../core/frontend/helpers/cancel_link'); @@ -52,11 +51,11 @@ describe('{{cancel_link}} helper', function () { }); assertExists(rendered); - rendered.string.should.match(defaultLinkClass); + assert.match(rendered.string, defaultLinkClass); assert.match(rendered.string, /data-members-cancel-subscription="sub_cancel"/); - rendered.string.should.match(defaultCancelLinkText); + assert.match(rendered.string, defaultCancelLinkText); - rendered.string.should.match(defaultErrorElementClass); + assert.match(rendered.string, defaultErrorElementClass); }); it('can render continue subscription link', function () { @@ -66,9 +65,9 @@ describe('{{cancel_link}} helper', function () { }); assertExists(rendered); - rendered.string.should.match(defaultLinkClass); + assert.match(rendered.string, defaultLinkClass); assert.match(rendered.string, /data-members-continue-subscription="sub_continue"/); - rendered.string.should.match(defaultContinueLinkText); + assert.match(rendered.string, defaultContinueLinkText); }); it('can render custom link class', function () { diff --git a/ghost/core/test/unit/frontend/helpers/next-post.test.js b/ghost/core/test/unit/frontend/helpers/next-post.test.js index 3710f33a7b6..26f898fcdf6 100644 --- a/ghost/core/test/unit/frontend/helpers/next-post.test.js +++ b/ghost/core/test/unit/frontend/helpers/next-post.test.js @@ -1,10 +1,8 @@ -const assert = require('node:assert/strict'); const errors = require('@tryghost/errors'); const sinon = require('sinon'); const markdownToMobiledoc = require('../../../utils/fixtures/data-generator').markdownToMobiledoc; const next_post = require('../../../../core/frontend/helpers/prev_post'); const api = require('../../../../core/frontend/services/proxy').api; -const should = require('should'); const logging = require('@tryghost/logging'); describe('{{next_post}} helper', function () { @@ -56,14 +54,20 @@ describe('{{next_post}} helper', function () { published_at: new Date(0), url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); + + sinon.assert.calledOnce(fn); + sinon.assert.calledWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly(browsePostsStub, sinon.match({include: 'author,authors,tags,tiers'})); }); }); @@ -93,12 +97,17 @@ describe('{{next_post}} helper', function () { published_at: new Date(0), url: '/current/' }, optionsData); - assert.equal(fn.called, false); - assert.equal(inverse.called, true); - assert.equal(inverse.firstCall.args.length, 2); - inverse.firstCall.args[0].should.have.properties('slug', 'title'); - inverse.firstCall.args[1].should.be.an.Object().and.have.property('data'); + sinon.assert.notCalled(fn); + + sinon.assert.calledOnceWithExactly( + inverse, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); }); }); @@ -118,9 +127,10 @@ describe('{{next_post}} helper', function () { await next_post .call({}, optionsData); - assert.equal(fn.called, false); - assert.equal(inverse.called, true); - assert.equal(browsePostsStub.called, false); + + sinon.assert.notCalled(fn); + sinon.assert.calledOnce(inverse); + sinon.assert.notCalled(browsePostsStub); }); }); @@ -156,8 +166,9 @@ describe('{{next_post}} helper', function () { url: '/current/', page: true }, optionsData); - assert.equal(fn.called, false); - assert.equal(inverse.called, true); + + sinon.assert.notCalled(fn); + sinon.assert.calledOnce(inverse); }); }); @@ -191,8 +202,9 @@ describe('{{next_post}} helper', function () { created_at: new Date(0), url: '/current/' }, optionsData); - assert.equal(fn.called, false); - assert.equal(inverse.called, true); + + sinon.assert.notCalled(fn); + sinon.assert.calledOnce(inverse); }); }); @@ -224,15 +236,25 @@ describe('{{next_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.match(browsePostsStub.firstCall.args[0].filter, /\+primary_tag:test/); + sinon.assert.calledOnce(fn); + sinon.assert.calledWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(/\+primary_tag:test/) + }) + ); }); it('shows \'if\' template with prev post data with primary_author set', async function () { @@ -252,15 +274,25 @@ describe('{{next_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.match(browsePostsStub.firstCall.args[0].filter, /\+primary_author:hans/); + sinon.assert.calledOnce(fn); + sinon.assert.calledWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(/\+primary_author:hans/) + }) + ); }); it('shows \'if\' template with prev post data with author set', async function () { @@ -280,15 +312,25 @@ describe('{{next_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.match(browsePostsStub.firstCall.args[0].filter, /\+author:author-name/); + sinon.assert.calledOnce(fn); + sinon.assert.calledWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(/\+author:author-name/) + }) + ); }); it('shows \'if\' template with prev post data & ignores in author if author isnt present', async function () { @@ -307,15 +349,25 @@ describe('{{next_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.doesNotMatch(browsePostsStub.firstCall.args[0].filter, /\+author:/); + sinon.assert.calledOnce(fn); + sinon.assert.calledWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(filter => !/\+author:/.test(filter)) + }) + ); }); it('shows \'if\' template with prev post data & ignores unknown in value', async function () { @@ -335,15 +387,25 @@ describe('{{next_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.doesNotMatch(browsePostsStub.firstCall.args[0].filter, /\+magic/); + sinon.assert.calledOnce(fn); + sinon.assert.calledWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(filter => !/\+magic/.test(filter)) + }) + ); }); }); @@ -371,13 +433,19 @@ describe('{{next_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.called, false); - assert.equal(inverse.calledOnce, true); - assert.equal(loggingStub.calledOnce, true); + sinon.assert.notCalled(fn); - inverse.firstCall.args[1].should.be.an.Object().and.have.property('data'); - inverse.firstCall.args[1].data.should.be.an.Object().and.have.property('error'); - assert.match(inverse.firstCall.args[1].data.error, /^Something wasn't found/); + sinon.assert.calledOnceWithExactly( + inverse, + sinon.match.any, + sinon.match({ + data: sinon.match({ + error: sinon.match(/^Something wasn't found/) + }) + }) + ); + + sinon.assert.calledOnce(loggingStub); }); it('should show warning for call without any options', async function () { @@ -391,8 +459,8 @@ describe('{{next_post}} helper', function () { optionsData ); - assert.equal(fn.called, false); - assert.equal(inverse.called, false); + sinon.assert.notCalled(fn); + sinon.assert.notCalled(inverse); }); }); @@ -431,17 +499,26 @@ describe('{{next_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - - // Check context passed - assert.equal(browsePostsStub.firstCall.args[0].context.member, member); + sinon.assert.calledOnce(fn); + sinon.assert.calledWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + // Check context passed + context: {member} + }) + ); }); }); }); diff --git a/ghost/core/test/unit/frontend/helpers/pagination.test.js b/ghost/core/test/unit/frontend/helpers/pagination.test.js index 4b678bbb479..7f165746746 100644 --- a/ghost/core/test/unit/frontend/helpers/pagination.test.js +++ b/ghost/core/test/unit/frontend/helpers/pagination.test.js @@ -1,6 +1,5 @@ const assert = require('node:assert/strict'); const {assertExists} = require('../../../utils/assertions'); -const should = require('should'); const hbs = require('../../../../core/frontend/services/theme-engine/engine'); const configUtils = require('../../../utils/config-utils'); const path = require('path'); @@ -46,11 +45,11 @@ describe('{{pagination}} helper', function () { }); assertExists(rendered); // strip out carriage returns and compare. - rendered.string.should.match(paginationRegex); - rendered.string.should.match(pageRegex); + assert.match(rendered.string, paginationRegex); + assert.match(rendered.string, pageRegex); assert.match(rendered.string, /Page 1 of 1/); - rendered.string.should.not.match(newerRegex); - rendered.string.should.not.match(olderRegex); + assert.doesNotMatch(rendered.string, newerRegex); + assert.doesNotMatch(rendered.string, olderRegex); }); it('can render first page of many with older posts link', function () { @@ -59,11 +58,11 @@ describe('{{pagination}} helper', function () { }); assertExists(rendered); - rendered.string.should.match(paginationRegex); - rendered.string.should.match(pageRegex); - rendered.string.should.match(olderRegex); + assert.match(rendered.string, paginationRegex); + assert.match(rendered.string, pageRegex); + assert.match(rendered.string, olderRegex); assert.match(rendered.string, /Page 1 of 3/); - rendered.string.should.not.match(newerRegex); + assert.doesNotMatch(rendered.string, newerRegex); }); it('can render middle pages of many with older and newer posts link', function () { @@ -72,10 +71,10 @@ describe('{{pagination}} helper', function () { }); assertExists(rendered); - rendered.string.should.match(paginationRegex); - rendered.string.should.match(pageRegex); - rendered.string.should.match(olderRegex); - rendered.string.should.match(newerRegex); + assert.match(rendered.string, paginationRegex); + assert.match(rendered.string, pageRegex); + assert.match(rendered.string, olderRegex); + assert.match(rendered.string, newerRegex); assert.match(rendered.string, /Page 2 of 3/); }); @@ -85,11 +84,11 @@ describe('{{pagination}} helper', function () { }); assertExists(rendered); - rendered.string.should.match(paginationRegex); - rendered.string.should.match(pageRegex); - rendered.string.should.match(newerRegex); + assert.match(rendered.string, paginationRegex); + assert.match(rendered.string, pageRegex); + assert.match(rendered.string, newerRegex); assert.match(rendered.string, /Page 3 of 3/); - rendered.string.should.not.match(olderRegex); + assert.doesNotMatch(rendered.string, olderRegex); }); it('validates values', function () { diff --git a/ghost/core/test/unit/frontend/helpers/prev-post.test.js b/ghost/core/test/unit/frontend/helpers/prev-post.test.js index 47997d9dc16..8a86c562423 100644 --- a/ghost/core/test/unit/frontend/helpers/prev-post.test.js +++ b/ghost/core/test/unit/frontend/helpers/prev-post.test.js @@ -4,7 +4,6 @@ const sinon = require('sinon'); const markdownToMobiledoc = require('../../../utils/fixtures/data-generator').markdownToMobiledoc; const prev_post = require('../../../../core/frontend/helpers/prev_post'); const api = require('../../../../core/frontend/services/proxy').api; -const should = require('should'); const logging = require('@tryghost/logging'); describe('{{prev_post}} helper', function () { @@ -57,14 +56,21 @@ describe('{{prev_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); + sinon.assert.calledOnceWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({include: 'author,authors,tags,tiers'}) + ); }); }); @@ -93,12 +99,16 @@ describe('{{prev_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.called, false); - assert.equal(inverse.called, true); + sinon.assert.notCalled(fn); - assert.equal(inverse.firstCall.args.length, 2); - inverse.firstCall.args[0].should.have.properties('slug', 'title'); - inverse.firstCall.args[1].should.be.an.Object().and.have.property('data'); + sinon.assert.calledOnceWithExactly( + inverse, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); }); }); @@ -228,15 +238,24 @@ describe('{{prev_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.match(browsePostsStub.firstCall.args[0].filter, /\+primary_tag:test/); + sinon.assert.calledOnceWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(/\+primary_tag:test/) + }) + ); }); it('shows \'if\' template with prev post data with primary_author set', async function () { @@ -256,15 +275,24 @@ describe('{{prev_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.match(browsePostsStub.firstCall.args[0].filter, /\+primary_author:hans/); + sinon.assert.calledOnceWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(/\+primary_author:hans/) + }) + ); }); it('shows \'if\' template with prev post data with author set', async function () { @@ -284,15 +312,24 @@ describe('{{prev_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.match(browsePostsStub.firstCall.args[0].filter, /\+author:author-name/); + sinon.assert.calledOnceWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(/\+author:author-name/) + }) + ); }); it('shows \'if\' template with prev post data & ignores in author if author isnt present', async function () { @@ -311,15 +348,24 @@ describe('{{prev_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.doesNotMatch(browsePostsStub.firstCall.args[0].filter, /\+author:/); + sinon.assert.calledOnceWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(filter => !/\+author:/.test(filter)) + }) + ); }); it('shows \'if\' template with prev post data & ignores unknown in value', async function () { @@ -339,15 +385,24 @@ describe('{{prev_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - assert.doesNotMatch(browsePostsStub.firstCall.args[0].filter, /\+magic/); + sinon.assert.calledOnceWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + filter: sinon.match(filter => !/\+magic/.test(filter)) + }) + ); }); }); @@ -375,13 +430,19 @@ describe('{{prev_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.called, false); - assert.equal(inverse.calledOnce, true); - assert.equal(loggingStub.calledOnce, true); + sinon.assert.notCalled(fn); - inverse.firstCall.args[1].should.be.an.Object().and.have.property('data'); - inverse.firstCall.args[1].data.should.be.an.Object().and.have.property('error'); - assert.match(inverse.firstCall.args[1].data.error, /^Something wasn't found/); + sinon.assert.calledOnceWithExactly( + inverse, + sinon.match.any, + sinon.match({ + data: sinon.match({ + error: sinon.match(/^Something wasn't found/) + }) + }) + ); + + sinon.assert.calledOnce(loggingStub); }); it('should show warning for call without any options', async function () { @@ -435,17 +496,25 @@ describe('{{prev_post}} helper', function () { url: '/current/' }, optionsData); - assert.equal(fn.calledOnce, true); - assert.equal(inverse.calledOnce, false); - - assert.equal(fn.firstCall.args.length, 2); - fn.firstCall.args[0].should.have.properties('slug', 'title'); - fn.firstCall.args[1].should.be.an.Object().and.have.property('data'); - assert.equal(browsePostsStub.calledOnce, true); - assert.equal(browsePostsStub.firstCall.args[0].include, 'author,authors,tags,tiers'); - - // Check context passed - assert.equal(browsePostsStub.firstCall.args[0].context.member, member); + sinon.assert.calledOnceWithExactly( + fn, + sinon.match({ + slug: sinon.match.string, + title: sinon.match.string + }), + sinon.match({data: sinon.match.any}) + ); + + sinon.assert.notCalled(inverse); + + sinon.assert.calledOnceWithExactly( + browsePostsStub, + sinon.match({ + include: 'author,authors,tags,tiers', + // Check context passed + context: sinon.match({member}) + }) + ); }); }); }); diff --git a/ghost/core/test/unit/frontend/services/routing/static-routes-router.test.js b/ghost/core/test/unit/frontend/services/routing/static-routes-router.test.js index be41ac3896c..2c987c1d549 100644 --- a/ghost/core/test/unit/frontend/services/routing/static-routes-router.test.js +++ b/ghost/core/test/unit/frontend/services/routing/static-routes-router.test.js @@ -82,13 +82,12 @@ describe('UNIT - services/routing/StaticRoutesRouter', function () { staticRoutesRouter._prepareStaticRouteContext(req, res, next); assert.equal(next.called, true); - res.routerOptions.should.have.properties('type', 'templates', 'defaultTemplate', 'context', 'data', 'contentType'); assert.equal(res.routerOptions.type, 'custom'); assert.deepEqual(res.routerOptions.templates, []); assert.equal(typeof res.routerOptions.defaultTemplate, 'function'); assert.deepEqual(res.routerOptions.context, ['about']); assert.deepEqual(res.routerOptions.data, {}); - + assert('contentType' in res.routerOptions); assert.equal(res.routerOptions.contentType, undefined); assert.equal(res.locals.slug, undefined); }); @@ -99,13 +98,12 @@ describe('UNIT - services/routing/StaticRoutesRouter', function () { staticRoutesRouter._prepareStaticRouteContext(req, res, next); assert.equal(next.called, true); - res.routerOptions.should.have.properties('type', 'templates', 'defaultTemplate', 'context', 'data', 'contentType'); assert.equal(res.routerOptions.type, 'custom'); assert.deepEqual(res.routerOptions.templates, []); assert.equal(typeof res.routerOptions.defaultTemplate, 'function'); assert.deepEqual(res.routerOptions.context, ['index']); assert.deepEqual(res.routerOptions.data, {}); - + assert('contentType' in res.routerOptions); assert.equal(res.locals.slug, undefined); }); }); diff --git a/ghost/core/test/unit/frontend/services/theme-engine/handlebars/template.test.js b/ghost/core/test/unit/frontend/services/theme-engine/handlebars/template.test.js index 08ffa310cdb..54e22bff777 100644 --- a/ghost/core/test/unit/frontend/services/theme-engine/handlebars/template.test.js +++ b/ghost/core/test/unit/frontend/services/theme-engine/handlebars/template.test.js @@ -1,6 +1,5 @@ const assert = require('node:assert/strict'); const {assertExists} = require('../../../../../utils/assertions'); -const should = require('should'); const errors = require('@tryghost/errors'); const {hbs, templates} = require('../../../../../../core/frontend/services/handlebars'); @@ -11,7 +10,7 @@ describe('Helpers Template', function () { const safeString = templates.execute('test', {name: 'world'}); assertExists(safeString); - safeString.should.have.property('string').and.equal('

Hello world

'); + assert.equal(safeString.string, '

Hello world

'); }); it('will throw an IncorrectUsageError if the partial does not exist', function () { diff --git a/ghost/core/test/unit/server/adapters/scheduling/post-scheduling/post-scheduler.test.js b/ghost/core/test/unit/server/adapters/scheduling/post-scheduling/post-scheduler.test.js index de89e860ded..0bc869c1695 100644 --- a/ghost/core/test/unit/server/adapters/scheduling/post-scheduling/post-scheduler.test.js +++ b/ghost/core/test/unit/server/adapters/scheduling/post-scheduling/post-scheduler.test.js @@ -1,6 +1,5 @@ const assert = require('node:assert/strict'); const errors = require('@tryghost/errors'); -const should = require('should'); const sinon = require('sinon'); const moment = require('moment'); const testUtils = require('../../../../../utils'); @@ -79,7 +78,7 @@ describe('Scheduling: Post Scheduler', function () { assert.equal(adapter.schedule.calledOnce, true); assert.equal(adapter.schedule.args[0][0].time, moment(post.get('published_at')).valueOf()); - adapter.schedule.args[0][0].url.should.startWith(urlUtils.urlJoin('http://scheduler.local:1111/', 'schedules', 'posts', post.get('id'), '?token=')); + assert(adapter.schedule.args[0][0].url.startsWith(urlUtils.urlJoin('http://scheduler.local:1111/', 'schedules', 'posts', post.get('id'), '?token='))); assert.equal(adapter.schedule.args[0][0].extra.httpMethod, 'PUT'); assert.equal(null, adapter.schedule.args[0][0].extra.oldTime); }); diff --git a/ghost/core/test/unit/server/data/exporter/index.test.js b/ghost/core/test/unit/server/data/exporter/index.test.js index 5aa15faa636..51c1f8c1bee 100644 --- a/ghost/core/test/unit/server/data/exporter/index.test.js +++ b/ghost/core/test/unit/server/data/exporter/index.test.js @@ -52,7 +52,7 @@ describe('Exporter', function () { assert.equal(db.knex.called, true); assert.equal(knexMock.callCount, expectedCallCount); - queryMock.select.callCount.should.have.eql(expectedCallCount); + sinon.assert.callCount(queryMock.select, expectedCallCount); const expectedTables = new Set([ 'posts', @@ -99,7 +99,7 @@ describe('Exporter', function () { assert.equal(queryMock.select.called, true); assert.equal(knexMock.callCount, expectedCallCount); - queryMock.select.callCount.should.have.eql(expectedCallCount); + sinon.assert.callCount(queryMock.select, expectedCallCount); const expectedTables = new Set([ 'posts', diff --git a/ghost/core/test/unit/server/data/schema/validator.test.js b/ghost/core/test/unit/server/data/schema/validator.test.js index 9837704ecc9..ba3561d4854 100644 --- a/ghost/core/test/unit/server/data/schema/validator.test.js +++ b/ghost/core/test/unit/server/data/schema/validator.test.js @@ -1,5 +1,4 @@ const assert = require('node:assert/strict'); -const should = require('should'); const _ = require('lodash'); const ObjectId = require('bson-objectid').default; const testUtils = require('../../../../utils'); @@ -32,7 +31,7 @@ describe('Validate Schema', function () { // NOTE: Some of these fields are auto-filled in the model layer (e.g. created_at, created_at etc.) ['id', 'uuid', 'slug', 'title', 'created_at'].forEach(function (attr) { - errorMessages.should.match(new RegExp('posts.' + attr)); + assert.match(errorMessages, RegExp('posts.' + attr)); }); }); }); diff --git a/ghost/core/test/unit/server/services/email-service/email-renderer.test.js b/ghost/core/test/unit/server/services/email-service/email-renderer.test.js index 30c11e603eb..914b0e16264 100644 --- a/ghost/core/test/unit/server/services/email-service/email-renderer.test.js +++ b/ghost/core/test/unit/server/services/email-service/email-renderer.test.js @@ -1,4 +1,3 @@ -require('should'); const EmailRenderer = require('../../../../../core/server/services/email-service/email-renderer'); const assert = require('node:assert/strict'); const {assertExists} = require('../../../../utils/assertions'); @@ -1399,20 +1398,11 @@ describe('Email renderer', function () { // Unsubscribe button included assert(response.plaintext.includes('Unsubscribe [%%{unsubscribe_url}%%]')); assert(response.html.includes('Unsubscribe')); - assert.equal(response.replacements.length, 4); - response.replacements.should.match([ - { - id: 'uuid' - }, - { - id: 'key' - }, - { - id: 'unsubscribe_url' - }, - { - id: 'list_unsubscribe' - } + assert.deepEqual(response.replacements.map(r => r.id), [ + 'uuid', + 'key', + 'unsubscribe_url', + 'list_unsubscribe' ]); assert(response.plaintext.includes('http://example.com')); @@ -2007,20 +1997,11 @@ describe('Email renderer', function () { assert(response.html.includes('Unsubscribe')); assert(response.html.includes('http://example.com')); - assert.equal(response.replacements.length, 4); - response.replacements.should.match([ - { - id: 'uuid' - }, - { - id: 'key' - }, - { - id: 'unsubscribe_url' - }, - { - id: 'list_unsubscribe' - } + assert.deepEqual(response.replacements.map(r => r.id), [ + 'uuid', + 'key', + 'unsubscribe_url', + 'list_unsubscribe' ]); assert(!response.html.includes('members only section')); assert(response.html.includes('some text for both')); diff --git a/ghost/core/test/unit/server/services/mail/ghost-mailer.test.js b/ghost/core/test/unit/server/services/mail/ghost-mailer.test.js index 164871b0cc8..bb0f3de3ab1 100644 --- a/ghost/core/test/unit/server/services/mail/ghost-mailer.test.js +++ b/ghost/core/test/unit/server/services/mail/ghost-mailer.test.js @@ -58,7 +58,7 @@ describe('Mail: Ghostmailer', function () { mailer = new mail.GhostMailer(); assertExists(mailer); - mailer.should.have.property('send').and.be.a.Function(); + assert.equal(typeof mailer.send, 'function'); }); it('should setup SMTP transport on initialization', function () { diff --git a/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js b/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js index 1b52cbd41b3..77136191e9c 100644 --- a/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/controllers/router-controller.test.js @@ -1513,20 +1513,19 @@ describe('RouterController', function () { let res; let responseData; - function createMockSubscription({id = 'sub_123', status = 'active', offerId = null, trialEndAt = null} = {}) { + function createMockSubscription({id = 'sub_123', status = 'active', offerId = null, trialEndAt = null, discountStart = null, discountEnd = null, cancelAtPeriodEnd = false} = {}) { return { id, get: sinon.stub().callsFake((key) => { - if (key === 'status') { - return status; - } - if (key === 'offer_id') { - return offerId; - } - if (key === 'trial_end_at') { - return trialEndAt; - } - return null; + const values = { + status, + offer_id: offerId, + trial_end_at: trialEndAt, + discount_start: discountStart, + discount_end: discountEnd, + cancel_at_period_end: cancelAtPeriodEnd + }; + return values[key] ?? null; }), related: sinon.stub().withArgs('stripePrice').returns(mockStripePrice) }; @@ -1555,7 +1554,8 @@ describe('RouterController', function () { beforeEach(function () { mockOffersAPI = { - listOffersAvailableToSubscription: sinon.stub().resolves([]) + listOffersAvailableToSubscription: sinon.stub().resolves([]), + getOffer: sinon.stub().resolves(null) }; tokenService = { @@ -1603,9 +1603,13 @@ describe('RouterController', function () { assert(offersAPIWithError.listOffersAvailableToSubscription.calledOnce); }); - it('returns empty offers when subscription already has an offer applied', async function () { + it('returns empty offers when subscription has an active discount', async function () { const routerController = createRouterController({ - subscriptions: createMockSubscription({offerId: 'existing_offer_123'}) + subscriptions: createMockSubscription({ + offerId: 'existing_offer_123', + discountStart: new Date('2026-01-01') + // discountEnd: null means forever + }) }); await routerController.getMemberOffers({ @@ -1616,6 +1620,47 @@ describe('RouterController', function () { assert(mockOffersAPI.listOffersAvailableToSubscription.notCalled); }); + it('returns offers when subscription has an expired discount', async function () { + const mockOffer = {id: 'retention_offer', name: 'Stay with us'}; + mockOffersAPI.listOffersAvailableToSubscription.resolves([mockOffer]); + + 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]}); + assert(mockOffersAPI.listOffersAvailableToSubscription.calledOnce); + }); + + it('returns offers when subscription has expired once offer (legacy data, no discount_start)', async function () { + const mockOffer = {id: 'retention_offer', name: 'Stay with us'}; + 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]}); + assert(mockOffersAPI.listOffersAvailableToSubscription.calledOnce); + }); + it('returns empty offers when member has multiple active subscriptions', async function () { const routerController = createRouterController({ subscriptions: [ diff --git a/ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.js b/ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.js index d736d0ba0d0..05d90cfa566 100644 --- a/ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.js +++ b/ghost/core/test/unit/server/services/members/members-api/services/geolocation-service.test.js @@ -40,7 +40,7 @@ describe('lib/geolocation', function () { assert.equal(scope.isDone(), true, 'request was not made'); assertExists(result, 'nothing was returned'); - result.should.deepEqual(RESPONSE, 'result didn\'t match expected response'); + assert.deepEqual(result, RESPONSE, 'result didn\'t match expected response'); }); it('fetches from geojs.io with IPv6 address', async function () { @@ -52,7 +52,7 @@ describe('lib/geolocation', function () { assert.equal(scope.isDone(), true, 'request was not made'); assertExists(result, 'nothing was returned'); - result.should.deepEqual(RESPONSE, 'result didn\'t match expected response'); + assert.deepEqual(result, RESPONSE, 'result didn\'t match expected response'); }); it('handles non-IP addresses', async function () { diff --git a/ghost/core/test/unit/server/services/members/members-api/utils/has-active-offer.test.js b/ghost/core/test/unit/server/services/members/members-api/utils/has-active-offer.test.js new file mode 100644 index 00000000000..6edecd74e9d --- /dev/null +++ b/ghost/core/test/unit/server/services/members/members-api/utils/has-active-offer.test.js @@ -0,0 +1,177 @@ +const assert = require('node:assert/strict'); +const sinon = require('sinon'); +const hasActiveOffer = require('../../../../../../../core/server/services/members/members-api/utils/has-active-offer'); + +describe('hasActiveOffer', function () { + afterEach(function () { + sinon.restore(); + }); + + function createSubscriptionModel({discountStart = null, discountEnd = null, trialEndAt = null, offerId = null, startDate = null} = {}) { + return { + get: sinon.stub().callsFake((key) => { + const values = { + discount_start: discountStart, + discount_end: discountEnd, + trial_end_at: trialEndAt, + offer_id: offerId, + start_date: startDate + }; + return values[key] ?? null; + }) + }; + } + + function createOffersAPI(offer = null) { + return { + getOffer: sinon.stub().resolves(offer) + }; + } + + // Post-6.16 data: discount_start is populated + + it('returns true when discount_start is set and discount_end is null (forever discount)', async function () { + const model = createSubscriptionModel({ + discountStart: new Date('2026-01-01') + }); + + const result = await hasActiveOffer(model, createOffersAPI()); + + assert.equal(result, true); + }); + + it('returns true when discount_start is set and discount_end is in the future', async function () { + const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + const model = createSubscriptionModel({ + discountStart: new Date('2026-01-01'), + discountEnd: futureDate + }); + + const result = await hasActiveOffer(model, createOffersAPI()); + + assert.equal(result, true); + }); + + it('returns false when discount_start is set and discount_end is in the past', async function () { + const pastDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const model = createSubscriptionModel({ + discountStart: new Date('2025-01-01'), + discountEnd: pastDate + }); + + const result = await hasActiveOffer(model, createOffersAPI()); + + assert.equal(result, false); + }); + + // Trial-based offers (free_months, trial) + + it('returns true when trial_end_at is in the future', async function () { + const futureDate = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + const model = createSubscriptionModel({ + trialEndAt: futureDate + }); + + const result = await hasActiveOffer(model, createOffersAPI()); + + assert.equal(result, true); + }); + + it('returns false when trial_end_at is in the past', async function () { + const pastDate = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000); + const model = createSubscriptionModel({ + trialEndAt: pastDate + }); + + const result = await hasActiveOffer(model, createOffersAPI()); + + assert.equal(result, false); + }); + + // Legacy data: discount_start is null, fall back to offer duration lookup + + it('returns false when no discount_start, no trial, and no offer_id', async function () { + const model = createSubscriptionModel(); + const result = await hasActiveOffer(model, createOffersAPI()); + assert.equal(result, false); + }); + + it('returns true for a forever offer (legacy data)', async function () { + const model = createSubscriptionModel({offerId: 'offer_123'}); + const offersAPI = createOffersAPI({duration: 'forever'}); + const result = await hasActiveOffer(model, offersAPI); + + assert.equal(result, true); + }); + + it('returns false for a once offer (legacy data)', async function () { + const model = createSubscriptionModel({offerId: 'offer_123'}); + const offersAPI = createOffersAPI({duration: 'once'}); + const result = await hasActiveOffer(model, offersAPI); + + assert.equal(result, false); + }); + + it('returns true for a repeating offer still within duration (legacy data)', async function () { + const threeMonthsAgo = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); + const model = createSubscriptionModel({ + offerId: 'offer_123', + startDate: threeMonthsAgo + }); + const offersAPI = createOffersAPI({duration: 'repeating', duration_in_months: 6}); + const result = await hasActiveOffer(model, offersAPI); + + assert.equal(result, true); + }); + + it('returns false for a repeating offer past its duration (legacy data)', async function () { + const oneYearAgo = new Date(Date.now() - 365 * 24 * 60 * 60 * 1000); + const model = createSubscriptionModel({ + offerId: 'offer_123', + startDate: oneYearAgo + }); + const offersAPI = createOffersAPI({duration: 'repeating', duration_in_months: 6}); + + const result = await hasActiveOffer(model, offersAPI); + + assert.equal(result, false); + }); + + it('returns true when offer lookup throws (errs on the side of blocking)', async function () { + const model = createSubscriptionModel({offerId: 'offer_123'}); + const offersAPI = { + getOffer: sinon.stub().rejects(new Error('Database error')) + }; + + const result = await hasActiveOffer(model, offersAPI); + + assert.equal(result, true); + }); + + it('returns false when offer lookup returns null (offer deleted)', async function () { + const model = createSubscriptionModel({offerId: 'offer_123'}); + const offersAPI = createOffersAPI(null); + + const result = await hasActiveOffer(model, offersAPI); + + assert.equal(result, false); + }); + + // Priority: discount_start takes precedence over trial and legacy fallback + + it('discount_start takes precedence over trial_end_at', async function () { + const pastDiscountEnd = new Date(Date.now() - 1000); + const futureTrialEnd = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000); + const model = createSubscriptionModel({ + discountStart: new Date('2025-01-01'), + discountEnd: pastDiscountEnd, + trialEndAt: futureTrialEnd + }); + + // discount_start is set, so discount_end in the past means expired + // even though trial_end_at is in the future + const result = await hasActiveOffer(model, createOffersAPI()); + + assert.equal(result, false); + }); +}); diff --git a/ghost/core/test/unit/server/services/stats/content.test.js b/ghost/core/test/unit/server/services/stats/content.test.js index 9ff3c8ef4cf..c13754da50e 100644 --- a/ghost/core/test/unit/server/services/stats/content.test.js +++ b/ghost/core/test/unit/server/services/stats/content.test.js @@ -71,7 +71,7 @@ describe('ContentStatsService', function () { const result = mockTinybirdClient.buildRequest('api_top_pages', options); assertExists(result.url); - result.url.should.startWith('https://api.tinybird.co/v0/pipes/api_top_pages.json?'); + assert(result.url.startsWith('https://api.tinybird.co/v0/pipes/api_top_pages.json?')); assert(result.url.includes('site_uuid=site-id')); assert(result.url.includes('date_from=2023-01-01')); assert(result.url.includes('date_to=2023-01-31')); @@ -148,7 +148,6 @@ describe('ContentStatsService', function () { const result = await service.lookupPostTitles(['post-1', 'post-2']); assertExists(result); - result.should.have.properties(['post-1', 'post-2']); assert.equal(result['post-1'].title, 'Test Post 1'); assert.equal(result['post-1'].id, 'post-id-1'); assert.equal(result['post-2'].title, 'Test Post 2'); @@ -183,7 +182,6 @@ describe('ContentStatsService', function () { const result = service.getResourceTitle('/about/'); assertExists(result); - result.should.have.properties(['title', 'resourceType']); assert.equal(result.title, 'About Us'); assert.equal(result.resourceType, 'page'); }); @@ -198,7 +196,6 @@ describe('ContentStatsService', function () { const result = service.getResourceTitle('/tag/news/'); assertExists(result); - result.should.have.properties(['title', 'resourceType']); assert.equal(result.title, 'News'); assert.equal(result.resourceType, 'tag'); }); diff --git a/ghost/core/test/unit/server/services/stats/utils/tinybird.test.js b/ghost/core/test/unit/server/services/stats/utils/tinybird.test.js index a07807d0ba8..bd0e86e183f 100644 --- a/ghost/core/test/unit/server/services/stats/utils/tinybird.test.js +++ b/ghost/core/test/unit/server/services/stats/utils/tinybird.test.js @@ -1,7 +1,6 @@ const assert = require('node:assert/strict'); const {assertExists} = require('../../../../../utils/assertions'); const sinon = require('sinon'); -const should = require('should'); const tinybird = require('../../../../../../core/server/services/stats/utils/tinybird'); describe('Tinybird Client', function () { @@ -60,7 +59,7 @@ describe('Tinybird Client', function () { }); assertExists(url); - url.should.startWith('https://api.tinybird.co/v0/pipes/test_pipe.json?'); + assert(url.startsWith('https://api.tinybird.co/v0/pipes/test_pipe.json?')); assert(url.includes('site_uuid=931ade9e-a4f1-4217-8625-34bd34250c16')); assert(url.includes('date_from=2023-01-01')); assert(url.includes('date_to=2023-01-31')); @@ -85,7 +84,7 @@ describe('Tinybird Client', function () { dateTo: '2023-01-31' }); - url.should.startWith('https://api.tinybird.co/v0/pipes/test_pipe_v2.json?'); + assert(url.startsWith('https://api.tinybird.co/v0/pipes/test_pipe_v2.json?')); }); it('overrides defaults with provided options', function () { @@ -115,7 +114,7 @@ describe('Tinybird Client', function () { const {url, options} = tinybirdClient.buildRequest('test_pipe', {}); - url.should.startWith('http://localhost:8000/v0/pipes/test_pipe.json?'); + assert(url.startsWith('http://localhost:8000/v0/pipes/test_pipe.json?')); assert.equal(options.headers.Authorization, 'Bearer mock-jwt-token'); }); }); @@ -227,7 +226,7 @@ describe('Tinybird Client', function () { // Verify request was called with correct parameters assert.equal(mockRequest.get.calledOnce, true); const [url, options] = mockRequest.get.firstCall.args; - url.should.startWith('https://api.tinybird.co/v0/pipes/test_pipe.json?'); + assert(url.startsWith('https://api.tinybird.co/v0/pipes/test_pipe.json?')); assert.equal(options.headers.Authorization, 'Bearer mock-jwt-token'); }); diff --git a/ghost/core/test/unit/server/web/api/middleware/upload.test.js b/ghost/core/test/unit/server/web/api/middleware/upload.test.js index e736201ff99..3c90e1570c4 100644 --- a/ghost/core/test/unit/server/web/api/middleware/upload.test.js +++ b/ghost/core/test/unit/server/web/api/middleware/upload.test.js @@ -1,4 +1,3 @@ -const should = require('should'); const validation = require('../../../../../../core/server/web/api/middleware/upload')._test; const imageFixturePath = ('../../../../../utils/fixtures/images/'); const fs = require('fs'); @@ -12,11 +11,11 @@ describe('web utils', function () { }); it('should return false if file does not exist in input', function () { - validation.checkFileExists({}).should.be.false; + assert.equal(validation.checkFileExists({}), false); }); it('should return false if file is incorrectly structured', function () { - validation.checkFileExists({type: 'file'}).should.be.false; + assert.equal(validation.checkFileExists({type: 'file'}), false); }); }); @@ -36,12 +35,12 @@ describe('web utils', function () { }); it('returns false if file has invalid extension', function () { - validation.checkFileIsValid({name: 'test.txt', mimetype: 'text'}, ['text'], ['.tar']).should.be.false; - validation.checkFileIsValid({name: 'test', mimetype: 'text'}, ['text'], ['.txt']).should.be.false; + assert.equal(validation.checkFileIsValid({name: 'test.txt', mimetype: 'text'}, ['text'], ['.tar']), false); + assert.equal(validation.checkFileIsValid({name: 'test', mimetype: 'text'}, ['text'], ['.txt']), false); }); it('returns false if file has invalid type', function () { - validation.checkFileIsValid({name: 'test.txt', mimetype: 'text'}, ['archive'], ['.txt']).should.be.false; + assert.equal(validation.checkFileIsValid({name: 'test.txt', mimetype: 'text'}, ['archive'], ['.txt']), false); }); }); diff --git a/ghost/i18n/locales/bg/comments.json b/ghost/i18n/locales/bg/comments.json index 3d75d236042..9648904c303 100644 --- a/ghost/i18n/locales/bg/comments.json +++ b/ghost/i18n/locales/bg/comments.json @@ -17,9 +17,9 @@ "Best": "Най-добрите", "Cancel": "Отказ", "Comment": "Коментар", - "Commenting disabled": "", + "Commenting disabled": "Коментирането е изключено", "Complete your profile": "Попълнете профила си", - "Contact support": "", + "Contact support": "Връзка с поддръжката", "Delete": "Изтриване", "Deleted": "Изтрито", "Deleted member": "Изтрит абонат", @@ -30,7 +30,7 @@ "edited": "редактиран", "Enter your name": "Попълнете името си", "Expertise": "Компетенции", - "for more information.": "", + "for more information.": "за повече информация.", "Founder @ Acme Inc": "Основател на Компания ООД", "Full-time parent": "Родител на пълно работно време", "Head of Marketing at Acme, Inc": "Директор маркетинг в Компания ООД", @@ -73,8 +73,8 @@ "This comment has been hidden.": "Този коментар е скрит.", "This comment has been removed.": "Този коментар е премахнат.", "Upgrade now": "Надградете сега", - "View in admin": "", + "View in admin": "Преглед в админ панела", "Yesterday": "Вчера", - "You can't post comments in this publication.": "", + "You can't post comments in this publication.": "Не може да коментирате под тази публикация.", "Your request will be sent to the owner of this site.": "Искането ще бъде изпратено до собственика на сайта." } diff --git a/ghost/i18n/locales/bg/portal.json b/ghost/i18n/locales/bg/portal.json index e596448a716..4dc5347d6df 100644 --- a/ghost/i18n/locales/bg/portal.json +++ b/ghost/i18n/locales/bg/portal.json @@ -23,7 +23,7 @@ "An unexpected error occured. Please try again or contact support if the error persists.": "Възникна неочаквана грешка. Моля, опитайте отново или потърсете поддръжката ако това се повтаря.", "Back": "Обратно", "Back to Log in": "Обратно към формуляра за влизане", - "Billing info & receipts": "", + "Billing info & receipts": "Платежна информация", "Black Friday": "Черен петък", "Cancel anytime.": "Отказване по всяко време.", "Cancel subscription": "Откажи абонамент", @@ -80,11 +80,11 @@ "Failed to send magic link email": "Неуспешно изпращане на линк за влизане по имейл", "Failed to send verification email": "Неуспешно изпращане на имейл за проверка", "Failed to sign up, please try again": "Неуспешна регистрация, опитайте отново", - "Failed to update account data": "Неуспешно актуализиране на данните", - "Failed to update account details": "Неуспешно актуализиране на детайлите", - "Failed to update billing information, please try again": "", - "Failed to update newsletter settings": "Неуспешно актуализиране на настройките на бюлетина", - "Failed to update subscription, please try again": "Не успяхте да актуализирате абонамента, опитайте отново", + "Failed to update account data": "Неуспешно обновяване на данните", + "Failed to update account details": "Неуспешно обновяване на детайлите", + "Failed to update billing information, please try again": "Неуспешно обновяване на данните за плащане, опитайте отново", + "Failed to update newsletter settings": "Неуспешно обновяване на настройките на бюлетина", + "Failed to update subscription, please try again": "Не успяхте да обновите абонамента, опитайте отново", "Failed to verify code, please try again": "Неуспешна проверка на кода, опитайте отново", "Forever": "Завинаги", "Free Trial – Ends {trialEnd}": "Безплатен тест – до {trialEnd}", @@ -119,21 +119,21 @@ "Name": "Име", "Need more help? Contact support": "Още имате нужда от помощ? Потърсете поддръжката", "Newsletters can be disabled on your account for two reasons: A previous email was marked as spam, or attempting to send an email resulted in a permanent failure (bounce).": "Бюлетините могат да бъдат деактивирани в профила ви по две причини: предишен имейл е бил маркиран като спам или опитът за изпращане на имейл е довел до траен неуспех (отказ).", - "Next payment": "", + "Next payment": "Следващо плащане", "No member exists with this e-mail address.": "Няма абонат с такъв имейл адрес.", "No member exists with this e-mail address. Please sign up first.": "Няма абонат с такъв имейл адрес. Моля, първо се регистрирайте.", "Not receiving emails?": "Не получавате поща?", "Now check your email!": "Проверете имейла си!", "Once resubscribed, if you still don't see emails in your inbox, check your spam folder. Some inbox providers keep a record of previous spam complaints and will continue to flag emails. If this happens, mark the latest newsletter as 'Not spam' to move it back to your primary inbox.": "След като се абонирате отново, ако все още не получавате имейли, проверете папката за спам. Някои доставчици пазят история с предишни оплаквания за спам и ще продължат да маркират имейлите. Ако вашият случай е такъв, маркирайте последния бюлетин като 'Не е спам', за да го преместите обратно в основната си папка.", - "Open AOL Mail": "", - "Open email": "", - "Open Gmail": "", - "Open Hey": "", - "Open iCloud Mail": "", - "Open Mail.ru": "", - "Open Outlook": "", - "Open Proton Mail": "", - "Open Yahoo Mail": "", + "Open AOL Mail": "Отворете AOL Mail", + "Open email": "Отворете имейла си", + "Open Gmail": "Отворете Gmail", + "Open Hey": "Отворете Hey", + "Open iCloud Mail": "Отворете iCloud Mail", + "Open Mail.ru": "Отворете Mail.ru", + "Open Outlook": "Отворете Outlook", + "Open Proton Mail": "Отворете Proton Mail", + "Open Yahoo Mail": "Отворете Yahoo Mail", "Permanent failure (bounce)": "Постоянен проблем (отказ)", "Phone number": "Телефонен номер", "Plan": "План", @@ -168,18 +168,18 @@ "Submit feedback": "Изпратете отзив", "Subscribe": "Абонамент", "Subscribed": "Абониран", - "Subscription plan updated successfully": "Абонаментният план е актуализиран успешно", + "Subscription plan updated successfully": "Абонаментният план е обновен успешно", "Success": "Чудесно", "Success! Check your email for magic link to sign-in.": "Чудесно! Проверете имейла си за своя магически линк за влизане.", "Success! Your account is fully activated, you now have access to all content.": "Чудесно! Вашият профил е активиран и вече имате достъп до цялото съдържание.", - "Success! Your email is updated.": "Чудесно! Вашият имейл е актуализиран.", + "Success! Your email is updated.": "Чудесно! Вашият имейл е обновен.", "Successfully unsubscribed": "Успешно отписване", "Thank you for subscribing. Before you start reading, below are a few other sites you may enjoy.": "Благодарим ви за абонамента. Преди да започнете да четете, още няколко сайта, които може да ви харесат.", "Thank you for your support": "Благодарности за подкрепата ви", "Thank you for your support!": "Благодарности за подкрепата ви!", "Thanks for the feedback!": "Благодарности за обратната връзка!", "That didn't go to plan": "Не се получи както трябва", - "The email address we have for you is {memberEmail} — if that's not correct, you can update it in your .": "Имейлът, с който сте регистрирани, е {memberEmail} — ако това не е вярно, актуализирайте го в .", + "The email address we have for you is {memberEmail} — if that's not correct, you can update it in your .": "Имейлът, с който сте регистрирани, е {memberEmail} — ако това не е вярно, обновете го в .", "There was a problem submitting your feedback. Please try again a little later.": "Имаше проблем при изпращането на обратната връзка. Моля, опитайте отново малко по-късно.", "There was an error cancelling your subscription, please try again.": "Възникна грешка при отмяната на абонамента ви, опитайте отново.", "There was an error continuing your subscription, please try again.": "Възникна грешка при продължаването на абонамента ви, опитайте отново.", diff --git a/package.json b/package.json index 78e6690ac6f..b3c25702da9 100644 --- a/package.json +++ b/package.json @@ -26,46 +26,23 @@ "build:clean": "nx reset && rimraf -g 'ghost/*/build' && rimraf -g 'ghost/*/tsconfig.tsbuildinfo'", "clean:hard": "node ./.github/scripts/clean.js", "dev": "nx run ghost-monorepo:docker:dev", - "dev:forward": "echo '***********************************************************************************************\n* Deprecation warning: This command will be removed in a future release, use yarn dev instead *\n***********************************************************************************************' && sleep 5 && yarn dev", "dev:lexical": "EDITOR_URL=http://localhost:2368/ghost/assets/koenig-lexical/ yarn dev", "dev:analytics": "DEV_COMPOSE_FILES='-f compose.dev.analytics.yaml' nx run ghost-monorepo:docker:dev", "dev:storage": "DEV_COMPOSE_FILES='-f compose.dev.storage.yaml' nx run ghost-monorepo:docker:dev", "dev:all": "DEV_COMPOSE_FILES='-f compose.dev.analytics.yaml -f compose.dev.storage.yaml' nx run ghost-monorepo:docker:dev", - "dev:ghost": "node .github/scripts/dev.js --ghost", - "dev:legacy": "node .github/scripts/dev.js", - "dev:legacy:debug": "DEBUG_COLORS=true DEBUG=@tryghost*,ghost:* yarn dev:legacy", - "dev:legacy:admin": "node .github/scripts/dev.js --admin", - "dev:tinybird": "node .github/scripts/dev-with-tinybird.js --tinybird", "fix": "yarn cache clean && rimraf -g '**/node_modules' && yarn && yarn nx reset", "knex-migrator": "yarn workspace ghost run knex-migrator", "setup": "yarn && git submodule update --init && NODE_ENV=development node .github/scripts/setup.js", - "reset:data": "cd ghost/core && node index.js generate-data --clear-database --quantities members:1000,posts:100 --seed 123", - "reset:data:empty": "cd ghost/core && node index.js generate-data --clear-database --quantities members:0,posts:0 --seed 123", - "reset:data:xxl": "cd ghost/core && node index.js generate-data --clear-database --quantities members:2000000,posts:0,emails:0,members_stripe_customers:0,members_login_events:0,members_status_events:0 --seed 123", - "docker:reset:data": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node index.js generate-data --clear-database --quantities members:1000,posts:100 --seed 123'", - "docker": "COMPOSE_PROFILES=${COMPOSE_PROFILES:-ghost} docker compose run --rm -it ghost", - "docker:dev": "COMPOSE_PROFILES=${COMPOSE_PROFILES:-ghost} docker compose up --attach=ghost --force-recreate --no-log-prefix", - "docker:dev:object-storage": "docker compose -f compose.yml -f compose.object-storage.yml --profile object-storage up --attach=ghost --force-recreate --no-log-prefix", - "docker:dev:analytics": "docker compose --profile analytics up -d --wait", - "docker:dev:analytics:stop": "docker compose --profile analytics down", - "docker:dev:analytics:logs": "docker compose --profile analytics logs -f", - "docker:build": "yarn docker:clean && yarn build:clean && docker compose --profile all build", - "docker:clean": "echo \"Deleting node_modules volumes...\" && docker compose --profile all down --remove-orphans && docker volume ls -q -f name=ghost_node_modules | xargs -I{} docker volume rm {}", - "docker:shell": "COMPOSE_PROFILES=${COMPOSE_PROFILES:-ghost} docker compose run --rm -it ghost /bin/bash", - "docker:mysql": "COMPOSE_PROFILES=${COMPOSE_PROFILES:-ghost} docker compose up mysql -d --wait && docker compose exec mysql mysql -u root -proot ghost", - "docker:sleep": "COMPOSE_PROFILES=${COMPOSE_PROFILES:-ghost} docker compose run -d --name ghost-devcontainer --rm -it ghost /bin/bash -c 'sleep infinity'", - "docker:sleep:stop": "docker stop ghost-devcontainer", - "docker:test:unit": "COMPOSE_PROFILES=${COMPOSE_PROFILES:-ghost} NX_DAEMON=false docker compose run --rm --no-deps ghost yarn test:unit", - "docker:test:browser": "COMPOSE_PROFILES=${COMPOSE_PROFILES:-ghost} docker compose run --rm ghost yarn test:browser", - "docker:test:all": "COMPOSE_PROFILES=${COMPOSE_PROFILES:-ghost} NX_DAEMON=false docker compose run --rm ghost yarn nx run ghost:test:all", - "docker:test:e2e": "COMPOSE_PROFILES=${COMPOSE_PROFILES:-ghost} NX_DAEMON=false docker compose run --rm ghost yarn test:e2e", - "docker:reset": "docker compose --profile all down -v && docker compose up -d --wait", - "docker:restart": "docker compose down && docker compose up -d --wait", - "docker:down": "docker compose --profile all down", + "reset:data": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node index.js generate-data --clear-database --quantities members:1000,posts:100 --seed 123'", + "reset:data:empty": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node index.js generate-data --clear-database --quantities members:0,posts:0 --seed 123'", + "reset:data:xxl": "docker exec ghost-dev bash -c 'cd /home/ghost/ghost/core && node index.js generate-data --clear-database --quantities members:2000000,posts:0,emails:0,members_stripe_customers:0,members_login_events:0,members_status_events:0 --seed 123'", + "docker:build": "docker compose -f compose.dev.yaml ${DEV_COMPOSE_FILES} build", + "docker:clean": "docker compose -f compose.dev.yaml ${DEV_COMPOSE_FILES} --profile all down -v --remove-orphans --rmi local", + "docker:down": "docker compose -f compose.dev.yaml ${DEV_COMPOSE_FILES} down", "lint": "nx run-many -t lint", "test": "nx run-many -t test", "test:unit": "nx run-many -t test:unit", - "test:browser": "node .github/scripts/dev.js --browser-tests --all --", + "test:browser": "node ghost/core/test/scripts/browser-test-runner.js --", "test:e2e": "yarn workspace @tryghost/e2e test", "test:e2e:analytics": "yarn workspace @tryghost/e2e test:analytics", "test:e2e:all": "yarn workspace @tryghost/e2e test:all", diff --git a/yarn.lock b/yarn.lock index 7693d836a04..c63707f99e0 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8839,7 +8839,7 @@ resolved "https://registry.yarnpkg.com/@tryghost/database-info/-/database-info-0.3.30.tgz#550902da9bfa6e7f9adc145e2cc2a51a5c7e1ad5" integrity sha512-WX8PlkHRxiLV6PwXRoRgJi7sGx4taWhHfm285oZPWzsNhvyDvJpeacikaXgbvOPBMdxEfB+ZpIF6XYlLPGdN0Q== -"@tryghost/debug@0.1.35", "@tryghost/debug@^0.1.13", "@tryghost/debug@^0.1.26", "@tryghost/debug@^0.1.35": +"@tryghost/debug@0.1.35": version "0.1.35" resolved "https://registry.yarnpkg.com/@tryghost/debug/-/debug-0.1.35.tgz#6bff0a16b946b25cb14a75942c2f17bd8c993aa2" integrity sha512-NNKMKV6xuaOaXjTJ/NBMWEzfSkFLahtxARlyYbFuxb9y95jhyJb2+mu9Zsd+gKWZZIkP7ACkWqyooTm4rr9eCQ== @@ -8847,7 +8847,7 @@ "@tryghost/root-utils" "^0.3.33" debug "^4.3.1" -"@tryghost/debug@^0.1.36": +"@tryghost/debug@^0.1.13", "@tryghost/debug@^0.1.26", "@tryghost/debug@^0.1.35", "@tryghost/debug@^0.1.36": version "0.1.36" resolved "https://registry.yarnpkg.com/@tryghost/debug/-/debug-0.1.36.tgz#54561ffad4d24632406824aa8b78148a59a826e2" integrity sha512-6B3zrO7Y3SWlxTB9M+dwhj63whE2R4ktmDYI7r1L1Ao9S//Y0XYOPVVlqUDxHfig7T29JliZfbhZw9VdSEUYjg== @@ -9141,10 +9141,10 @@ dependencies: semver "^7.7.0" -"@tryghost/koenig-lexical@1.7.12": - version "1.7.12" - resolved "https://registry.yarnpkg.com/@tryghost/koenig-lexical/-/koenig-lexical-1.7.12.tgz#d546b1149b268bd0e52d3eb7e7198ad50da64a10" - integrity sha512-8JJxtrVFqmyfsr2igg38O1s2zKDrpDtM8GW6eW8pmYIX66qedqsZOFv4cg/rYevZeO1wNejxr4+cME4y97gnXA== +"@tryghost/koenig-lexical@1.7.13": + version "1.7.13" + resolved "https://registry.yarnpkg.com/@tryghost/koenig-lexical/-/koenig-lexical-1.7.13.tgz#9ea9374f4d40fc683164fc36f459e50194943a08" + integrity sha512-0ctOjb3eZbpP/2VJkPk3iREyyZnMWuWXteRRGoz8tyXptqaR/yzYEBd94Zh6Ug1UlCJawY80vVsMFrLydZyDCQ== "@tryghost/limit-service@1.4.1": version "1.4.1" @@ -9200,10 +9200,10 @@ mobiledoc-dom-renderer "0.7.0" mobiledoc-text-renderer "0.4.0" -"@tryghost/mongo-knex@^0.9.1", "@tryghost/mongo-knex@^0.9.2": - version "0.9.2" - resolved "https://registry.yarnpkg.com/@tryghost/mongo-knex/-/mongo-knex-0.9.2.tgz#3bd9c96ec1ced10253fac778b811613e3942e583" - integrity sha512-9YA1wpPwAgAT75YAMX46dGp9HpNS7uwoz8d8o88DS3pE1N6uTJUJPzcqcj6gXcXB0tAnJu+cmr1BdTA2YeAYvw== +"@tryghost/mongo-knex@^0.9.1", "@tryghost/mongo-knex@^0.9.4": + version "0.9.4" + resolved "https://registry.yarnpkg.com/@tryghost/mongo-knex/-/mongo-knex-0.9.4.tgz#bd92d191c28d95367d4898aefc57cf82a1d95304" + integrity sha512-R5mqpuQ1nN4qWAii9Tvg5POZ0nLO7Voy7QaQF+95sseUpfe4XEUsr3+mm4HYPC8MNKginlUm/unLf5QFzFQl5w== dependencies: debug "^4.3.3" lodash "^4.17.21" @@ -9251,6 +9251,16 @@ dependencies: date-fns "^2.28.0" +"@tryghost/nql@0.12.10", "@tryghost/nql@^0.12.5": + version "0.12.10" + resolved "https://registry.yarnpkg.com/@tryghost/nql/-/nql-0.12.10.tgz#3050f24203e8c3946568f6cb03e7371ac10cf4e5" + integrity sha512-kpj2ICTBmkz5Uet7Z/J61C/EEBTfa55np6LnbqW6N8g33uvCh9NkAsM2WgV1NK2lffpQT3Cs/qA2ymzHAguvoA== + dependencies: + "@tryghost/mongo-knex" "^0.9.4" + "@tryghost/mongo-utils" "^0.6.3" + "@tryghost/nql-lang" "^0.6.4" + mingo "^2.2.2" + "@tryghost/nql@0.12.6": version "0.12.6" resolved "https://registry.yarnpkg.com/@tryghost/nql/-/nql-0.12.6.tgz#18c8b57f73d37269e2c0ab23b6c3f4f4030804b4" @@ -9261,16 +9271,6 @@ "@tryghost/nql-lang" "^0.6.2" mingo "^2.2.2" -"@tryghost/nql@0.12.8", "@tryghost/nql@^0.12.5": - version "0.12.8" - resolved "https://registry.yarnpkg.com/@tryghost/nql/-/nql-0.12.8.tgz#b753603126e4f7e9f211e77485d8be19bec4fad0" - integrity sha512-88P6IijqjeFyIeDU2WdJPE9SVAZF8MpL53JIsNf1aViqVRAnfVqC/e6UmBsIoHJfFgSJsNdW7Jtznu7BYYvPSA== - dependencies: - "@tryghost/mongo-knex" "^0.9.2" - "@tryghost/mongo-utils" "^0.6.3" - "@tryghost/nql-lang" "^0.6.4" - mingo "^2.2.2" - "@tryghost/pretty-cli@1.2.47", "@tryghost/pretty-cli@^1.2.38": version "1.2.47" resolved "https://registry.yarnpkg.com/@tryghost/pretty-cli/-/pretty-cli-1.2.47.tgz#314f06b12c486ecdd6547f0ffaf2274e02047531" @@ -9330,7 +9330,7 @@ got "13.0.0" lodash "^4.17.21" -"@tryghost/root-utils@0.3.33", "@tryghost/root-utils@^0.3.24", "@tryghost/root-utils@^0.3.33": +"@tryghost/root-utils@0.3.33": version "0.3.33" resolved "https://registry.yarnpkg.com/@tryghost/root-utils/-/root-utils-0.3.33.tgz#208e3d15520131c2d4157c7e62fe74771a7a110f" integrity sha512-Gmc/TrKtiRT7PV9JOPoSZ7jAOl/jJDWJFKNaLZbDQaiJIBP5C6PucqEfRqGb2Ko/S9j73HzEEBu6B7+qZMvbBg== @@ -9338,7 +9338,7 @@ caller "^1.0.1" find-root "^1.1.0" -"@tryghost/root-utils@^0.3.34": +"@tryghost/root-utils@^0.3.24", "@tryghost/root-utils@^0.3.33", "@tryghost/root-utils@^0.3.34": version "0.3.34" resolved "https://registry.yarnpkg.com/@tryghost/root-utils/-/root-utils-0.3.34.tgz#db131568cf04069929a27fb8ed519b16a2226a5d" integrity sha512-RH3nPr5/1tK/nQTZMSO9rDeEelFZbY0gB0HInbhXl7995cgR/yuOnTPWa3CCQT3m/cYPZOPFhlInO8evke9rDg== @@ -9386,14 +9386,14 @@ resolved "https://registry.yarnpkg.com/@tryghost/timezone-data/-/timezone-data-0.4.12.tgz#c8a63979a073fe7ca9a85af4fcf255ff73a13859" integrity sha512-oUDQyYP3sxpC1/ndT15tfGHx50YqIC1ovktyEkS/1f04+H5+bPiCgCLu/Dvi63u4Jc1GTEcTvHoRqTngpA+mZw== -"@tryghost/tpl@0.1.35", "@tryghost/tpl@^0.1.35": +"@tryghost/tpl@0.1.35": version "0.1.35" resolved "https://registry.yarnpkg.com/@tryghost/tpl/-/tpl-0.1.35.tgz#7ad6b84f94529b2e709046255706dcda083267c9" integrity sha512-U6zWUnxDgw2nHZc5DTI0JuqYsytK76BVfIB3hz2rYrCKL4O6JL76F25Jr6I+A8cEIfL5GKLSG8/tWmEAHLy0Mg== dependencies: lodash.template "^4.5.0" -"@tryghost/tpl@^0.1.36": +"@tryghost/tpl@^0.1.35", "@tryghost/tpl@^0.1.36": version "0.1.36" resolved "https://registry.yarnpkg.com/@tryghost/tpl/-/tpl-0.1.36.tgz#1c8c5b23b8948d58f207fccb7c707658e8a166fa" integrity sha512-1bgvGE06ABEuXQqVeuYK3i58YiEFf6fHiXSCq4CzA+MIBUoUs4ID9oQLkdPJwPQ9TsuuJt3z4DPiG8KnLt06fg== @@ -9413,7 +9413,7 @@ remark-footnotes "1.0.0" unist-util-visit "^2.0.0" -"@tryghost/validator@0.2.17", "@tryghost/validator@^0.2.17": +"@tryghost/validator@0.2.17": version "0.2.17" resolved "https://registry.yarnpkg.com/@tryghost/validator/-/validator-0.2.17.tgz#51732e93677f3ee10e9c641c5d2cc93f8e038934" integrity sha512-MsiF8tkZmsUmWmtDCr8oMua/Lyk0b16zJrT/tDHaERi2eKKgOqf0RwLRdIS/droqX1uOZCX+cLX3xSYbKhh4cA== @@ -9424,7 +9424,7 @@ moment-timezone "^0.5.23" validator "7.2.0" -"@tryghost/validator@^0.2.18": +"@tryghost/validator@^0.2.17", "@tryghost/validator@^0.2.18": version "0.2.18" resolved "https://registry.yarnpkg.com/@tryghost/validator/-/validator-0.2.18.tgz#de9a2fbec5c81024a745fc4b4cbfdaaff9a68587" integrity sha512-ua345mKqmOQvykwSEH25dYSyTe1bndS8/mwr/n/mRREW+p1a0dhuTuS21Z0s2TkYMoyMTcXL50XC3v8jBapk/Q== @@ -24467,12 +24467,12 @@ lodash.upperfirst@4.3.1: resolved "https://registry.yarnpkg.com/lodash.upperfirst/-/lodash.upperfirst-4.3.1.tgz#1365edf431480481ef0d1c68957a5ed99d49f7ce" integrity sha512-sReKOYJIJf74dhJONhU4e0/shzi1trVbSWDOhKYE5XV2O+H7Sb2Dihwuc7xWxVl+DgFPyTqIN3zMfT9cq5iWDg== -lodash@4.17.21, lodash@^4.0.0, lodash@^4.14.2, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.5.1, lodash@^4.7.0, lodash@~4.17.21: +lodash@4.17.21: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== -lodash@4.17.23: +lodash@4.17.23, lodash@^4.0.0, lodash@^4.14.2, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.19, lodash@^4.17.20, lodash@^4.17.21, lodash@^4.17.4, lodash@^4.5.1, lodash@^4.7.0, lodash@~4.17.21: version "4.17.23" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a" integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==