diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index af4f3e1ea5b..6fda9af5a90 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -312,7 +312,7 @@ jobs: NX_BASE: ${{ needs.job_setup.outputs.nx_base }} NX_HEAD: ${{ env.HEAD_COMMIT }} - - uses: tryghost/actions/actions/slack-build@db6f335950a25f481f4fedfa84ab43a470348c07 # main + - uses: tryghost/actions/actions/slack-build@a6201b612c6b77c5b210e96e6ff31b8886b93966 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} @@ -376,7 +376,7 @@ jobs: name: admin-coverage path: ghost/*/coverage/cobertura-coverage.xml - - uses: tryghost/actions/actions/slack-build@db6f335950a25f481f4fedfa84ab43a470348c07 # main + - uses: tryghost/actions/actions/slack-build@a6201b612c6b77c5b210e96e6ff31b8886b93966 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} @@ -448,7 +448,7 @@ jobs: exit 1 fi - - uses: tryghost/actions/actions/slack-build@db6f335950a25f481f4fedfa84ab43a470348c07 # main + - uses: tryghost/actions/actions/slack-build@a6201b612c6b77c5b210e96e6ff31b8886b93966 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} @@ -584,7 +584,7 @@ jobs: ghost/*/coverage-e2e/cobertura-coverage.xml ghost/*/coverage-integration/cobertura-coverage.xml - - uses: tryghost/actions/actions/slack-build@db6f335950a25f481f4fedfa84ab43a470348c07 # main + - uses: tryghost/actions/actions/slack-build@a6201b612c6b77c5b210e96e6ff31b8886b93966 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} @@ -670,7 +670,7 @@ jobs: exit 1 fi - - uses: tryghost/actions/actions/slack-build@db6f335950a25f481f4fedfa84ab43a470348c07 # main + - uses: tryghost/actions/actions/slack-build@a6201b612c6b77c5b210e96e6ff31b8886b93966 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} @@ -723,7 +723,7 @@ jobs: path: apps/${{ steps.app_name.outputs.name }}/playwright-report retention-days: 30 - - uses: tryghost/actions/actions/slack-build@db6f335950a25f481f4fedfa84ab43a470348c07 # main + - uses: tryghost/actions/actions/slack-build@a6201b612c6b77c5b210e96e6ff31b8886b93966 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} @@ -740,7 +740,7 @@ jobs: working-directory: ghost/core/core/server/data/tinybird services: tinybird: - image: tinybirdco/tinybird-local:latest@sha256:974885a33b3fe145bbc85e2c17ef32d94bbd14459fc5d69b1ef8375dfa998353 + image: tinybirdco/tinybird-local:latest@sha256:0b6225bcaa3283b8abd40b577ef9523fa1feed9e90c751dfa5190ed422b40684 ports: - 7181:7181 steps: @@ -822,7 +822,7 @@ jobs: run: | [ -f ~/.ghost/logs/*.log ] && cat ~/.ghost/logs/*.log - - uses: tryghost/actions/actions/slack-build@db6f335950a25f481f4fedfa84ab43a470348c07 # main + - uses: tryghost/actions/actions/slack-build@a6201b612c6b77c5b210e96e6ff31b8886b93966 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} @@ -1406,7 +1406,7 @@ jobs: path: e2e/test-results retention-days: 7 - - uses: tryghost/actions/actions/slack-build@db6f335950a25f481f4fedfa84ab43a470348c07 # main + - uses: tryghost/actions/actions/slack-build@a6201b612c6b77c5b210e96e6ff31b8886b93966 # main if: failure() && github.event_name == 'push' && github.ref == 'refs/heads/main' with: status: ${{ job.status }} diff --git a/.github/workflows/label-actions.yml b/.github/workflows/label-actions.yml index 2e10cbf288c..c3dafb59df1 100644 --- a/.github/workflows/label-actions.yml +++ b/.github/workflows/label-actions.yml @@ -18,4 +18,4 @@ jobs: runs-on: ubuntu-latest if: github.repository_owner == 'TryGhost' steps: - - uses: tryghost/actions/actions/label-actions@db6f335950a25f481f4fedfa84ab43a470348c07 # main + - uses: tryghost/actions/actions/label-actions@a6201b612c6b77c5b210e96e6ff31b8886b93966 # main diff --git a/apps/admin-x-design-system/src/global/modal/confirmation-modal.tsx b/apps/admin-x-design-system/src/global/modal/confirmation-modal.tsx index a3dae4bb316..24b2226f9eb 100644 --- a/apps/admin-x-design-system/src/global/modal/confirmation-modal.tsx +++ b/apps/admin-x-design-system/src/global/modal/confirmation-modal.tsx @@ -4,7 +4,7 @@ import React, {useState} from 'react'; import {ButtonColor} from '../button'; export interface ConfirmationModalProps { - title?: string; + title?: React.ReactNode; prompt?: React.ReactNode; cancelLabel?: string; okLabel?: string; @@ -16,6 +16,7 @@ export interface ConfirmationModalProps { }) => void | Promise; customFooter?: boolean | React.ReactNode; formSheet?: boolean; + stickyFooter?: boolean; } export const ConfirmationModalContent: React.FC = ({ @@ -28,7 +29,8 @@ export const ConfirmationModalContent: React.FC = ({ onCancel, onOk, customFooter, - formSheet = true + formSheet = true, + stickyFooter = false }) => { const modal = useModal(); const [taskState, setTaskState] = useState<'running' | ''>(''); @@ -41,6 +43,7 @@ export const ConfirmationModalContent: React.FC = ({ formSheet={formSheet} okColor={okColor} okLabel={taskState === 'running' ? okRunningLabel : okLabel} + stickyFooter={stickyFooter} testId='confirmation-modal' title={title} width={540} diff --git a/apps/admin-x-design-system/src/global/modal/modal.tsx b/apps/admin-x-design-system/src/global/modal/modal.tsx index 98670f4e7c3..43b3718d765 100644 --- a/apps/admin-x-design-system/src/global/modal/modal.tsx +++ b/apps/admin-x-design-system/src/global/modal/modal.tsx @@ -21,7 +21,7 @@ export interface ModalProps { align?: 'center' | 'left' | 'right'; testId?: string; - title?: string; + title?: React.ReactNode; okLabel?: string; okColor?: ButtonColor; okLoading?: boolean; diff --git a/apps/admin-x-framework/src/api/themes.ts b/apps/admin-x-framework/src/api/themes.ts index ee0c3df914b..756d89fdc49 100644 --- a/apps/admin-x-framework/src/api/themes.ts +++ b/apps/admin-x-framework/src/api/themes.ts @@ -22,7 +22,7 @@ export type InstalledTheme = Theme & { warnings?: ThemeProblem<'warning'>[]; } -export type ThemeProblem = { +export type ThemeProblem = { code: string details: string failures: Array<{ diff --git a/apps/admin-x-settings/src/components/settings/site/theme-modal.tsx b/apps/admin-x-settings/src/components/settings/site/theme-modal.tsx index bc8b7e9fba2..75521e4932b 100644 --- a/apps/admin-x-settings/src/components/settings/site/theme-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/theme-modal.tsx @@ -158,8 +158,8 @@ const ThemeToolbar: React.FC = ({ } if (fatalErrors && !data) { - let title = 'Invalid Theme'; - let prompt = <>This theme is invalid and cannot be activated. Fix the following errors and re-upload the theme; + let title = 'Theme not uploaded'; + let prompt = <>This theme couldn't be uploaded because Ghost found a blocking validation error. Fix the issue below and upload the theme again.; NiceModal.show(InvalidThemeModal, { title, prompt, @@ -190,17 +190,15 @@ const ThemeToolbar: React.FC = ({ } if (uploadedTheme?.errors?.length || uploadedTheme.warnings?.length) { - const hasErrors = uploadedTheme?.errors?.length; - - title = `Upload successful with ${hasErrors ? 'errors' : 'warnings'}`; + title = 'Upload successful'; prompt = <> - The theme "{uploadedTheme.name}" was installed but we detected some {hasErrors ? 'errors' : 'warnings'}. + The theme "{uploadedTheme.name}" was installed successfully. ; if (!uploadedTheme.active) { prompt = <> {prompt} - You are still able to activate and use the theme but it is recommended to fix these {hasErrors ? 'errors' : 'warnings'} before you do so. + You can activate it when you're ready. ; } } @@ -484,17 +482,15 @@ const ChangeThemeModal: React.FC = ({source, themeRef}) = } if (newlyInstalledTheme.errors?.length || newlyInstalledTheme.warnings?.length) { - const hasErrors = newlyInstalledTheme.errors?.length; - - title = `Installed with ${hasErrors ? 'errors' : 'warnings'}`; + title = 'Installed successfully'; prompt = <> - The theme "{newlyInstalledTheme.name}" was installed successfully but we detected some {hasErrors ? 'errors' : 'warnings'}. + The theme "{newlyInstalledTheme.name}" was installed successfully. ; if (!newlyInstalledTheme.active) { prompt = <> {prompt} - You are still able to activate and use the theme but it is recommended to contact the theme developer fix these {hasErrors ? 'errors' : 'warnings'} before you do so. + You can activate it when you're ready. ; } } diff --git a/apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx b/apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx index f4efc958dfd..831037c1f05 100644 --- a/apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx +++ b/apps/admin-x-settings/src/components/settings/site/theme/advanced-theme-settings.tsx @@ -72,8 +72,8 @@ const ThemeActions: React.FC = ({ } else { handleError(e); } - let title = 'Invalid Theme'; - let prompt = <>This theme is invalid and cannot be activated. Fix the following errors and re-upload the theme; + let title = 'Theme not activated'; + let prompt = <>This theme couldn't be activated because Ghost found a blocking validation error. Fix the issue below and try again.; if (fatalErrors) { NiceModal.show(InvalidThemeModal, { diff --git a/apps/admin-x-settings/src/components/settings/site/theme/invalid-theme-modal.tsx b/apps/admin-x-settings/src/components/settings/site/theme/invalid-theme-modal.tsx index 9eadc8996c7..2e2ad8dda88 100644 --- a/apps/admin-x-settings/src/components/settings/site/theme/invalid-theme-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/theme/invalid-theme-modal.tsx @@ -1,89 +1,50 @@ import NiceModal from '@ebay/nice-modal-react'; -import React, {type ReactNode, useState} from 'react'; -import {Button, ConfirmationModalContent, Heading, List, ListItem} from '@tryghost/admin-x-design-system'; -import {type ThemeProblem} from '@tryghost/admin-x-framework/api/themes'; +import React, {type ReactNode} from 'react'; +import {ConfirmationModalContent} from '@tryghost/admin-x-design-system'; +import {ErrorTextCard, type FatalErrors, ThemeValidationDetailsDisclosure, ValidationProblemCard, getIssuesFromFatalErrors} from './theme-validation-details'; +import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; -type FatalError = { - details: { - errors: ThemeProblem[]; - }|string; - }; - -export type FatalErrors = FatalError[]; - -export const ThemeProblemView = ({problem}:{problem: ThemeProblem}) => { - const [isExpanded, setExpanded] = useState(false); - - const handleClick = () => { - setExpanded(!isExpanded); - }; - - return -
- { - problem?.fatal ? - Fatal: - : - {problem.level === 'error' ? 'Error: ' : 'Warning: '} - } - -
-
-
- { - isExpanded ? -
-
- Affected files: -
    - {problem.failures.map(failure =>
  • {failure.ref}{failure.message ? `: ${failure.message}` : ''}
  • )} -
-
: - null - } - - } - hideActions - separator - />; -}; +export type {FatalErrors} from './theme-validation-details'; const InvalidThemeModal: React.FC<{ title: string prompt: ReactNode fatalErrors?: FatalErrors; + validationDetailsDefaultOpen?: boolean; onRetry?: (modal?: { remove: () => void; }) => void | Promise; -}> = ({title, prompt, fatalErrors, onRetry}) => { - let warningPrompt = null; - if (fatalErrors) { - warningPrompt =
- - {fatalErrors.map((error) => { - if (typeof error.details === 'object' && error.details.errors && error.details.errors.length > 0) { - return error.details.errors.map(err => ); - } else if (typeof error.details === 'string') { - return ; - } else { - return null; - } - })} - -
; - } +}> = ({title, prompt, fatalErrors, validationDetailsDefaultOpen, onRetry}) => { + const {data: configData} = useBrowseConfig(); + const defaultOpen = validationDetailsDefaultOpen ?? configData?.config?.environment === 'development'; + const {blockingProblems, secondaryProblems, stringErrors} = getIssuesFromFatalErrors(fatalErrors); + const blockingIssueCount = blockingProblems.length + stringErrors.length; + const promptText = prompt ?? <>Ghost found {blockingIssueCount === 1 ? 'a blocking validation error' : `${blockingIssueCount} blocking validation errors`} and did not save your theme. Fix {blockingIssueCount === 1 ? 'the issue' : 'the issues'} below and try again.; return - {prompt} - {warningPrompt} +
+
{promptText}
+ + {(blockingProblems.length > 0 || stringErrors.length > 0) && ( +
+ {blockingProblems.map(problem => ( + + ))} + {stringErrors.map(error => )} +
+ )} + + +
} + stickyFooter={true} title={title} onOk={onRetry} />; diff --git a/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx b/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx index 292f1bb8d0f..410a131a8f6 100644 --- a/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/theme/theme-code-editor-modal.tsx @@ -812,8 +812,7 @@ const ThemeCodeEditorModal: React.FC<{themeName: string}> = ({themeName}) => { if (!response.ok) { if (response.status === 422 && data?.errors) { NiceModal.show(InvalidThemeModal, { - title: 'Invalid Theme', - prompt: <>Fix the validation errors below and try saving again., + title: 'Theme not saved', fatalErrors: data.errors as FatalErrors }); return; diff --git a/apps/admin-x-settings/src/components/settings/site/theme/theme-installed-modal.tsx b/apps/admin-x-settings/src/components/settings/site/theme/theme-installed-modal.tsx index d98a7321beb..36ec458e786 100644 --- a/apps/admin-x-settings/src/components/settings/site/theme/theme-installed-modal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/theme/theme-installed-modal.tsx @@ -1,89 +1,77 @@ import NiceModal from '@ebay/nice-modal-react'; -import React, {type ReactNode, useState} from 'react'; +import React, {type ReactNode} from 'react'; import useCustomFonts from '../../../../hooks/use-custom-fonts'; -import {Button, ConfirmationModalContent, Heading, List, ListItem, showToast} from '@tryghost/admin-x-design-system'; -import {type InstalledTheme, type ThemeProblem, useActivateTheme} from '@tryghost/admin-x-framework/api/themes'; +import {ConfirmationModalContent, showToast} from '@tryghost/admin-x-design-system'; +import {type InstalledTheme, useActivateTheme} from '@tryghost/admin-x-framework/api/themes'; +import {OutcomeBanner, ThemeValidationDetailsDisclosure, getIssuesFromInstalledTheme} from './theme-validation-details'; +import {getHomepageUrl, useBrowseSite} from '@tryghost/admin-x-framework/api/site'; +import {useBrowseConfig} from '@tryghost/admin-x-framework/api/config'; import {useHandleError} from '@tryghost/admin-x-framework/hooks'; -export const ThemeProblemView = ({problem}:{problem: ThemeProblem}) => { - const [isExpanded, setExpanded] = useState(false); - - return -
- {problem.level === 'error' ? 'Error: ' : 'Warning: '} - -
-
-
- { - isExpanded ? -
-
- Affected files: -
    - {problem.failures.map(failure =>
  • {failure.ref}{failure.message ? `: ${failure.message}` : ''}
  • )} -
-
: - null - } - - } - hideActions - separator - />; -}; - const ThemeInstalledModal: React.FC<{ title: string prompt: ReactNode installedTheme: InstalledTheme; + validationDetailsDefaultOpen?: boolean; onActivate?: () => void; -}> = ({title, prompt, installedTheme, onActivate}) => { +}> = ({title, installedTheme, validationDetailsDefaultOpen, onActivate}) => { const {mutateAsync: activateTheme} = useActivateTheme(); const {refreshActiveThemeData} = useCustomFonts(); const handleError = useHandleError(); + const {data: configData} = useBrowseConfig(); + const {data: siteData} = useBrowseSite(); + const defaultOpen = validationDetailsDefaultOpen ?? configData?.config?.environment === 'development'; + const secondaryProblems = getIssuesFromInstalledTheme(installedTheme); + const homepageUrl = siteData?.site ? getHomepageUrl(siteData.site) : undefined; - /* eslint-disable react/no-array-index-key */ - let errorPrompt = null; - if (installedTheme && installedTheme.errors) { - errorPrompt =
- Highly recommended to fix, functionality could be restricted} title="Errors"> - {installedTheme.errors?.map((error, index) => )} - -
; - } - - let warningPrompt = null; - if (installedTheme && installedTheme.warnings) { - warningPrompt =
- - {installedTheme.warnings?.map((warning, index) => )} - -
; - } - /* eslint-enable react/no-array-index-key */ - - let okLabel = `Activate${installedTheme.errors?.length ? ' with errors' : ''}`; + let okLabel = 'Activate theme'; if (installedTheme.active) { okLabel = 'OK'; } + const modalTitle = installedTheme.active ? It's live! : title; + const outcomeTitle = 'Uploaded successfully'; + const outcomeCopy = installedTheme.active ? ( + <> + Your theme {installedTheme.name} was saved successfully and is now visible to your readers. + {homepageUrl ? <> + {' '}Take a look → + : null} + + ) : ( + <> + {installedTheme.name} has been uploaded. Activate it to make it live. + + ); + return - {prompt} +
+ {installedTheme.active ? ( +
+

{outcomeCopy}

+
+ ) : ( + +
+

{outcomeCopy}

+
+
+ )} - {errorPrompt} - {warningPrompt} + +
} - title={title} + stickyFooter={true} + title={modalTitle} onOk={async (activateModal) => { if (!installedTheme.active) { try { diff --git a/apps/admin-x-settings/src/components/settings/site/theme/theme-validation-details.tsx b/apps/admin-x-settings/src/components/settings/site/theme/theme-validation-details.tsx new file mode 100644 index 00000000000..309798fc888 --- /dev/null +++ b/apps/admin-x-settings/src/components/settings/site/theme/theme-validation-details.tsx @@ -0,0 +1,226 @@ +import React, {useEffect, useMemo, useState} from 'react'; +import {Badge, Banner} from '@tryghost/shade/components'; +import {type InstalledTheme, type ThemeProblem} from '@tryghost/admin-x-framework/api/themes'; +import {LucideIcon} from '@tryghost/shade/utils'; + +type ThemeValidationErrorDetails = { + errors?: ThemeProblem[]; + warnings?: ThemeProblem[]; +}; + +type ThemeValidationError = { + details: ThemeValidationErrorDetails | string; +}; + +export type FatalErrors = ThemeValidationError[]; + +type IssueSummary = { + blockingProblems: ThemeProblem[]; + secondaryProblems: ThemeProblem[]; + stringErrors: string[]; +}; + +type DisplaySeverity = 'Error' | 'Warning' | 'Recommendation'; + +function isDetailsObject(details: ThemeValidationError['details']): details is ThemeValidationErrorDetails { + return typeof details === 'object' && details !== null; +} + +function allProblemsFromDetails(details: ThemeValidationErrorDetails) { + return [...(details.errors || []), ...(details.warnings || [])]; +} + +export function getIssuesFromFatalErrors(fatalErrors: FatalErrors = []): IssueSummary { + const blockingProblems: ThemeProblem[] = []; + const secondaryProblems: ThemeProblem[] = []; + const stringErrors: string[] = []; + + fatalErrors.forEach((error) => { + if (isDetailsObject(error.details)) { + allProblemsFromDetails(error.details).forEach((problem) => { + if (problem.fatal) { + blockingProblems.push(problem); + } else { + secondaryProblems.push(problem); + } + }); + } else { + stringErrors.push(error.details); + } + }); + + return {blockingProblems, secondaryProblems, stringErrors}; +} + +export function getIssuesFromInstalledTheme(installedTheme: InstalledTheme): ThemeProblem[] { + return [...(installedTheme.errors || []), ...(installedTheme.warnings || [])]; +} + +function getDisplaySeverity(problem: ThemeProblem): DisplaySeverity { + if (problem.level === 'warning') { + return 'Warning'; + } + + if (problem.level === 'recommendation') { + return 'Recommendation'; + } + + return 'Error'; +} + +function getDisplayVariant(problem: ThemeProblem): 'destructive' | 'warning' | 'secondary' { + if (problem.level === 'warning') { + return 'warning'; + } + + if (problem.level === 'recommendation') { + return 'secondary'; + } + + return 'destructive'; +} + +function formatNonBlockingIssueCount(count: number) { + return `${count} non-blocking ${count === 1 ? 'issue' : 'issues'}`; +} + +function ProblemDetails({problem}: {problem: ThemeProblem}) { + return ( +
+
+ {problem.failures?.length > 0 && ( +
+
Affected files
+
    + {problem.failures.map(failure => ( +
  • + {failure.ref} + {failure.message ? : {failure.message} : null} +
  • + ))} +
+
+ )} +
+ ); +} + +export function ValidationProblemCard({problem, prominent = false}: {problem: ThemeProblem; prominent?: boolean}) { + const [expanded, setExpanded] = useState(prominent); + const displaySeverity = getDisplaySeverity(problem); + + return ( +
+ + {expanded && ( +
+ +
+ )} +
+ ); +} + +export function ThemeValidationDetailsDisclosure({ + defaultOpen, + problems +}: { + defaultOpen: boolean; + problems: ThemeProblem[]; +}) { + const [open, setOpen] = useState(defaultOpen); + const count = problems.length; + + useEffect(() => { + setOpen(defaultOpen); + }, [defaultOpen]); + + const sortedProblems = useMemo(() => { + return [...problems].sort((a, b) => { + const severityOrder: Record = {Error: 0, Warning: 1, Recommendation: 2}; + return severityOrder[getDisplaySeverity(a)] - severityOrder[getDisplaySeverity(b)]; + }); + }, [problems]); + + if (!count) { + return null; + } + + return ( +
+ + {open && ( +
+ {sortedProblems.map(problem => ( + + ))} +
+ )} +
+ ); +} + +export function ErrorTextCard({message}: {message: string}) { + return ( +
+
+ +

{message}

+
+
+ ); +} + +export function OutcomeBanner({ + children, + title, + variant +}: { + children: React.ReactNode; + title: string; + variant: 'success' | 'destructive'; +}) { + const Icon = variant === 'success' ? LucideIcon.CheckCircle2 : LucideIcon.AlertTriangle; + const iconClassName = variant === 'success' ? 'text-state-success' : 'text-destructive'; + + return ( + +
+
+ +
+
+

{title}

+
{children}
+
+
+
+ ); +} diff --git a/apps/admin-x-settings/test/acceptance/site/design.test.ts b/apps/admin-x-settings/test/acceptance/site/design.test.ts index 481a607d954..f2f7b310757 100644 --- a/apps/admin-x-settings/test/acceptance/site/design.test.ts +++ b/apps/admin-x-settings/test/acceptance/site/design.test.ts @@ -479,7 +479,7 @@ test.describe('Design settings', async () => { await modal.getByRole('button', {name: 'Install Headline'}).click(); - await expect(page.getByTestId('confirmation-modal')).toHaveText(/installed/); + await expect(page.getByTestId('confirmation-modal')).toHaveText(/uploaded/i); await page.getByRole('button', {name: 'Activate'}).click(); diff --git a/apps/admin-x-settings/test/acceptance/site/theme.test.ts b/apps/admin-x-settings/test/acceptance/site/theme.test.ts index a86cb61e4b5..903f1033d89 100644 --- a/apps/admin-x-settings/test/acceptance/site/theme.test.ts +++ b/apps/admin-x-settings/test/acceptance/site/theme.test.ts @@ -120,7 +120,7 @@ test.describe('Theme settings', async () => { await expect(modal.getByRole('button', {name: 'Activate Casper'})).toBeVisible(); await modal.getByRole('button', {name: 'Activate Casper'}).click(); - await expect(page.getByTestId('confirmation-modal')).toHaveText(/activate/); + await expect(page.getByTestId('confirmation-modal')).toHaveText(/activate/i); await page.getByTestId('confirmation-modal').getByRole('button', {name: 'Activate'}).click(); await expect(page.getByTestId('toast-success')).toHaveText(/casper is now your active theme/); diff --git a/compose.dev.analytics.yaml b/compose.dev.analytics.yaml index 5e66ce450d4..2a9c443c037 100644 --- a/compose.dev.analytics.yaml +++ b/compose.dev.analytics.yaml @@ -29,7 +29,7 @@ services: condition: service_completed_successfully tinybird-local: - image: tinybirdco/tinybird-local:latest@sha256:974885a33b3fe145bbc85e2c17ef32d94bbd14459fc5d69b1ef8375dfa998353 + image: tinybirdco/tinybird-local:latest@sha256:0b6225bcaa3283b8abd40b577ef9523fa1feed9e90c751dfa5190ed422b40684 container_name: ghost-dev-tinybird platform: linux/amd64 stop_grace_period: 2s diff --git a/ghost/core/core/server/services/email-service/batch-sending-service.js b/ghost/core/core/server/services/email-service/batch-sending-service.js index cd0d686ab9f..e9de5493fdf 100644 --- a/ghost/core/core/server/services/email-service/batch-sending-service.js +++ b/ghost/core/core/server/services/email-service/batch-sending-service.js @@ -206,7 +206,8 @@ class BatchSendingService { return await email.getLazyRelation('newsletter', {require: true}); }, {...this.#getBeforeRetryConfig(email), description: `getLazyRelation newsletter for email ${email.id}`}); - const postRelations = [...new Set(['posts_meta', 'authors', ...this.#getRequiredUrlRelations()])]; + // 'tiers' is required by the email tier-gating logic (renderer/segmenter), not for URL generation + const postRelations = [...new Set(['posts_meta', 'authors', 'tiers', ...this.#getRequiredUrlRelations()])]; const post = await this.retryDb(async () => { return await email.getLazyRelation('post', {require: true, withRelated: postRelations}); }, {...this.#getBeforeRetryConfig(email), description: `getLazyRelation post for email ${email.id}`}); diff --git a/ghost/core/core/server/services/email-service/email-renderer.js b/ghost/core/core/server/services/email-service/email-renderer.js index a9a1e9c7047..fb508da5fbd 100644 --- a/ghost/core/core/server/services/email-service/email-renderer.js +++ b/ghost/core/core/server/services/email-service/email-renderer.js @@ -17,6 +17,7 @@ const EmailAddressParser = require('../email-address/email-address-parser'); const {getEmailDesign} = require('../email-rendering/email-design'); const {registerHelpers} = require('./helpers/register-helpers'); const crypto = require('crypto'); +const {getPostAccessFilter} = require('../members/content-gating'); /** @import {TemplateDelegate} from 'handlebars' */ const DEFAULT_LOCALE = 'en-gb'; @@ -73,6 +74,82 @@ function isValidLocale(locale) { } } +/** + * Builds a plain {visibility, tiers} shape from a Post model, for the shared + * content-gating helpers (which operate on serialized post attributes). + * @param {Post} post + * @returns {{visibility: string, tiers: object[]|undefined}} + */ +function getPostGatingShape(post) { + const tiersRelation = post.related && post.related('tiers'); + const tiers = tiersRelation && typeof tiersRelation.toJSON === 'function' + ? tiersRelation.toJSON() + : undefined; + return { + visibility: post.get('visibility'), + tiers + }; +} + +/** + * The NQL member filter selecting members WITHOUT access to a tier-restricted + * post: members who hold none of the post's tiers. This is the exact complement + * of the post's tier access filter (De Morgan over the OR of tiers). Free + * members (no products) match this naturally. + * @param {Post} post + * @returns {string|null} + */ +function getNegatedTierFilter(post) { + const tiers = getPostGatingShape(post).tiers || []; + if (tiers.length === 0) { + return null; + } + return tiers.map(tier => `product:-'${tier.slug}'`).join('+'); +} + +/** + * Returns 'status:free' / 'status:-free' identifying which free/paid audience a + * segment renders for. Used ONLY for data-gh-segment card stripping, which is a + * free/paid-only axis independent of tier access. Returns null for segments that + * target a mixed/everyone audience (where no free/paid cards are present anyway). + * @param {Segment} segment + * @returns {string|null} + */ +function getSegmentStatus(segment) { + if (!segment) { + return null; + } + if (segment.includes('status:-free')) { + return 'status:-free'; + } + if (segment.includes('status:free')) { + return 'status:free'; + } + return null; +} + +/** + * Whether the members matched by a segment can read the post's gated content. + * Derived from the post's visibility (not just free/paid): for paid posts the + * access segment is the paid one; for tier-restricted posts it's the segment + * carrying the post's positive tier filter. + * @param {Post} post + * @param {Segment} segment + * @returns {boolean} + */ +function segmentHasPostAccess(post, segment) { + const visibility = post.get('visibility'); + if (visibility !== 'paid' && visibility !== 'tiers') { + return true; + } + const accessFilter = getPostAccessFilter(getPostGatingShape(post)); + if (!accessFilter) { + // misconfigured tiers post (no tiers) -> nobody has access + return false; + } + return !!segment && segment.includes(accessFilter); +} + /** * @param {Readonly} date * @param {string} timezone @@ -343,26 +420,42 @@ class EmailRenderer { const allowedSegments = ['status:free', 'status:-free']; const html = await this.renderPostBaseHtml(post); - /** - * Always add free and paid segments if email has paywall card - */ - if (html.indexOf('') !== -1) { - // We have different content between free and paid members - return allowedSegments; - } + const hasPaywall = html.indexOf('') !== -1; const $ = cheerioLoad(html); + const cardSegments = [...new Set( + $('[data-gh-segment]').get().map(el => el.attribs['data-gh-segment']) + )].filter(segment => allowedSegments.includes(segment)); + const hasCards = cardSegments.length > 0; - let allSegments = $('[data-gh-segment]') - .get() - .map(el => el.attribs['data-gh-segment']); - - const segments = [...new Set(allSegments)].filter(segment => allowedSegments.includes(segment)); - if (segments.length === 0) { - // No difference in email content between free and paid + if (!hasPaywall && !hasCards) { + // No difference in email content between members return [null]; } + // Tier-restricted posts split recipients by tier access (not just + // free/paid) so members on a tier that can't read this post get the + // public preview + paywall, exactly as they do on the web. + if (post.get('visibility') === 'tiers' && hasPaywall) { + const accessFilter = getPostAccessFilter(getPostGatingShape(post)); + const noAccessFilter = getNegatedTierFilter(post); + + if (accessFilter && noAccessFilter) { + if (hasCards) { + // free/paid cards in the preview need free vs paid rendering + // within the no-access audience -> three render variants + return [ + 'status:free', + `status:-free+(${accessFilter})`, + `status:-free+(${noAccessFilter})` + ]; + } + // free members hold no products, so they fall into no-access + return [accessFilter, noAccessFilter]; + } + // misconfigured tiers post (no tiers) -> fall through to free/paid + } + // We have different content between free and paid members return allowedSegments; } @@ -406,14 +499,16 @@ class EmailRenderer { const hasMembersOnlyContent = membersOnlyIndex !== -1; let addPaywall = false; - if (isPaidPost && hasMembersOnlyContent) { - if (segment === 'status:free') { - // Add paywall - addPaywall = true; + // Members without access to the gated content (free members, or members + // on a tier that can't read this post) get the public preview + paywall, + // exactly as on the web. Access is derived from the post's visibility, + // not just free/paid. + if (isPaidPost && hasMembersOnlyContent && !segmentHasPostAccess(post, segment)) { + // Add paywall + addPaywall = true; - // Remove the members-only content - html = html.slice(0, membersOnlyIndex); - } + // Remove the members-only content + html = html.slice(0, membersOnlyIndex); } let $ = cheerioLoad(html); @@ -422,9 +517,13 @@ class EmailRenderer { // before rendering the template as the preheader for the email may be generated // using the HTML and we don't want to include content that should not be // visible depending on the segment + // data-gh-segment cards are a free/paid-only axis, independent of tier + // access. Match them against this segment's free/paid status rather than + // the raw segment filter (which may carry a tier expression). + const segmentStatus = getSegmentStatus(segment); $('[data-gh-segment]').get().forEach((node) => { // TODO: replace with NQL interpretation - if (node.attribs['data-gh-segment'] !== segment) { + if (node.attribs['data-gh-segment'] !== segmentStatus) { $(node).remove(); } else { // Getting rid of the attribute for a cleaner html output diff --git a/ghost/core/core/server/services/members/content-gating.js b/ghost/core/core/server/services/members/content-gating.js index de980951b43..9a0b6d91589 100644 --- a/ghost/core/core/server/services/members/content-gating.js +++ b/ghost/core/core/server/services/members/content-gating.js @@ -25,6 +25,37 @@ const rejectUnknownKeys = input => nql.utils.mapQuery(input, function (value, ke }; }); +/** + * Builds the NQL member filter describing which members have access to a post's + * gated content, based purely on its `visibility`. This is the single source of + * truth for "who can read this post" — shared between the web content gating + * (checkPostAccess) and the email sending pipeline (segmentation), so the two + * can never drift apart. + * + * Note: `public` and `members` are handled by the callers (they don't reduce to + * a member-status filter); this only produces the filter for the gated cases. + * + * @param {object} post - A post object + * @returns {string|null} An NQL member filter, or null when the post's tiers are + * misconfigured so that no member should have access. + */ +function getPostAccessFilter(post) { + if (post.visibility === 'paid') { + return 'status:-free'; + } + + if (post.visibility === 'tiers') { + if (!post.tiers) { + return null; + } + return post.tiers.map((product) => { + return `product:'${product.slug}'`; + }).join(',') || null; + } + + return post.visibility; +} + /** * @param {object} post - A post object to check access to * @param {object} member - The member whos access should be checked @@ -44,15 +75,7 @@ function checkPostAccess(post, member) { return PERMIT_ACCESS; } - let visibility = post.visibility === 'paid' ? 'status:-free' : post.visibility; - if (visibility === 'tiers') { - if (!post.tiers) { - return BLOCK_ACCESS; - } - visibility = post.tiers.map((product) => { - return `product:'${product.slug}'`; - }).join(','); - } + const visibility = getPostAccessFilter(post); if (visibility && member.status && nql(visibility, {expansions: MEMBER_NQL_EXPANSIONS, transformer: rejectUnknownKeys}).queryJSON(member)) { return PERMIT_ACCESS; @@ -88,6 +111,7 @@ function checkGatedBlockAccess(gatedBlockParams, member) { } module.exports = { + getPostAccessFilter, checkPostAccess, checkGatedBlockAccess, PERMIT_ACCESS, diff --git a/ghost/core/package.json b/ghost/core/package.json index 024c0e5ee56..9e4b5f859b5 100644 --- a/ghost/core/package.json +++ b/ghost/core/package.json @@ -124,7 +124,7 @@ "@tryghost/logging": "catalog:", "@tryghost/members-csv": "2.0.7", "@tryghost/metrics": "1.0.43", - "@tryghost/mongo-utils": "0.6.4", + "@tryghost/mongo-utils": "0.6.5", "@tryghost/mw-error-handler": "1.0.13", "@tryghost/mw-vhost": "1.0.6", "@tryghost/nodemailer": "2.2.4", @@ -302,7 +302,7 @@ "qs": "6.15.2", "sinon": "catalog:", "supertest": "catalog:", - "tmp": "0.2.6", + "tmp": "0.2.7", "tsx": "catalog:", "typescript": "catalog:", "validator": "catalog:", diff --git a/ghost/core/test/integration/services/email-service/tier-segmentation.test.js b/ghost/core/test/integration/services/email-service/tier-segmentation.test.js new file mode 100644 index 00000000000..ffaab77d76f --- /dev/null +++ b/ghost/core/test/integration/services/email-service/tier-segmentation.test.js @@ -0,0 +1,139 @@ +const {agentProvider, fixtureManager} = require('../../../utils/e2e-framework'); +const assert = require('node:assert/strict'); +const ObjectID = require('bson-objectid').default; + +/** + * Risk 1 coverage for tier-based email visibility. + * + * The email pipeline splits recipients of a tier-restricted post into two + * segments: members who hold one of the post's tiers (full content) and members + * who hold none of them (public preview + paywall). The no-access segment is the + * NQL complement of the access filter — a negation over the to-many `products` + * relation (`product:-'a'`). This test proves, against a real database and + * mongo-knex, that the two segments partition the recipients exactly: no member + * is dropped and no member appears in both — including a member holding multiple + * tiers. + * + * The segments are resolved through the same Member.getFilteredCollectionQuery + * path that batch-sending-service uses to select recipients, so this exercises + * the real query engine (not the in-memory NQL queryJSON the web path uses). + */ +describe('Email tier segmentation (recipient partition)', function () { + let models; + let goldProduct; + let silverProduct; + + // Unique label so we only ever query the members this test seeds, never + // any members created by shared fixtures. + const LABEL = 'tier-segmentation-test'; + + const members = {}; + + beforeAll(async function () { + await agentProvider.getAdminAPIAgent(); + await fixtureManager.init('newsletters'); + + // Reference models only after Ghost has booted + models = require('../../../../core/server/models'); + + goldProduct = await models.Product.add({ + name: 'Gold Tier (seg test)', + slug: 'gold-tier-seg', + type: 'paid', + active: true + }); + silverProduct = await models.Product.add({ + name: 'Silver Tier (seg test)', + slug: 'silver-tier-seg', + type: 'paid', + active: true + }); + + const seed = { + labels: [{name: LABEL}], + email_disabled: false + }; + + members.gold = await models.Member.add({ + ...seed, + email: `gold-${ObjectID().toHexString()}@example.com`, + status: 'paid', + products: [{id: goldProduct.id}] + }); + members.silver = await models.Member.add({ + ...seed, + email: `silver-${ObjectID().toHexString()}@example.com`, + status: 'paid', + products: [{id: silverProduct.id}] + }); + members.both = await models.Member.add({ + ...seed, + email: `both-${ObjectID().toHexString()}@example.com`, + status: 'paid', + products: [{id: goldProduct.id}, {id: silverProduct.id}] + }); + members.free = await models.Member.add({ + ...seed, + email: `free-${ObjectID().toHexString()}@example.com`, + status: 'free' + }); + }); + + afterAll(async function () { + for (const member of Object.values(members)) { + if (member) { + await models.Member.destroy({id: member.id}); + } + } + if (goldProduct) { + await models.Product.destroy({id: goldProduct.id}); + } + if (silverProduct) { + await models.Product.destroy({id: silverProduct.id}); + } + }); + + /** + * Resolve a segment's member filter to the set of seeded members it selects, + * mirroring how batch-sending-service selects recipients (the segment is + * AND-ed into the member query). Scoped to this test's label so shared + * fixtures never leak in. + */ + async function membersForSegment(segment) { + const rows = await models.Member + .getFilteredCollectionQuery({filter: `label:${LABEL}+(${segment})`}) + .select('members.email'); + return new Set(rows.map(row => row.email)); + } + + it('partitions recipients of a tier-restricted post into access and no-access segments', async function () { + const goldSlug = goldProduct.get('slug'); + + // The exact segment strings email-renderer's getSegments() emits for a + // post restricted to the Gold tier (pinned by the email-renderer unit tests). + const accessSegment = `product:'${goldSlug}'`; + const noAccessSegment = `product:-'${goldSlug}'`; + + const accessMembers = await membersForSegment(accessSegment); + const noAccessMembers = await membersForSegment(noAccessSegment); + + // Access = members holding the Gold tier (gold-only and the multi-tier member) + assert.deepEqual(accessMembers, new Set([ + members.gold.get('email'), + members.both.get('email') + ]), 'access segment should be exactly the members on the Gold tier'); + + // No-access = the Silver-only member and the free member + assert.deepEqual(noAccessMembers, new Set([ + members.silver.get('email'), + members.free.get('email') + ]), 'no-access segment should be exactly the members NOT on the Gold tier'); + + // The partition must be exact: no overlap, and the union covers everyone. + const overlap = [...accessMembers].filter(email => noAccessMembers.has(email)); + assert.deepEqual(overlap, [], 'no member may appear in both segments (no double-send)'); + + const union = new Set([...accessMembers, ...noAccessMembers]); + assert.equal(union.size, 4, 'every seeded recipient must land in exactly one segment (no drops)'); + }); +}); diff --git a/ghost/core/test/unit/api/canary/utils/validators/input/pages.test.js b/ghost/core/test/unit/api/canary/utils/validators/input/pages.test.js index 31033c43cd0..e87538c6920 100644 --- a/ghost/core/test/unit/api/canary/utils/validators/input/pages.test.js +++ b/ghost/core/test/unit/api/canary/utils/validators/input/pages.test.js @@ -2,11 +2,11 @@ const _ = require('lodash'); const assert = require('node:assert/strict'); const sinon = require('sinon'); const validators = require('../../../../../../../core/server/api/endpoints/utils/validators'); -const models = require('../../../../../../../core/server/models'); +const {Member} = require('../../../../../../../core/server/models/member'); describe('Unit: endpoints/utils/validators/input/pages', function () { beforeEach(function () { - const memberFindPageStub = sinon.stub(models.Member, 'findPage').rejects(); + const memberFindPageStub = sinon.stub(Member, 'findPage').rejects(); memberFindPageStub.withArgs({filter: 'label:vip', limit: 1}).resolves(); }); diff --git a/ghost/core/test/unit/api/canary/utils/validators/input/posts.test.js b/ghost/core/test/unit/api/canary/utils/validators/input/posts.test.js index 905ec91253c..0965bafbf42 100644 --- a/ghost/core/test/unit/api/canary/utils/validators/input/posts.test.js +++ b/ghost/core/test/unit/api/canary/utils/validators/input/posts.test.js @@ -2,11 +2,11 @@ const _ = require('lodash'); const assert = require('node:assert/strict'); const sinon = require('sinon'); const validators = require('../../../../../../../core/server/api/endpoints/utils/validators'); -const models = require('../../../../../../../core/server/models'); +const {Member} = require('../../../../../../../core/server/models/member'); describe('Unit: endpoints/utils/validators/input/posts', function () { beforeEach(function () { - const memberFindPageStub = sinon.stub(models.Member, 'findPage').rejects(); + const memberFindPageStub = sinon.stub(Member, 'findPage').rejects(); memberFindPageStub.withArgs({filter: 'label:vip', limit: 1}).resolves(); }); diff --git a/ghost/core/test/unit/api/endpoints/previews.test.js b/ghost/core/test/unit/api/endpoints/previews.test.js index 8e8a543f247..500cbf98c2c 100644 --- a/ghost/core/test/unit/api/endpoints/previews.test.js +++ b/ghost/core/test/unit/api/endpoints/previews.test.js @@ -1,12 +1,13 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); -const models = require('../../../../core/server/models'); +const {Post} = require('../../../../core/server/models/post'); +const {Product} = require('../../../../core/server/models/product'); const previewsController = require('../../../../core/server/api/endpoints/previews'); describe('Previews controller', function () { beforeEach(function () { - sinon.stub(models.Post, 'findOne').resolves({}); - sinon.stub(models.Product, 'findAll').resolves([{ + sinon.stub(Post, 'findOne').resolves({}); + sinon.stub(Product, 'findAll').resolves([{ get: sinon.stub().returns('silver') }]); }); 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 39ceaa8a27f..fab4384bc65 100644 --- a/ghost/core/test/unit/server/data/exporter/index.test.js +++ b/ghost/core/test/unit/server/data/exporter/index.test.js @@ -5,7 +5,7 @@ const errors = require('@tryghost/errors'); const db = require('../../../../../core/server/data/db'); const exporter = require('../../../../../core/server/data/exporter'); const schema = require('../../../../../core/server/data/schema'); -const models = require('../../../../../core/server/models'); +const {Settings} = require('../../../../../core/server/models/settings'); const logging = require('@tryghost/logging'); const schemaTables = Object.keys(schema.tables); @@ -133,7 +133,7 @@ describe('Exporter', function () { describe('exportFileName', function () { it('should return a correctly structured filename', async function () { - const settingsStub = sinon.stub(models.Settings, 'findOne').returns( + const settingsStub = sinon.stub(Settings, 'findOne').returns( Promise.resolve({ get: function () { return 'testblog'; @@ -148,7 +148,7 @@ describe('Exporter', function () { }); it('should return a correctly structured filename if settings is empty', async function () { - const settingsStub = sinon.stub(models.Settings, 'findOne').returns( + const settingsStub = sinon.stub(Settings, 'findOne').returns( Promise.resolve() ); @@ -159,7 +159,7 @@ describe('Exporter', function () { }); it('should return a correctly structured filename if settings errors', async function () { - const settingsStub = sinon.stub(models.Settings, 'findOne').returns( + const settingsStub = sinon.stub(Settings, 'findOne').returns( Promise.reject() ); const loggingStub = sinon.stub(logging, 'error'); diff --git a/ghost/core/test/unit/server/data/importer/index.test.js b/ghost/core/test/unit/server/data/importer/index.test.js index a0595a6f372..11b9572ff25 100644 --- a/ghost/core/test/unit/server/data/importer/index.test.js +++ b/ghost/core/test/unit/server/data/importer/index.test.js @@ -28,7 +28,7 @@ const StripePricesImporter = require('../../../../../core/server/data/importer/i const PostsImporter = require('../../../../../core/server/data/importer/importers/data/posts-importer'); const CustomThemeSettingsImporter = require('../../../../../core/server/data/importer/importers/data/custom-theme-settings-importer'); const RevueSubscriberImporter = require('../../../../../core/server/data/importer/importers/data/revue-subscriber-importer'); -const models = require('../../../../../core/server/models'); +const Base = require('../../../../../core/server/models/base'); const configUtils = require('../../../../utils/config-utils'); const logging = require('@tryghost/logging'); @@ -724,7 +724,7 @@ describe('Importer', function () { this.errors.push(importError); }); - sinon.stub(models.Base, 'transaction').callsFake(fn => fn({fake: 'transacting'})); + sinon.stub(Base, 'transaction').callsFake(fn => fn({fake: 'transacting'})); await assert.rejects(DataImporter.doImport({meta: {version: '4.0.0'}, data: {}}, {}), (err) => { assert(err instanceof Error); diff --git a/ghost/core/test/unit/server/models/api-key.test.js b/ghost/core/test/unit/server/models/api-key.test.js index 11a260267f4..6eece8ab241 100644 --- a/ghost/core/test/unit/server/models/api-key.test.js +++ b/ghost/core/test/unit/server/models/api-key.test.js @@ -1,11 +1,11 @@ const assert = require('node:assert/strict'); -const models = require('../../../../core/server/models'); +const {ApiKey} = require('../../../../core/server/models/api-key'); const sinon = require('sinon'); describe('Unit: models/api_key', function () { describe('fn: refreshSecret', function () { it('returns a call to edit passing a new admin secret', function () { - const editStub = sinon.stub(models.ApiKey, 'edit').resolves(); + const editStub = sinon.stub(ApiKey, 'edit').resolves(); const fakeData = { id: 'TREVOR', @@ -13,7 +13,7 @@ describe('Unit: models/api_key', function () { }; const fakeOptions = {}; - const result = models.ApiKey.refreshSecret(fakeData, fakeOptions); + const result = ApiKey.refreshSecret(fakeData, fakeOptions); assert.equal(result, editStub.returnValues[0]); assert.equal(editStub.args[0][0].id, 'TREVOR'); @@ -24,7 +24,7 @@ describe('Unit: models/api_key', function () { }); it('returns a call to edit passing a new content secret', function () { - const editStub = sinon.stub(models.ApiKey, 'edit').resolves(); + const editStub = sinon.stub(ApiKey, 'edit').resolves(); const fakeData = { id: 'TREVOR', @@ -32,7 +32,7 @@ describe('Unit: models/api_key', function () { }; const fakeOptions = {}; - const result = models.ApiKey.refreshSecret(fakeData, fakeOptions); + const result = ApiKey.refreshSecret(fakeData, fakeOptions); assert.equal(result, editStub.returnValues[0]); assert.equal(editStub.args[0][0].id, 'TREVOR'); diff --git a/ghost/core/test/unit/server/models/automation.test.js b/ghost/core/test/unit/server/models/automation.test.js index cf12efc2d18..48720b2ceda 100644 --- a/ghost/core/test/unit/server/models/automation.test.js +++ b/ghost/core/test/unit/server/models/automation.test.js @@ -1,6 +1,6 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); -const models = require('../../../../core/server/models'); +const {Automation} = require('../../../../core/server/models/automation'); const logging = require('@tryghost/logging'); describe('Unit: models/automation', function () { @@ -10,14 +10,14 @@ describe('Unit: models/automation', function () { describe('defaults', function () { it('sets default status to inactive', function () { - const model = new models.Automation(); + const model = new Automation(); const defaults = model.defaults(); assert.equal(defaults.status, 'inactive'); }); it('returns expected default values', function () { - const model = new models.Automation(); + const model = new Automation(); const defaults = model.defaults(); assert.ok(defaults); @@ -29,7 +29,7 @@ describe('Unit: models/automation', function () { describe('onSaved', function () { it('logs when a welcome email is enabled', function () { const infoStub = sinon.stub(logging, 'info'); - const model = models.Automation.forge({ + const model = Automation.forge({ id: 'test-id', slug: 'member-welcome-email-free', status: 'active' @@ -46,7 +46,7 @@ describe('Unit: models/automation', function () { it('logs when a welcome email is disabled', function () { const infoStub = sinon.stub(logging, 'info'); - const model = models.Automation.forge({ + const model = Automation.forge({ id: 'test-id', slug: 'member-welcome-email-paid', status: 'inactive' @@ -63,7 +63,7 @@ describe('Unit: models/automation', function () { it('does not log for non-welcome-email slugs', function () { const infoStub = sinon.stub(logging, 'info'); - const model = models.Automation.forge({ + const model = Automation.forge({ id: 'test-id', slug: 'some-other-slug', status: 'active' @@ -77,7 +77,7 @@ describe('Unit: models/automation', function () { it('does not log when status has not changed', function () { const infoStub = sinon.stub(logging, 'info'); - const model = models.Automation.forge({ + const model = Automation.forge({ id: 'test-id', slug: 'member-welcome-email-free', status: 'active' diff --git a/ghost/core/test/unit/server/models/base/actions.test.js b/ghost/core/test/unit/server/models/base/actions.test.js index 68077f3f7aa..e0659f3af3d 100644 --- a/ghost/core/test/unit/server/models/base/actions.test.js +++ b/ghost/core/test/unit/server/models/base/actions.test.js @@ -1,13 +1,13 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); -const models = require('../../../../../core/server/models'); +const Base = require('../../../../../core/server/models/base'); describe('Unit: models/base/plugins/actions', function () { let TestModel; beforeEach(function () { // Create a test model that has actions enabled - TestModel = models.Base.Model.extend({ + TestModel = Base.Model.extend({ tableName: 'test_models', actionsCollectCRUD: true, actionsResourceType: 'test_resource', @@ -22,7 +22,7 @@ describe('Unit: models/base/plugins/actions', function () { describe('getAction', function () { describe('Missing configuration', function () { it('should return undefined when actionsCollectCRUD is false', function () { - const UnconfiguredModel = models.Base.Model.extend({ + const UnconfiguredModel = Base.Model.extend({ tableName: 'test', actionsCollectCRUD: false }); @@ -36,7 +36,7 @@ describe('Unit: models/base/plugins/actions', function () { assert.equal(action, undefined); }); it('should return undefined when actionsResourceType is not set', function () { - const UnconfiguredModel = models.Base.Model.extend({ + const UnconfiguredModel = Base.Model.extend({ tableName: 'test', actionsCollectCRUD: true, actionsResourceType: null @@ -260,7 +260,7 @@ describe('Unit: models/base/plugins/actions', function () { describe('Primary name extraction', function () { it('should handle models with title field', function () { - const TitleModel = models.Base.Model.extend({ + const TitleModel = Base.Model.extend({ tableName: 'title_models', actionsCollectCRUD: true, actionsResourceType: 'post' @@ -279,7 +279,7 @@ describe('Unit: models/base/plugins/actions', function () { }); it('should handle models with name field', function () { - const NameModel = models.Base.Model.extend({ + const NameModel = Base.Model.extend({ tableName: 'name_models', actionsCollectCRUD: true, actionsResourceType: 'tag' @@ -298,7 +298,7 @@ describe('Unit: models/base/plugins/actions', function () { }); it('should fallback through title -> name -> email for primary name', function () { - const FallbackModel = models.Base.Model.extend({ + const FallbackModel = Base.Model.extend({ tableName: 'fallback_models', actionsCollectCRUD: true, actionsResourceType: 'resource' diff --git a/ghost/core/test/unit/server/models/base/crud.test.js b/ghost/core/test/unit/server/models/base/crud.test.js index 81e1bdb10c7..21b50e970ef 100644 --- a/ghost/core/test/unit/server/models/base/crud.test.js +++ b/ghost/core/test/unit/server/models/base/crud.test.js @@ -1,7 +1,7 @@ const assert = require('node:assert/strict'); const errors = require('@tryghost/errors'); const sinon = require('sinon'); -const models = require('../../../../../core/server/models'); +const Base = require('../../../../../core/server/models/base'); describe('Models: crud', function () { afterEach(function () { @@ -15,15 +15,15 @@ describe('Models: crud', function () { prop: 'whatever' } }; - const model = models.Base.Model.forge({}); - const filterOptionsSpy = sinon.spy(models.Base.Model, 'filterOptions'); - const forgeStub = sinon.stub(models.Base.Model, 'forge') + const model = Base.Model.forge({}); + const filterOptionsSpy = sinon.spy(Base.Model, 'filterOptions'); + const forgeStub = sinon.stub(Base.Model, 'forge') .returns(model); const fetchStub = sinon.stub(model, 'fetch') .resolves(model); const destroyStub = sinon.stub(model, 'destroy'); - return models.Base.Model.destroy(unfilteredOptions).then(() => { + return Base.Model.destroy(unfilteredOptions).then(() => { assert.equal(filterOptionsSpy.args[0][0], unfilteredOptions); assert.equal(filterOptionsSpy.args[0][1], 'destroy'); @@ -42,15 +42,15 @@ describe('Models: crud', function () { const unfilteredOptions = { id: 23 }; - const model = models.Base.Model.forge({}); - const filterOptionsSpy = sinon.spy(models.Base.Model, 'filterOptions'); - const forgeStub = sinon.stub(models.Base.Model, 'forge') + const model = Base.Model.forge({}); + const filterOptionsSpy = sinon.spy(Base.Model, 'filterOptions'); + const forgeStub = sinon.stub(Base.Model, 'forge') .returns(model); const fetchStub = sinon.stub(model, 'fetch') .resolves(model); const destroyStub = sinon.stub(model, 'destroy'); - return models.Base.Model.destroy(unfilteredOptions).then(() => { + return Base.Model.destroy(unfilteredOptions).then(() => { assert.equal(filterOptionsSpy.args[0][0], unfilteredOptions); assert.equal(filterOptionsSpy.args[0][1], 'destroy'); @@ -74,16 +74,16 @@ describe('Models: crud', function () { const unfilteredOptions = { donny: 'donson' }; - const model = models.Base.Model.forge({}); - const fetchedModel = models.Base.Model.forge({}); - const filterOptionsSpy = sinon.spy(models.Base.Model, 'filterOptions'); - const filterDataSpy = sinon.spy(models.Base.Model, 'filterData'); - const forgeStub = sinon.stub(models.Base.Model, 'forge') + const model = Base.Model.forge({}); + const fetchedModel = Base.Model.forge({}); + const filterOptionsSpy = sinon.spy(Base.Model, 'filterOptions'); + const filterDataSpy = sinon.spy(Base.Model, 'filterData'); + const forgeStub = sinon.stub(Base.Model, 'forge') .returns(model); const fetchStub = sinon.stub(model, 'fetch') .resolves(fetchedModel); - const findOneReturnValue = models.Base.Model.findOne(data, unfilteredOptions); + const findOneReturnValue = Base.Model.findOne(data, unfilteredOptions); return findOneReturnValue.then((result) => { assert.equal(result, fetchedModel); @@ -110,16 +110,16 @@ describe('Models: crud', function () { forUpdate: true, transacting: {} }; - const model = models.Base.Model.forge({}); - const fetchedModel = models.Base.Model.forge({}); - sinon.spy(models.Base.Model, 'filterOptions'); - sinon.spy(models.Base.Model, 'filterData'); - sinon.stub(models.Base.Model, 'forge') + const model = Base.Model.forge({}); + const fetchedModel = Base.Model.forge({}); + sinon.spy(Base.Model, 'filterOptions'); + sinon.spy(Base.Model, 'filterData'); + sinon.stub(Base.Model, 'forge') .returns(model); const fetchStub = sinon.stub(model, 'fetch') .resolves(fetchedModel); - await models.Base.Model.findOne(data, unfilteredOptions); + await Base.Model.findOne(data, unfilteredOptions); assert.equal(fetchStub.args[0][0].lock, 'forUpdate'); }); @@ -133,18 +133,18 @@ describe('Models: crud', function () { const unfilteredOptions = { id: 'something real special' }; - const model = models.Base.Model.forge({}); - const savedModel = models.Base.Model.forge({}); - const filterOptionsSpy = sinon.spy(models.Base.Model, 'filterOptions'); - const filterDataSpy = sinon.spy(models.Base.Model, 'filterData'); - const forgeStub = sinon.stub(models.Base.Model, 'forge') + const model = Base.Model.forge({}); + const savedModel = Base.Model.forge({}); + const filterOptionsSpy = sinon.spy(Base.Model, 'filterOptions'); + const filterDataSpy = sinon.spy(Base.Model, 'filterData'); + const forgeStub = sinon.stub(Base.Model, 'forge') .returns(model); const fetchStub = sinon.stub(model, 'fetch') .resolves(model); const saveStub = sinon.stub(model, 'save') .resolves(savedModel); - return models.Base.Model.edit(data, unfilteredOptions).then((result) => { + return Base.Model.edit(data, unfilteredOptions).then((result) => { assert.equal(result, savedModel); assert.equal(filterOptionsSpy.args[0][0], unfilteredOptions); @@ -173,13 +173,13 @@ describe('Models: crud', function () { transacting: {} }; - const model = models.Base.Model.forge({}); - sinon.stub(models.Base.Model, 'forge') + const model = Base.Model.forge({}); + sinon.stub(Base.Model, 'forge') .returns(model); const fetchStub = sinon.stub(model, 'fetch') .resolves(); - return models.Base.Model.findOne(data, unfilteredOptions).then(() => { + return Base.Model.findOne(data, unfilteredOptions).then(() => { assert.equal(fetchStub.args[0][0].lock, undefined); }); }); @@ -191,11 +191,11 @@ describe('Models: crud', function () { const unfilteredOptions = { importing: true }; - const model = models.Base.Model.forge({}); - sinon.stub(models.Base.Model, 'forge').returns(model); + const model = Base.Model.forge({}); + sinon.stub(Base.Model, 'forge').returns(model); sinon.stub(model, 'fetch').resolves(); - return models.Base.Model.findOne(data, unfilteredOptions).then(() => { + return Base.Model.findOne(data, unfilteredOptions).then(() => { assert.equal(model.hasTimestamps, true); }); }); @@ -207,14 +207,14 @@ describe('Models: crud', function () { const unfilteredOptions = { id: 'something real special' }; - const model = models.Base.Model.forge({}); - sinon.spy(models.Base.Model, 'filterOptions'); - sinon.spy(models.Base.Model, 'filterData'); - sinon.stub(models.Base.Model, 'forge').returns(model); + const model = Base.Model.forge({}); + sinon.spy(Base.Model, 'filterOptions'); + sinon.spy(Base.Model, 'filterData'); + sinon.stub(Base.Model, 'forge').returns(model); sinon.stub(model, 'fetch').resolves(); sinon.stub(model, 'save'); - return models.Base.Model.edit(data, unfilteredOptions).then(() => { + return Base.Model.edit(data, unfilteredOptions).then(() => { throw new Error('That should not happen'); }).catch((err) => { assert.equal((err instanceof errors.NotFoundError), true); @@ -228,16 +228,16 @@ describe('Models: crud', function () { rum: 'ham' }; const unfilteredOptions = {}; - const model = models.Base.Model.forge({}); - const savedModel = models.Base.Model.forge({}); - const filterOptionsSpy = sinon.spy(models.Base.Model, 'filterOptions'); - const filterDataSpy = sinon.spy(models.Base.Model, 'filterData'); - const forgeStub = sinon.stub(models.Base.Model, 'forge') + const model = Base.Model.forge({}); + const savedModel = Base.Model.forge({}); + const filterOptionsSpy = sinon.spy(Base.Model, 'filterOptions'); + const filterDataSpy = sinon.spy(Base.Model, 'filterData'); + const forgeStub = sinon.stub(Base.Model, 'forge') .returns(model); const saveStub = sinon.stub(model, 'save') .resolves(savedModel); - return models.Base.Model.add(data, unfilteredOptions).then((result) => { + return Base.Model.add(data, unfilteredOptions).then((result) => { assert.equal(result, savedModel); assert.equal(filterOptionsSpy.args[0][0], unfilteredOptions); @@ -262,11 +262,11 @@ describe('Models: crud', function () { const unfilteredOptions = { importing: true }; - const model = models.Base.Model.forge({}); - sinon.stub(models.Base.Model, 'forge').returns(model); + const model = Base.Model.forge({}); + sinon.stub(Base.Model, 'forge').returns(model); sinon.stub(model, 'save').resolves(); - return models.Base.Model.add(data, unfilteredOptions).then(() => { + return Base.Model.add(data, unfilteredOptions).then(() => { assert.equal(model.hasTimestamps, false); }); }); diff --git a/ghost/core/test/unit/server/models/base/index.test.js b/ghost/core/test/unit/server/models/base/index.test.js index 3a15cbce610..631ce85b1d7 100644 --- a/ghost/core/test/unit/server/models/base/index.test.js +++ b/ghost/core/test/unit/server/models/base/index.test.js @@ -1,7 +1,7 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); const security = require('@tryghost/security'); -const models = require('../../../../../core/server/models'); +const Base = require('../../../../../core/server/models/base'); const urlUtils = require('../../../../../core/shared/url-utils'); const testUtils = require('../../../../utils'); @@ -29,7 +29,7 @@ describe('Models: base', function () { Model.findOne.resolves(false); securityStringSafeStub.withArgs('My-Slug').returns('my-slug'); - return models.Base.Model.generateSlug(Model, 'My-Slug', options) + return Base.Model.generateSlug(Model, 'My-Slug', options) .then((slug) => { assert.equal(slug, 'my-slug'); }); @@ -47,7 +47,7 @@ describe('Models: base', function () { securityStringSafeStub.withArgs('My-Slug').returns('my-slug'); - return models.Base.Model.generateSlug(Model, 'My-Slug', {modelId: 'incorrect-model-id'}) + return Base.Model.generateSlug(Model, 'My-Slug', {modelId: 'incorrect-model-id'}) .then((slug) => { assert.equal(slug, 'my-slug-2'); }); @@ -65,7 +65,7 @@ describe('Models: base', function () { securityStringSafeStub.withArgs('My-Slug').returns('my-slug'); - return models.Base.Model.generateSlug(Model, 'My-Slug', {modelId: 'correct-model-id'}) + return Base.Model.generateSlug(Model, 'My-Slug', {modelId: 'correct-model-id'}) .then((slug) => { assert.equal(slug, 'my-slug'); }); @@ -83,7 +83,7 @@ describe('Models: base', function () { securityStringSafeStub.withArgs('My-Slug').returns('my-slug'); - return models.Base.Model.generateSlug(Model, 'My-Slug', options) + return Base.Model.generateSlug(Model, 'My-Slug', options) .then((slug) => { assert.equal(slug, 'my-slug-2'); }); @@ -95,7 +95,7 @@ describe('Models: base', function () { securityStringSafeStub.withArgs(slug).returns(slug); - return models.Base.Model.generateSlug(Model, slug, options) + return Base.Model.generateSlug(Model, slug, options) .then((generatedSlug) => { assert.equal(generatedSlug, 'a'.repeat(185)); }); @@ -107,7 +107,7 @@ describe('Models: base', function () { securityStringSafeStub.withArgs(slug).returns(slug); - return models.Base.Model.generateSlug(Model, slug, options) + return Base.Model.generateSlug(Model, slug, options) .then((generatedSlug) => { assert.equal(generatedSlug, 'upsi-tableName'); }); @@ -123,7 +123,7 @@ describe('Models: base', function () { securityStringSafeStub.withArgs(slug).returns(slug); - return models.Base.Model.generateSlug(Model, slug, options) + return Base.Model.generateSlug(Model, slug, options) .then((generatedSlug) => { assert.equal(generatedSlug, 'hash-#lul'); }); @@ -133,7 +133,7 @@ describe('Models: base', function () { Model.findOne.resolves(false); securityStringSafeStub.withArgs('abc\u0008').returns('abc'); - return models.Base.Model.generateSlug(Model, 'abc\u0008', options) + return Base.Model.generateSlug(Model, 'abc\u0008', options) .then((slug) => { assert.equal(slug, 'abc'); }); @@ -145,7 +145,7 @@ describe('Models: base', function () { const data = testUtils.DataGenerator.forKnex.createPost({updated_at: '0000-00-00 00:00:00'}); try { - models.Base.Model.sanitizeData + Base.Model.sanitizeData .bind({prototype: {tableName: 'posts'}})(data); } catch (err) { assert.equal(err.code, 'DATE_INVALID'); @@ -157,7 +157,7 @@ describe('Models: base', function () { assert.equal(typeof data.updated_at, 'string'); - models.Base.Model.sanitizeData + Base.Model.sanitizeData .bind({prototype: {tableName: 'posts'}})(data); assert(data.updated_at instanceof Date); @@ -168,7 +168,7 @@ describe('Models: base', function () { assert(data.updated_at instanceof Date); - models.Base.Model.sanitizeData + Base.Model.sanitizeData .bind({prototype: {tableName: 'posts'}})(data); assert(data.updated_at instanceof Date); @@ -184,7 +184,7 @@ describe('Models: base', function () { assert.equal(typeof data.authors[0].updated_at, 'string'); - models.Base.Model.sanitizeData + Base.Model.sanitizeData .bind({ prototype: { tableName: 'posts', @@ -200,7 +200,7 @@ describe('Models: base', function () { describe('setEmptyValuesToNull', function () { it('resets given empty value to null', function () { - const base = models.Base.Model.forge({a: '', b: ''}); + const base = Base.Model.forge({a: '', b: ''}); base.getNullableStringProperties = sinon.stub(); base.getNullableStringProperties.returns(['a']); diff --git a/ghost/core/test/unit/server/models/base/relations.test.js b/ghost/core/test/unit/server/models/base/relations.test.js index a06e80a8177..4e1dcae2d28 100644 --- a/ghost/core/test/unit/server/models/base/relations.test.js +++ b/ghost/core/test/unit/server/models/base/relations.test.js @@ -1,5 +1,5 @@ const sinon = require('sinon'); -const models = require('../../../../../core/server/models'); +const Base = require('../../../../../core/server/models/base'); const assert = require('node:assert/strict'); describe('Models: getLazyRelation', function () { @@ -8,18 +8,18 @@ describe('Models: getLazyRelation', function () { }); it('can fetch collections', async function () { - var OtherModel = models.Base.Model.extend({ + var OtherModel = Base.Model.extend({ tableName: 'other_models' }); - const TestModel = models.Base.Model.extend({ + const TestModel = Base.Model.extend({ tableName: 'test_models', tiers() { return this.belongsToMany(OtherModel, 'test_others', 'test_id', 'other_id'); } }); let rel = null; - const fetchStub = sinon.stub(models.Base.Collection.prototype, 'fetch').callsFake(function () { + const fetchStub = sinon.stub(Base.Collection.prototype, 'fetch').callsFake(function () { if (rel !== null) { throw new Error('Called twice'); } @@ -42,11 +42,11 @@ describe('Models: getLazyRelation', function () { }); it('can fetch models', async function () { - var OtherModel = models.Base.Model.extend({ + var OtherModel = Base.Model.extend({ tableName: 'other_models' }); - const TestModel = models.Base.Model.extend({ + const TestModel = Base.Model.extend({ tableName: 'test_models', other() { return this.belongsTo(OtherModel, 'other_id', 'id'); @@ -77,11 +77,11 @@ describe('Models: getLazyRelation', function () { }); it('can handle fetch of model without id for optional relations', async function () { - var OtherModel = models.Base.Model.extend({ + var OtherModel = Base.Model.extend({ tableName: 'other_models' }); - const TestModel = models.Base.Model.extend({ + const TestModel = Base.Model.extend({ tableName: 'test_models', other() { return this.belongsTo(OtherModel, 'other_id', 'id'); @@ -101,11 +101,11 @@ describe('Models: getLazyRelation', function () { }); it('throws for model without id for optional relations with require', async function () { - var OtherModel = models.Base.Model.extend({ + var OtherModel = Base.Model.extend({ tableName: 'other_models' }); - const TestModel = models.Base.Model.extend({ + const TestModel = Base.Model.extend({ tableName: 'test_models', other() { return this.belongsTo(OtherModel, 'other_id', 'id'); @@ -125,7 +125,7 @@ describe('Models: getLazyRelation', function () { }); it('returns undefined for nonexistent relations', async function () { - const TestModel = models.Base.Model.extend({ + const TestModel = Base.Model.extend({ tableName: 'test_models' }); const modelA = TestModel.forge({id: '1'}); @@ -133,7 +133,7 @@ describe('Models: getLazyRelation', function () { }); it('throws for nonexistent relations with require', async function () { - const TestModel = models.Base.Model.extend({ + const TestModel = Base.Model.extend({ tableName: 'test_models' }); const modelA = TestModel.forge({id: '1'}); diff --git a/ghost/core/test/unit/server/models/comment.test.js b/ghost/core/test/unit/server/models/comment.test.js index 4f8f73684d2..d8e4ba2ead3 100644 --- a/ghost/core/test/unit/server/models/comment.test.js +++ b/ghost/core/test/unit/server/models/comment.test.js @@ -1,12 +1,12 @@ const assert = require('node:assert/strict'); -const models = require('../../../../core/server/models'); +const {Comment} = require('../../../../core/server/models/comment'); describe('Unit: models/comment', function () { describe('defaultRelations', function () { it('includes member dislike state but not public dislike counts by default', function () { const options = {}; - models.Comment.defaultRelations('findPage', options); + Comment.defaultRelations('findPage', options); assert.ok(options.withRelated.includes('count.likes')); assert.ok(!options.withRelated.includes('count.dislikes')); @@ -19,7 +19,7 @@ describe('Unit: models/comment', function () { isAdmin: true }; - models.Comment.defaultRelations('findPage', options); + Comment.defaultRelations('findPage', options); assert.ok(options.withRelated.includes('count.likes')); assert.ok(options.withRelated.includes('count.dislikes')); @@ -32,7 +32,7 @@ describe('Unit: models/comment', function () { id: 'root-comment-id' }; - models.Comment.defaultRelations('findOne', options); + Comment.defaultRelations('findOne', options); const repliesRelation = options.withRelated.find((relation) => { return typeof relation === 'object' && relation.replies; @@ -53,7 +53,7 @@ describe('Unit: models/comment', function () { describe('orderAttributes', function () { it('does not expose dislike count ordering directly', function () { - const attributes = models.Comment.forge().orderAttributes(); + const attributes = Comment.forge().orderAttributes(); assert.ok(attributes.includes('count__likes')); assert.ok(attributes.includes('count__net_score')); diff --git a/ghost/core/test/unit/server/models/custom-theme-setting.test.js b/ghost/core/test/unit/server/models/custom-theme-setting.test.js index 64b513a2cf6..f109437755b 100644 --- a/ghost/core/test/unit/server/models/custom-theme-setting.test.js +++ b/ghost/core/test/unit/server/models/custom-theme-setting.test.js @@ -1,11 +1,11 @@ const assert = require('node:assert/strict'); -const models = require('../../../../core/server/models'); +const {CustomThemeSetting} = require('../../../../core/server/models/custom-theme-setting'); const config = require('../../../../core/shared/config'); describe('Unit: models/custom-theme-setting', function () { describe('parse', function () { it('ensure correct parsing when fetching from db', function () { - const setting = models.CustomThemeSetting.forge(); + const setting = CustomThemeSetting.forge(); let returns = setting.parse({theme: 'test', key: 'dark_mode', value: 'false', type: 'boolean'}); assert.equal(returns.value, false); @@ -35,7 +35,7 @@ describe('Unit: models/custom-theme-setting', function () { describe('format', function () { it('ensure correct formatting when setting', function () { - const setting = models.CustomThemeSetting.forge(); + const setting = CustomThemeSetting.forge(); let returns = setting.format({theme: 'test', key: 'dark_mode', value: '0', type: 'boolean'}); assert.equal(returns.value, 'false'); @@ -51,7 +51,7 @@ describe('Unit: models/custom-theme-setting', function () { }); it('transforms urls when persisting to db', function () { - const setting = models.CustomThemeSetting.forge(); + const setting = CustomThemeSetting.forge(); let returns = setting.formatOnWrite({theme: 'test', key: 'something', value: '/assets/image.jpg', type: 'image'}); assert.equal(returns.value, '__GHOST_URL__/assets/image.jpg'); diff --git a/ghost/core/test/unit/server/models/integration.test.js b/ghost/core/test/unit/server/models/integration.test.js index 36d755236a3..88fccb35dbc 100644 --- a/ghost/core/test/unit/server/models/integration.test.js +++ b/ghost/core/test/unit/server/models/integration.test.js @@ -1,6 +1,7 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); -const models = require('../../../../core/server/models'); +const {Integration} = require('../../../../core/server/models/integration'); +const Base = require('../../../../core/server/models/base'); const {knex} = require('../../../../core/server/data/db'); describe('Unit: models/integration', function () { @@ -13,17 +14,17 @@ describe('Unit: models/integration', function () { beforeEach(function () { basePermittedOptionsReturnVal = ['super', 'doopa']; - sinon.stub(models.Base.Model, 'permittedOptions') + sinon.stub(Base.Model, 'permittedOptions') .returns(basePermittedOptionsReturnVal); }); it('returns the base permittedOptions result', function () { - const returnedOptions = models.Integration.permittedOptions(); + const returnedOptions = Integration.permittedOptions(); assert.deepEqual(returnedOptions, basePermittedOptionsReturnVal); }); it('returns the base permittedOptions result plus "filter" when methodName is findOne', function () { - const returnedOptions = models.Integration.permittedOptions('findOne'); + const returnedOptions = Integration.permittedOptions('findOne'); assert.deepEqual(returnedOptions, basePermittedOptionsReturnVal.concat('filter')); }); }); @@ -50,7 +51,7 @@ describe('Unit: models/integration', function () { query.response([]); }); - return models.Integration.findOne({ + return Integration.findOne({ id: '123' }, { filter: 'type:[custom,builtin,core]' @@ -83,7 +84,7 @@ describe('Unit: models/integration', function () { query.response([{id: 'key-admin', secret: 'admin-secret'}]); }); - const apiKey = await models.Integration.getApiKeyBySlug('ghost-scheduler', 'admin'); + const apiKey = await Integration.getApiKeyBySlug('ghost-scheduler', 'admin'); assert.deepEqual(apiKey, {id: 'key-admin', secret: 'admin-secret'}); assert.equal(queries.length, 1); assert.equal(queries[0].sql, 'select `api_keys`.`id`, `api_keys`.`secret` from `api_keys` inner join `integrations` on `api_keys`.`integration_id` = `integrations`.`id` where `integrations`.`slug` = ? and `api_keys`.`type` = ? limit ?'); @@ -98,7 +99,7 @@ describe('Unit: models/integration', function () { const errors = require('@tryghost/errors'); await assert.rejects( - models.Integration.getApiKeyBySlug('ghost-scheduler', 'admin'), + Integration.getApiKeyBySlug('ghost-scheduler', 'admin'), err => err instanceof errors.NotFoundError ); }); diff --git a/ghost/core/test/unit/server/models/invite.test.js b/ghost/core/test/unit/server/models/invite.test.js index 64170f36cb1..4617a5c9d28 100644 --- a/ghost/core/test/unit/server/models/invite.test.js +++ b/ghost/core/test/unit/server/models/invite.test.js @@ -1,7 +1,8 @@ const assert = require('node:assert/strict'); const errors = require('@tryghost/errors'); const sinon = require('sinon'); -const models = require('../../../../core/server/models'); +const {Invite} = require('../../../../core/server/models/invite'); +const {Role} = require('../../../../core/server/models/role'); const settingsCache = require('../../../../core/shared/settings-cache'); describe('Unit: models/invite', function () { @@ -35,9 +36,9 @@ describe('Unit: models/invite', function () { }); it('role does not exist', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(null); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(null); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs) + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs) .then(Promise.reject) .catch((err) => { assert.equal(err instanceof errors.NotFoundError, true); @@ -45,10 +46,10 @@ describe('Unit: models/invite', function () { }); it('invite owner', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Owner'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs) + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs) .then(Promise.reject) .catch((err) => { assert.equal(err instanceof errors.NoPermissionError, true); @@ -61,31 +62,31 @@ describe('Unit: models/invite', function () { }); it('invite administrator', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Administrator'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); }); it('invite editor', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Editor'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); }); it('invite author', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Author'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); }); it('invite contributor', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Contributor'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); }); }); @@ -95,31 +96,31 @@ describe('Unit: models/invite', function () { }); it('invite administrator', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Administrator'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); }); it('invite editor', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Editor'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); }); it('invite author', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Author'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); }); it('invite contributor', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Contributor'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); }); }); @@ -129,10 +130,10 @@ describe('Unit: models/invite', function () { }); it('invite administrator', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Administrator'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true) + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true) .then(Promise.reject) .catch((err) => { assert.equal(err instanceof errors.NoPermissionError, true); @@ -140,10 +141,10 @@ describe('Unit: models/invite', function () { }); it('invite editor', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Editor'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true) + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true) .then(Promise.reject) .catch((err) => { assert.equal(err instanceof errors.NoPermissionError, true); @@ -154,10 +155,10 @@ describe('Unit: models/invite', function () { loadedPermissions.apiKey = { roles: [{name: 'Admin Integration'}] }; - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Editor'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true) + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true) .then(Promise.reject) .catch((err) => { assert.equal(err instanceof errors.NoPermissionError, true); @@ -166,17 +167,17 @@ describe('Unit: models/invite', function () { }); it('invite author', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Author'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); }); it('invite contributor', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Contributor'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, true, true, true); }); }); @@ -186,10 +187,10 @@ describe('Unit: models/invite', function () { }); it('invite administrator', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Administrator'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, false, false, true) + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, false, false, true) .then(Promise.reject) .catch((err) => { assert.equal(err instanceof errors.NoPermissionError, true); @@ -197,10 +198,10 @@ describe('Unit: models/invite', function () { }); it('invite editor', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Editor'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, false, false, true) + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, false, false, true) .then(Promise.reject) .catch((err) => { assert.equal(err instanceof errors.NoPermissionError, true); @@ -208,10 +209,10 @@ describe('Unit: models/invite', function () { }); it('invite author', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Author'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, false, false, true) + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, false, false, true) .then(Promise.reject) .catch((err) => { assert.equal(err instanceof errors.NoPermissionError, true); @@ -219,10 +220,10 @@ describe('Unit: models/invite', function () { }); it('invite contributor', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Contributor'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, false, false, true) + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, false, false, true) .then(Promise.reject) .catch((err) => { assert.equal(err instanceof errors.NoPermissionError, true); @@ -236,10 +237,10 @@ describe('Unit: models/invite', function () { }); it('invite administrator', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Administrator'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, false, false, true) + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, false, false, true) .then(Promise.reject) .catch((err) => { assert.equal(err instanceof errors.NoPermissionError, true); @@ -247,10 +248,10 @@ describe('Unit: models/invite', function () { }); it('invite editor', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Editor'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, false, false, true) + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, false, false, true) .then(Promise.reject) .catch((err) => { assert.equal(err instanceof errors.NoPermissionError, true); @@ -258,10 +259,10 @@ describe('Unit: models/invite', function () { }); it('invite author', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Author'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, false, false, true) + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, false, false, true) .then(Promise.reject) .catch((err) => { assert.equal(err instanceof errors.NoPermissionError, true); @@ -269,10 +270,10 @@ describe('Unit: models/invite', function () { }); it('invite contributor', function () { - sinon.stub(models.Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); + sinon.stub(Role, 'findOne').withArgs({id: 'role_id'}).resolves(roleModel); roleModel.get.withArgs('name').returns('Contributor'); - return models.Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, false, false, true) + return Invite.permissible(inviteModel, 'add', context, unsafeAttrs, loadedPermissions, false, false, true) .then(Promise.reject) .catch((err) => { assert.equal(err instanceof errors.NoPermissionError, true); diff --git a/ghost/core/test/unit/server/models/member-click-event.test.js b/ghost/core/test/unit/server/models/member-click-event.test.js index a1610023057..6fb02dfddda 100644 --- a/ghost/core/test/unit/server/models/member-click-event.test.js +++ b/ghost/core/test/unit/server/models/member-click-event.test.js @@ -1,5 +1,5 @@ const sinon = require('sinon'); -const models = require('../../../../core/server/models'); +const {MemberClickEvent} = require('../../../../core/server/models/member-click-event'); describe('Unit: models/MemberClickEvent', function () { afterEach(function () { @@ -7,13 +7,13 @@ describe('Unit: models/MemberClickEvent', function () { }); it('Has link and member relations', function () { - const model = models.MemberClickEvent.forge({id: 'any'}); + const model = MemberClickEvent.forge({id: 'any'}); model.link(); model.member(); }); it('Has filter relations', function () { - const model = models.MemberClickEvent.forge({id: 'any'}); + const model = MemberClickEvent.forge({id: 'any'}); model.filterRelations(); }); }); diff --git a/ghost/core/test/unit/server/models/member-created-event.test.js b/ghost/core/test/unit/server/models/member-created-event.test.js index e1683e8fe10..4b58e0350ac 100644 --- a/ghost/core/test/unit/server/models/member-created-event.test.js +++ b/ghost/core/test/unit/server/models/member-created-event.test.js @@ -1,7 +1,7 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); const errors = require('@tryghost/errors'); -const models = require('../../../../core/server/models'); +const {MemberCreatedEvent} = require('../../../../core/server/models/member-created-event'); describe('Unit: models/MemberCreatedEvent', function () { afterEach(function () { @@ -10,7 +10,7 @@ describe('Unit: models/MemberCreatedEvent', function () { describe('validation', function () { it('throws error for invalid attribution_type', function () { - return models.MemberCreatedEvent.add({attribution_type: 'invalid', source: 'member', member_id: '123'}) + return MemberCreatedEvent.add({attribution_type: 'invalid', source: 'member', member_id: '123'}) .then(function () { throw new Error('expected ValidationError'); }) @@ -22,7 +22,7 @@ describe('Unit: models/MemberCreatedEvent', function () { }); it('throws if member_id is missing', function () { - return models.MemberCreatedEvent.add({attribution_type: 'post', source: 'member'}) + return MemberCreatedEvent.add({attribution_type: 'post', source: 'member'}) .then(function () { throw new Error('expected ValidationError'); }) @@ -34,7 +34,7 @@ describe('Unit: models/MemberCreatedEvent', function () { }); it('throws if source is missing', function () { - return models.MemberCreatedEvent.add({attribution_type: 'post', member_id: '123'}) + return MemberCreatedEvent.add({attribution_type: 'post', member_id: '123'}) .then(function () { throw new Error('expected ValidationError'); }) @@ -46,7 +46,7 @@ describe('Unit: models/MemberCreatedEvent', function () { }); it('throws if source is invalid', function () { - return models.MemberCreatedEvent.add({attribution_type: 'post', member_id: '123', source: 'invalid'}) + return MemberCreatedEvent.add({attribution_type: 'post', member_id: '123', source: 'invalid'}) .then(function () { throw new Error('expected ValidationError'); }) diff --git a/ghost/core/test/unit/server/models/member-feedback.test.js b/ghost/core/test/unit/server/models/member-feedback.test.js index 03f3b4b37cb..a768bae28db 100644 --- a/ghost/core/test/unit/server/models/member-feedback.test.js +++ b/ghost/core/test/unit/server/models/member-feedback.test.js @@ -1,7 +1,7 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); const errors = require('@tryghost/errors'); -const models = require('../../../../core/server/models'); +const {MemberFeedback} = require('../../../../core/server/models/member-feedback'); describe('Unit: models/MemberFeedback', function () { afterEach(function () { @@ -10,7 +10,7 @@ describe('Unit: models/MemberFeedback', function () { describe('validation', function () { it('throws if member_id is missing', function () { - return models.MemberFeedback.add({score: 1, post_id: 'post'}) + return MemberFeedback.add({score: 1, post_id: 'post'}) .then(function () { throw new Error('expected ValidationError'); }) @@ -22,7 +22,7 @@ describe('Unit: models/MemberFeedback', function () { }); it('throws if post_id is missing', function () { - return models.MemberFeedback.add({score: 1, member_id: '123'}) + return MemberFeedback.add({score: 1, member_id: '123'}) .then(function () { throw new Error('expected ValidationError'); }) @@ -35,7 +35,7 @@ describe('Unit: models/MemberFeedback', function () { }); it('Delete is disabled', function () { - return models.MemberFeedback.destroy({id: 'any'}) + return MemberFeedback.destroy({id: 'any'}) .then(function () { throw new Error('expected IncorrectUsageError'); }) @@ -45,7 +45,7 @@ describe('Unit: models/MemberFeedback', function () { }); it('Has post and member relations', function () { - const model = models.MemberFeedback.forge({id: 'any'}); + const model = MemberFeedback.forge({id: 'any'}); model.post(); model.member(); }); diff --git a/ghost/core/test/unit/server/models/member-paid-subscription-event.test.js b/ghost/core/test/unit/server/models/member-paid-subscription-event.test.js index 9274feb8b41..b01302dba0d 100644 --- a/ghost/core/test/unit/server/models/member-paid-subscription-event.test.js +++ b/ghost/core/test/unit/server/models/member-paid-subscription-event.test.js @@ -1,5 +1,5 @@ const sinon = require('sinon'); -const models = require('../../../../core/server/models'); +const {MemberPaidSubscriptionEvent} = require('../../../../core/server/models/member-paid-subscription-event'); describe('Unit: models/MemberPaidSubscriptionEvent', function () { afterEach(function () { @@ -7,13 +7,13 @@ describe('Unit: models/MemberPaidSubscriptionEvent', function () { }); it('Has member and subscriptionCreatedEvent relations', function () { - const model = models.MemberPaidSubscriptionEvent.forge({id: 'any'}); + const model = MemberPaidSubscriptionEvent.forge({id: 'any'}); model.member(); model.subscriptionCreatedEvent(); }); it('Has filter relations', function () { - const model = models.MemberPaidSubscriptionEvent.forge({id: 'any'}); + const model = MemberPaidSubscriptionEvent.forge({id: 'any'}); model.filterRelations(); }); }); diff --git a/ghost/core/test/unit/server/models/member-subscribe-event.test.js b/ghost/core/test/unit/server/models/member-subscribe-event.test.js index 348a84ce300..a858239a87d 100644 --- a/ghost/core/test/unit/server/models/member-subscribe-event.test.js +++ b/ghost/core/test/unit/server/models/member-subscribe-event.test.js @@ -1,7 +1,7 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); const errors = require('@tryghost/errors'); -const models = require('../../../../core/server/models'); +const {MemberSubscribeEvent} = require('../../../../core/server/models/member-subscribe-event'); describe('Unit: models/MemberSubscribeEvent', function () { afterEach(function () { @@ -10,7 +10,7 @@ describe('Unit: models/MemberSubscribeEvent', function () { describe('validation', function () { it('throws if source is invalid', function () { - return models.MemberSubscribeEvent.add({member_id: '123', source: 'invalid'}) + return MemberSubscribeEvent.add({member_id: '123', source: 'invalid'}) .then(function () { throw new Error('expected ValidationError'); }) diff --git a/ghost/core/test/unit/server/models/member.test.js b/ghost/core/test/unit/server/models/member.test.js index 004498ecd82..9f8a9495b53 100644 --- a/ghost/core/test/unit/server/models/member.test.js +++ b/ghost/core/test/unit/server/models/member.test.js @@ -1,6 +1,7 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); -const models = require('../../../../core/server/models'); +const {Member} = require('../../../../core/server/models/member'); +const {Label} = require('../../../../core/server/models/label'); const configUtils = require('../../../utils/config-utils'); const labs = require('../../../../core/shared/labs'); @@ -21,7 +22,7 @@ describe('Unit: models/member', function () { beforeEach(function () { toJSON = function (model, options) { - return new models.Member(model).toJSON(options); + return new Member(model).toJSON(options); }; }); @@ -50,7 +51,7 @@ describe('Unit: models/member', function () { describe('onSaving', function () { it('skips labels without a name instead of throwing', async function () { - const memberModel = new models.Member({email: 'test@example.com'}); + const memberModel = new Member({email: 'test@example.com'}); // Simulate input from API where one label is missing `name` memberModel.set('labels', [ {name: 'Newsletter'}, @@ -65,7 +66,7 @@ describe('Unit: models/member', function () { id: 'existing-1', get: key => (key === 'name' ? 'Existing' : 'existing-1') }]; - const findAllStub = sinon.stub(models.Label, 'findAll').resolves({ + const findAllStub = sinon.stub(Label, 'findAll').resolves({ models: existingLabels }); @@ -83,7 +84,7 @@ describe('Unit: models/member', function () { let updatePivot; beforeEach(function () { - memberModel = new models.Member({email: 'text@example.com'}); + memberModel = new Member({email: 'text@example.com'}); updatePivot = sinon.stub(); sinon.stub(memberModel, 'products').callsFake(() => { diff --git a/ghost/core/test/unit/server/models/milestone.test.js b/ghost/core/test/unit/server/models/milestone.test.js index 538fc7125cb..4a22e1f6390 100644 --- a/ghost/core/test/unit/server/models/milestone.test.js +++ b/ghost/core/test/unit/server/models/milestone.test.js @@ -1,4 +1,4 @@ -const models = require('../../../../core/server/models'); +const {Milestone} = require('../../../../core/server/models/milestone'); const assert = require('node:assert/strict'); const errors = require('@tryghost/errors'); @@ -6,7 +6,7 @@ describe('Unit: models/milestone', function () { describe('validation', function () { describe('blank', function () { it('throws validation error for mandatory fields', function () { - return models.Milestone.add({}) + return Milestone.add({}) .then(function () { throw new Error('expected ValidationError'); }) diff --git a/ghost/core/test/unit/server/models/newsletter.test.js b/ghost/core/test/unit/server/models/newsletter.test.js index 6b5f2aeab28..5329c3c3ef4 100644 --- a/ghost/core/test/unit/server/models/newsletter.test.js +++ b/ghost/core/test/unit/server/models/newsletter.test.js @@ -2,7 +2,7 @@ const assert = require('node:assert/strict'); const errors = require('@tryghost/errors'); const sinon = require('sinon'); -const models = require('../../../../core/server/models'); +const {Newsletter} = require('../../../../core/server/models/newsletter'); describe('Unit: models/newsletter', function () { afterAll(function () { @@ -12,7 +12,7 @@ describe('Unit: models/newsletter', function () { describe('validation', function () { describe('blank', function () { it('throws validation error for mandatory fields', function () { - return models.Newsletter.add({}) + return Newsletter.add({}) .then(function () { throw new Error('expected ValidationError'); }) diff --git a/ghost/core/test/unit/server/models/outbox.test.js b/ghost/core/test/unit/server/models/outbox.test.js index e388d1d16ce..6d985cbe1cb 100644 --- a/ghost/core/test/unit/server/models/outbox.test.js +++ b/ghost/core/test/unit/server/models/outbox.test.js @@ -1,6 +1,6 @@ const assert = require('node:assert/strict'); const errors = require('@tryghost/errors'); -const models = require('../../../../core/server/models'); +const {Outbox} = require('../../../../core/server/models/outbox'); const {OUTBOX_STATUSES} = require('../../../../core/server/models/outbox'); describe('Unit: models/outbox', function () { @@ -15,21 +15,21 @@ describe('Unit: models/outbox', function () { describe('defaults', function () { it('sets default status to pending', function () { - const model = new models.Outbox(); + const model = new Outbox(); const defaults = model.defaults(); assert.equal(defaults.status, OUTBOX_STATUSES.PENDING); }); it('sets default retry_count to 0', function () { - const model = new models.Outbox(); + const model = new Outbox(); const defaults = model.defaults(); assert.equal(defaults.retry_count, 0); }); it('returns both default values', function () { - const model = new models.Outbox(); + const model = new Outbox(); const defaults = model.defaults(); assert.ok(defaults); @@ -41,7 +41,7 @@ describe('Unit: models/outbox', function () { describe('validation', function () { it('rejects invalid status values', function () { - return models.Outbox.add({ + return Outbox.add({ event_type: 'MemberCreatedEvent', payload: '{}', status: 'not-a-real-status' diff --git a/ghost/core/test/unit/server/models/permission.test.js b/ghost/core/test/unit/server/models/permission.test.js index 5e31ba003e8..734396f5b5f 100644 --- a/ghost/core/test/unit/server/models/permission.test.js +++ b/ghost/core/test/unit/server/models/permission.test.js @@ -1,6 +1,6 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); -const models = require('../../../../core/server/models'); +const {Permission} = require('../../../../core/server/models/permission'); const configUtils = require('../../../utils/config-utils'); describe('Unit: models/permission', function () { @@ -11,7 +11,7 @@ describe('Unit: models/permission', function () { describe('add', function () { it('[error] validation', function () { - return models.Permission.add({}) + return Permission.add({}) .then(function () { assert.equal('Should fail', true); }) diff --git a/ghost/core/test/unit/server/models/post.test.js b/ghost/core/test/unit/server/models/post.test.js index e73f94d02a7..fccd7a4007d 100644 --- a/ghost/core/test/unit/server/models/post.test.js +++ b/ghost/core/test/unit/server/models/post.test.js @@ -6,7 +6,7 @@ const sinon = require('sinon'); const testUtils = require('../../../utils'); const knex = require('../../../../core/server/data/db').knex; const urlService = require('../../../../core/server/services/url'); -const models = require('../../../../core/server/models'); +const {Post} = require('../../../../core/server/models/post'); const security = require('@tryghost/security'); describe('Unit: models/post', function () { @@ -36,7 +36,7 @@ describe('Unit: models/post', function () { query.response([]); }); - return models.Post.findPage({ + return Post.findPage({ filter: 'tags:[photo, video]+id:-' + testUtils.filterData.data.posts[3].id, limit: 3, withRelated: ['tags'] @@ -72,7 +72,7 @@ describe('Unit: models/post', function () { query.response([]); }); - return models.Post.findPage({ + return Post.findPage({ filter: 'authors:[leslie,pat]+(tag:hash-audio,feature_image:-null)', withRelated: ['authors', 'tags'] }).then(() => { @@ -107,7 +107,7 @@ describe('Unit: models/post', function () { query.response([]); }); - return models.Post.findPage({ + return Post.findPage({ filter: 'published_at:>\'2015-07-20\'', limit: 5, withRelated: ['tags'] @@ -139,7 +139,7 @@ describe('Unit: models/post', function () { query.response([]); }); - return models.Post.findPage({ + return Post.findPage({ filter: 'published_at:>\'2015-07-20\'', limit: 1, skipPagination: true, @@ -166,7 +166,7 @@ describe('Unit: models/post', function () { query.response([]); }); - return models.Post.findPage({ + return Post.findPage({ filter: 'published_at:>\'2015-07-20\'', limit: -5, page: -2, @@ -195,7 +195,7 @@ describe('Unit: models/post', function () { query.response([]); }); - return models.Post.findPage({ + return Post.findPage({ filter: 'primary_tag:photo', withRelated: ['tags'] }).then(() => { @@ -228,7 +228,7 @@ describe('Unit: models/post', function () { query.response([]); }); - return models.Post.findPage({ + return Post.findPage({ filter: 'primary_author:leslie', withRelated: ['authors'] }).then(() => { @@ -263,7 +263,7 @@ describe('Unit: models/post', function () { query.response([]); }); - return models.Post.findPage({ + return Post.findPage({ filter: 'status:[published,draft]', limit: 'all', status: 'published', @@ -291,7 +291,7 @@ describe('Unit: models/post', function () { describe('toJSON', function () { const toJSON = function toJSON(model, options) { - return new models.Post(model).toJSON(options); + return new Post(model).toJSON(options); }; it('ensure mobiledoc revisions are never exposed', function () { @@ -327,14 +327,14 @@ describe('Unit: models/post', function () { status: 'published' }; - const filter = new models.Post().extraFilters(options); + const filter = new Post().extraFilters(options); assert.equal(filter, 'status:published'); }); }); describe('enforcedFilters', function () { const enforcedFilters = function enforcedFilters(model, options) { - return new models.Post(model).enforcedFilters(options); + return new Post(model).enforcedFilters(options); }; it('returns published status filter for public context', function () { @@ -364,7 +364,7 @@ describe('Unit: models/post', function () { describe('defaultFilters', function () { const defaultFilters = function defaultFilters(model, options) { - return new models.Post(model).defaultFilters(options); + return new Post(model).defaultFilters(options); }; it('returns no default filter for internal context', function () { @@ -404,7 +404,7 @@ describe('Unit: models/post', function () { describe('countRelations', function () { it('can include all count relations', function () { - return models.Post.findAll({withRelated: ['count.signups', 'count.paid_conversions', 'count.clicks', 'count.sentiment', 'count.negative_feedback', 'count.positive_feedback']}); + return Post.findAll({withRelated: ['count.signups', 'count.paid_conversions', 'count.clicks', 'count.sentiment', 'count.negative_feedback', 'count.positive_feedback']}); }); }); }); @@ -439,7 +439,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { await assert.rejects( async () => { - await models.Post.permissible( + await Post.permissible( mockPostObj, 'edit', context, @@ -466,7 +466,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { mockPostObj.related.withArgs('authors').returns({models: [{id: 1}]}); await assert.rejects( - models.Post.permissible( + Post.permissible( mockPostObj, 'edit', context, @@ -491,7 +491,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { mockPostObj.related.withArgs('authors').returns({models: [{id: 1}]}); await assert.rejects( - models.Post.permissible( + Post.permissible( mockPostObj, 'edit', context, @@ -518,7 +518,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { mockPostObj.related.withArgs('authors').returns({models: [{id: 1}]}); mockPostObj.get.withArgs('status').returns('draft'); - const result = await models.Post.permissible( + const result = await Post.permissible( mockPostObj, 'edit', context, @@ -546,7 +546,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { mockPostObj.related.withArgs('authors').returns({models: [{id: 1}]}); await assert.rejects( - models.Post.permissible( + Post.permissible( mockPostObj, 'edit', context, @@ -573,7 +573,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { mockPostObj.related.withArgs('authors').returns({models: [{id: 1}]}); await assert.rejects( - models.Post.permissible( + Post.permissible( mockPostObj, 'edit', context, @@ -599,7 +599,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { mockPostObj.get.withArgs('status').returns('draft'); mockPostObj.related.withArgs('authors').returns({models: [{id: 1}]}); - return models.Post.permissible( + return Post.permissible( mockPostObj, 'edit', context, @@ -626,7 +626,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { const unsafeAttrs = {status: 'published', authors: [{id: 1}]}; await assert.rejects( - models.Post.permissible( + Post.permissible( mockPostObj, 'add', context, @@ -649,7 +649,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { const unsafeAttrs = {status: 'draft', authors: [{id: 2}]}; await assert.rejects( - models.Post.permissible( + Post.permissible( mockPostObj, 'add', context, @@ -672,7 +672,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { const unsafeAttrs = {status: 'draft', authors: [{id: 2}]}; await assert.rejects( - models.Post.permissible( + Post.permissible( mockPostObj, 'add', context, @@ -694,7 +694,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { const context = {user: 1}; const unsafeAttrs = {status: 'draft', authors: [{id: 1}]}; - const result = await models.Post.permissible( + const result = await Post.permissible( mockPostObj, 'add', context, @@ -721,7 +721,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { mockPostObj.related.withArgs('authors').returns({models: [{id: 1}]}); await assert.rejects( - models.Post.permissible( + Post.permissible( mockPostObj, 'destroy', context, @@ -748,7 +748,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { mockPostObj.get.withArgs('status').returns('published'); await assert.rejects( - models.Post.permissible( + Post.permissible( mockPostObj, 'destroy', context, @@ -774,7 +774,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { mockPostObj.get.withArgs('status').returns('draft'); mockPostObj.related.withArgs('authors').returns({models: [{id: 1}]}); - return models.Post.permissible( + return Post.permissible( mockPostObj, 'destroy', context, @@ -806,7 +806,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { mockPostObj.related.withArgs('authors').returns({models: [{id: 2}]}); await assert.rejects( - models.Post.permissible( + Post.permissible( mockPostObj, 'edit', context, @@ -834,7 +834,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { mockPostObj.related.withArgs('authors').returns({models: [{id: 1}]}); await assert.rejects( - models.Post.permissible( + Post.permissible( mockPostObj, 'edit', context, @@ -861,7 +861,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { mockPostObj.related.withArgs('authors').returns({models: [{id: 1}]}); await assert.rejects( - models.Post.permissible( + Post.permissible( mockPostObj, 'edit', context, @@ -888,7 +888,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { mockPostObj.related.withArgs('authors').returns({models: [{id: 1}]}); await assert.rejects( - models.Post.permissible( + Post.permissible( mockPostObj, 'edit', context, @@ -913,7 +913,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { mockPostObj.related.withArgs('authors').returns({models: [{id: 1}]}); - return models.Post.permissible( + return Post.permissible( mockPostObj, 'edit', context, @@ -937,7 +937,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { const unsafeAttrs = {authors: [{id: 2}]}; await assert.rejects( - models.Post.permissible( + Post.permissible( mockPostObj, 'add', context, @@ -963,7 +963,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { mockPostObj.related.withArgs('authors').returns({models: [{id: 1}]}); await assert.rejects( - models.Post.permissible( + Post.permissible( mockPostObj, 'add', context, @@ -992,7 +992,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { mockPostObj.related.withArgs('authors').returns({models: [{id: 2}]}); await assert.rejects( - models.Post.permissible( + Post.permissible( mockPostObj, 'edit', context, @@ -1015,7 +1015,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { const context = {user: 1}; const unsafeAttrs = {authors: [{id: 2}]}; - return models.Post.permissible( + return Post.permissible( mockPostObj, 'edit', context, @@ -1040,7 +1040,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { mockPostObj.get.withArgs('visibility').returns('paid'); mockPostObj.related.withArgs('authors').returns({models: [{id: 1}]}); - await models.Post.permissible( + await Post.permissible( mockPostObj, 'edit', context, @@ -1065,7 +1065,7 @@ describe('Unit: models/post: uses database (@TODO: fix me)', function () { mockPostObj.get.withArgs('visibility').returns('paid'); mockPostObj.related.withArgs('authors').returns({models: [{id: 1}]}); - await models.Post.permissible( + await Post.permissible( mockPostObj, 'edit', context, diff --git a/ghost/core/test/unit/server/models/session.test.js b/ghost/core/test/unit/server/models/session.test.js index 32f61899e57..17d2a57e67b 100644 --- a/ghost/core/test/unit/server/models/session.test.js +++ b/ghost/core/test/unit/server/models/session.test.js @@ -1,6 +1,7 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); -const models = require('../../../../core/server/models'); +const {Session} = require('../../../../core/server/models/session'); +const Base = require('../../../../core/server/models/base'); describe('Unit: models/session', function () { afterEach(function () { @@ -9,7 +10,7 @@ describe('Unit: models/session', function () { describe('parse', function () { const parse = function parse(attrs) { - return new models.Session().parse(attrs); + return new Session().parse(attrs); }; it('converts session_data to an object', function () { @@ -27,7 +28,7 @@ describe('Unit: models/session', function () { describe('format', function () { const format = function format(attrs) { - return new models.Session().format(attrs); + return new Session().format(attrs); }; it('converts session_data to a string', function () { @@ -55,7 +56,7 @@ describe('Unit: models/session', function () { describe('user', function () { it('sets up the relation to the "User" model', function () { - const model = models.Session.forge({}); + const model = Session.forge({}); const belongsToSpy = sinon.spy(model, 'belongsTo'); model.user(); @@ -69,32 +70,32 @@ describe('Unit: models/session', function () { beforeEach(function () { basePermittedOptionsReturnVal = ['super', 'doopa']; - basePermittedOptionsStub = sinon.stub(models.Base.Model, 'permittedOptions') + basePermittedOptionsStub = sinon.stub(Base.Model, 'permittedOptions') .returns(basePermittedOptionsReturnVal); }); it('passes the methodName and the context to the base permittedOptions method', function () { const methodName = 'methodName'; - models.Session.permittedOptions(methodName); + Session.permittedOptions(methodName); assert.equal(basePermittedOptionsStub.args[0][0], methodName); - assert.equal(basePermittedOptionsStub.thisValues[0], models.Session); + assert.equal(basePermittedOptionsStub.thisValues[0], Session); }); it('returns the base permittedOptions result', function () { - const returnedOptions = models.Session.permittedOptions(); + const returnedOptions = Session.permittedOptions(); assert.deepEqual(returnedOptions, basePermittedOptionsReturnVal); }); it('returns the base permittedOptions result plus "session_id" when methodName is upsert', function () { - const returnedOptions = models.Session.permittedOptions('upsert'); + const returnedOptions = Session.permittedOptions('upsert'); assert.deepEqual(returnedOptions, basePermittedOptionsReturnVal.concat('session_id')); }); it('returns the base permittedOptions result plus "session_id" when methodName is destroy', function () { - const returnedOptions = models.Session.permittedOptions('destroy'); + const returnedOptions = Session.permittedOptions('destroy'); assert.deepEqual(returnedOptions, basePermittedOptionsReturnVal.concat('session_id')); }); @@ -103,32 +104,32 @@ describe('Unit: models/session', function () { describe('destroy', function () { it('calls and returns the Base Model destroy if an id is passed', function () { const baseDestroyReturnVal = {}; - const baseDestroyStub = sinon.stub(models.Base.Model, 'destroy') + const baseDestroyStub = sinon.stub(Base.Model, 'destroy') .returns(baseDestroyReturnVal); const options = {id: 1}; - const returnVal = models.Session.destroy(options); + const returnVal = Session.destroy(options); assert.equal(baseDestroyStub.args[0][0], options); assert.equal(returnVal, baseDestroyReturnVal); }); it('calls forge with the session_id, fetchs with the filtered options and then destroys with the options', async function () { - const model = models.Session.forge({}); + const model = Session.forge({}); const session_id = 23; const unfilteredOptions = {session_id}; const filteredOptions = {session_id}; - const filterOptionsStub = sinon.stub(models.Session, 'filterOptions') + const filterOptionsStub = sinon.stub(Session, 'filterOptions') .returns(filteredOptions); - const forgeStub = sinon.stub(models.Session, 'forge') + const forgeStub = sinon.stub(Session, 'forge') .returns(model); const fetchStub = sinon.stub(model, 'fetch') .resolves(model); const destroyStub = sinon.stub(model, 'destroy') .resolves(); - await models.Session.destroy(unfilteredOptions); + await Session.destroy(unfilteredOptions); assert.equal(filterOptionsStub.args[0][0], unfilteredOptions); assert.equal(filterOptionsStub.args[0][1], 'destroy'); @@ -151,15 +152,15 @@ describe('Unit: models/session', function () { } }; - const filterOptionsStub = sinon.stub(models.Session, 'filterOptions') + const filterOptionsStub = sinon.stub(Session, 'filterOptions') .returns(filteredOptions); - const findOneStub = sinon.stub(models.Session, 'findOne') + const findOneStub = sinon.stub(Session, 'findOne') .resolves(); - const addStub = sinon.stub(models.Session, 'add'); + const addStub = sinon.stub(Session, 'add'); - await models.Session.upsert(data, unfilteredOptions); + await Session.upsert(data, unfilteredOptions); assert.equal(filterOptionsStub.args[0][0], unfilteredOptions); assert.equal(filterOptionsStub.args[0][1], 'upsert'); @@ -179,7 +180,7 @@ describe('Unit: models/session', function () { }); it('calls findOne and then edit if findOne results in nothing', async function () { - const model = models.Session.forge({id: 2}); + const model = Session.forge({id: 2}); const session_id = 314; const unfilteredOptions = {session_id}; const filteredOptions = {session_id}; @@ -189,15 +190,15 @@ describe('Unit: models/session', function () { } }; - const filterOptionsStub = sinon.stub(models.Session, 'filterOptions') + const filterOptionsStub = sinon.stub(Session, 'filterOptions') .returns(filteredOptions); - const findOneStub = sinon.stub(models.Session, 'findOne') + const findOneStub = sinon.stub(Session, 'findOne') .resolves(model); - const editStub = sinon.stub(models.Session, 'edit'); + const editStub = sinon.stub(Session, 'edit'); - await models.Session.upsert(data, unfilteredOptions); + await Session.upsert(data, unfilteredOptions); assert.equal(filterOptionsStub.args[0][0], unfilteredOptions); assert.equal(filterOptionsStub.args[0][1], 'upsert'); diff --git a/ghost/core/test/unit/server/models/settings.test.js b/ghost/core/test/unit/server/models/settings.test.js index 04bc0785b0f..7865e93b931 100644 --- a/ghost/core/test/unit/server/models/settings.test.js +++ b/ghost/core/test/unit/server/models/settings.test.js @@ -2,7 +2,7 @@ const assert = require('node:assert/strict'); const {assertExists} = require('../../../utils/assertions'); const sinon = require('sinon'); const mockDb = require('../../../utils/mock-knex'); -const models = require('../../../../core/server/models'); +const {Settings} = require('../../../../core/server/models/settings'); const config = require('../../../../core/shared/config'); const {knex} = require('../../../../core/server/data/db'); const events = require('../../../../core/server/lib/common/events'); @@ -41,7 +41,7 @@ describe('Unit: models/settings', function () { ][step - 1](); }); - return models.Settings.add({ + return Settings.add({ key: 'description', value: 'added value', type: 'string' @@ -66,7 +66,7 @@ describe('Unit: models/settings', function () { return query.response([{}]); }); - return models.Settings.edit({ + return Settings.edit({ key: 'description', value: 'edited value' }) @@ -80,7 +80,7 @@ describe('Unit: models/settings', function () { describe('format', function () { it('transforms urls when persisting to db', function () { - const setting = models.Settings.forge(); + const setting = Settings.forge(); const siteUrl = config.get('url'); let returns = setting.formatOnWrite({key: 'cover_image', value: `${siteUrl}/cover_image.png`, type: 'string'}); @@ -105,7 +105,7 @@ describe('Unit: models/settings', function () { describe('parse', function () { it('ensure correct parsing when fetching from db', function () { - const setting = models.Settings.forge(); + const setting = Settings.forge(); const siteUrl = config.get('url'); let returns = setting.parse({key: 'is_private', value: 'false', type: 'boolean'}); @@ -151,7 +151,7 @@ describe('Unit: models/settings', function () { describe('validation', function () { async function testInvalidSetting({key, value, type, group}) { - const setting = models.Settings.forge({key, value, type, group}); + const setting = Settings.forge({key, value, type, group}); let error; try { @@ -174,7 +174,7 @@ describe('Unit: models/settings', function () { query.response([{}]); }); - const setting = models.Settings.forge({key, value, type, group}); + const setting = Settings.forge({key, value, type, group}); // This should not reject. await setting.save(); diff --git a/ghost/core/test/unit/server/models/single-use-token.test.js b/ghost/core/test/unit/server/models/single-use-token.test.js index 6399761aa2c..aebf150b43d 100644 --- a/ghost/core/test/unit/server/models/single-use-token.test.js +++ b/ghost/core/test/unit/server/models/single-use-token.test.js @@ -1,4 +1,4 @@ -const models = require('../../../../core/server/models'); +const {SingleUseToken} = require('../../../../core/server/models/single-use-token'); const sinon = require('sinon'); const assert = require('node:assert/strict'); const {assertExists} = require('../../../utils/assertions'); @@ -20,13 +20,13 @@ describe('Unit: models/single-use-token', function () { describe('fn: defaults', function () { it('Defaults to used_count of zero', async function () { - const model = new models.SingleUseToken(); + const model = new SingleUseToken(); const defaults = model.defaults(); assert.equal(defaults.used_count, 0); }); it('Generates a valid v4 UUID by default', async function () { - const model = new models.SingleUseToken(); + const model = new SingleUseToken(); const defaults = model.defaults(); assertExists(defaults.uuid); @@ -34,8 +34,8 @@ describe('Unit: models/single-use-token', function () { }); it('Generates unique UUIDs for different instances', async function () { - const model1 = new models.SingleUseToken(); - const model2 = new models.SingleUseToken(); + const model1 = new SingleUseToken(); + const model2 = new SingleUseToken(); const defaults1 = model1.defaults(); const defaults2 = model2.defaults(); diff --git a/ghost/core/test/unit/server/models/stripe-customer-subscription.test.js b/ghost/core/test/unit/server/models/stripe-customer-subscription.test.js index 6b8d45a7f9e..566a355c6b3 100644 --- a/ghost/core/test/unit/server/models/stripe-customer-subscription.test.js +++ b/ghost/core/test/unit/server/models/stripe-customer-subscription.test.js @@ -1,6 +1,6 @@ const {assertExists} = require('../../../utils/assertions'); const sinon = require('sinon'); -const models = require('../../../../core/server/models'); +const {StripeCustomerSubscription} = require('../../../../core/server/models/stripe-customer-subscription'); describe('Unit: models/stripe-customer-subscription', function () { afterEach(function () { @@ -12,7 +12,7 @@ describe('Unit: models/stripe-customer-subscription', function () { beforeEach(function () { serialize = function (model, options) { - return new models.StripeCustomerSubscription(model).serialize(options); + return new StripeCustomerSubscription(model).serialize(options); }; }); diff --git a/ghost/core/test/unit/server/models/subscription-created-event.test.js b/ghost/core/test/unit/server/models/subscription-created-event.test.js index 1f9b8a3db63..b39bcc76326 100644 --- a/ghost/core/test/unit/server/models/subscription-created-event.test.js +++ b/ghost/core/test/unit/server/models/subscription-created-event.test.js @@ -1,7 +1,7 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); const errors = require('@tryghost/errors'); -const models = require('../../../../core/server/models'); +const {SubscriptionCreatedEvent} = require('../../../../core/server/models/subscription-created-event'); describe('Unit: models/SubscriptionCreatedEvent', function () { afterEach(function () { @@ -10,7 +10,7 @@ describe('Unit: models/SubscriptionCreatedEvent', function () { describe('validation', function () { it('throws error for invalid attribution_type', function () { - return models.SubscriptionCreatedEvent.add({attribution_type: 'invalid', member_id: '123', subscription_id: '123'}) + return SubscriptionCreatedEvent.add({attribution_type: 'invalid', member_id: '123', subscription_id: '123'}) .then(function () { throw new Error('expected ValidationError'); }) @@ -22,7 +22,7 @@ describe('Unit: models/SubscriptionCreatedEvent', function () { }); it('throws if member_id is missing', function () { - return models.SubscriptionCreatedEvent.add({attribution_type: 'post', subscription_id: '123'}) + return SubscriptionCreatedEvent.add({attribution_type: 'post', subscription_id: '123'}) .then(function () { throw new Error('expected ValidationError'); }) @@ -34,7 +34,7 @@ describe('Unit: models/SubscriptionCreatedEvent', function () { }); it('throws if subscription_id is missing', function () { - return models.SubscriptionCreatedEvent.add({attribution_type: 'post', member_id: '123'}) + return SubscriptionCreatedEvent.add({attribution_type: 'post', member_id: '123'}) .then(function () { throw new Error('expected ValidationError'); }) diff --git a/ghost/core/test/unit/server/models/tag.test.js b/ghost/core/test/unit/server/models/tag.test.js index 6f4b21ace27..ce7dd4baa75 100644 --- a/ghost/core/test/unit/server/models/tag.test.js +++ b/ghost/core/test/unit/server/models/tag.test.js @@ -1,6 +1,6 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); -const models = require('../../../../core/server/models'); +const {Tag} = require('../../../../core/server/models/tag'); const {knex} = require('../../../../core/server/data/db'); describe('Unit: models/tag', function () { @@ -34,7 +34,7 @@ describe('Unit: models/tag', function () { query.response([]); }); - return models.Tag.findPage({ + return Tag.findPage({ filter: 'count.posts:>=1', order: 'count.posts DESC', limit: 'all', diff --git a/ghost/core/test/unit/server/models/welcome-email-automated-email.test.js b/ghost/core/test/unit/server/models/welcome-email-automated-email.test.js index 7d2de15f636..59ed50766dd 100644 --- a/ghost/core/test/unit/server/models/welcome-email-automated-email.test.js +++ b/ghost/core/test/unit/server/models/welcome-email-automated-email.test.js @@ -1,6 +1,7 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); -const models = require('../../../../core/server/models'); +const {WelcomeEmailAutomatedEmail} = require('../../../../core/server/models/welcome-email-automated-email'); +const {EmailDesignSetting} = require('../../../../core/server/models/email-design-setting'); const config = require('../../../../core/shared/config'); describe('Unit: models/welcome-email-automated-email', function () { @@ -10,7 +11,7 @@ describe('Unit: models/welcome-email-automated-email', function () { describe('parse', function () { it('transforms __GHOST_URL__ to absolute URL in lexical field', function () { - const model = models.WelcomeEmailAutomatedEmail.forge(); + const model = WelcomeEmailAutomatedEmail.forge(); const result = model.parse({ id: '123', @@ -22,7 +23,7 @@ describe('Unit: models/welcome-email-automated-email', function () { }); it('handles null lexical field', function () { - const model = models.WelcomeEmailAutomatedEmail.forge(); + const model = WelcomeEmailAutomatedEmail.forge(); const result = model.parse({ id: '123', @@ -33,7 +34,7 @@ describe('Unit: models/welcome-email-automated-email', function () { }); it('handles undefined lexical field', function () { - const model = models.WelcomeEmailAutomatedEmail.forge(); + const model = WelcomeEmailAutomatedEmail.forge(); const result = model.parse({ id: '123' @@ -43,7 +44,7 @@ describe('Unit: models/welcome-email-automated-email', function () { }); it('preserves other fields', function () { - const model = models.WelcomeEmailAutomatedEmail.forge(); + const model = WelcomeEmailAutomatedEmail.forge(); const result = model.parse({ id: '123', @@ -58,7 +59,7 @@ describe('Unit: models/welcome-email-automated-email', function () { describe('formatOnWrite', function () { it('transforms absolute URLs to __GHOST_URL__ in lexical field', function () { - const model = models.WelcomeEmailAutomatedEmail.forge(); + const model = WelcomeEmailAutomatedEmail.forge(); const siteUrl = config.get('url'); const result = model.formatOnWrite({ @@ -70,7 +71,7 @@ describe('Unit: models/welcome-email-automated-email', function () { }); it('handles null lexical field', function () { - const model = models.WelcomeEmailAutomatedEmail.forge(); + const model = WelcomeEmailAutomatedEmail.forge(); const result = model.formatOnWrite({ lexical: null @@ -80,7 +81,7 @@ describe('Unit: models/welcome-email-automated-email', function () { }); it('handles undefined lexical field', function () { - const model = models.WelcomeEmailAutomatedEmail.forge(); + const model = WelcomeEmailAutomatedEmail.forge(); const result = model.formatOnWrite({ subject: 'Welcome!' @@ -91,7 +92,7 @@ describe('Unit: models/welcome-email-automated-email', function () { }); it('preserves other fields', function () { - const model = models.WelcomeEmailAutomatedEmail.forge(); + const model = WelcomeEmailAutomatedEmail.forge(); const result = model.formatOnWrite({ id: '123', @@ -106,9 +107,9 @@ describe('Unit: models/welcome-email-automated-email', function () { describe('onCreating', function () { it('assigns the default email design setting when not provided', async function () { - const model = models.WelcomeEmailAutomatedEmail.forge(); - const baseOnCreating = sinon.stub(Object.getPrototypeOf(models.WelcomeEmailAutomatedEmail.prototype), 'onCreating').resolves(); - const findOne = sinon.stub(models.EmailDesignSetting, 'findOne').resolves(models.EmailDesignSetting.forge({id: 'default-setting-id'})); + const model = WelcomeEmailAutomatedEmail.forge(); + const baseOnCreating = sinon.stub(Object.getPrototypeOf(WelcomeEmailAutomatedEmail.prototype), 'onCreating').resolves(); + const findOne = sinon.stub(EmailDesignSetting, 'findOne').resolves(EmailDesignSetting.forge({id: 'default-setting-id'})); await model.onCreating(model, {}, {}); @@ -118,9 +119,9 @@ describe('Unit: models/welcome-email-automated-email', function () { }); it('assigns the default email design setting when null is provided', async function () { - const model = models.WelcomeEmailAutomatedEmail.forge({email_design_setting_id: null}); - sinon.stub(Object.getPrototypeOf(models.WelcomeEmailAutomatedEmail.prototype), 'onCreating').resolves(); - sinon.stub(models.EmailDesignSetting, 'findOne').resolves(models.EmailDesignSetting.forge({id: 'default-setting-id'})); + const model = WelcomeEmailAutomatedEmail.forge({email_design_setting_id: null}); + sinon.stub(Object.getPrototypeOf(WelcomeEmailAutomatedEmail.prototype), 'onCreating').resolves(); + sinon.stub(EmailDesignSetting, 'findOne').resolves(EmailDesignSetting.forge({id: 'default-setting-id'})); await model.onCreating(model, {}, {}); @@ -128,9 +129,9 @@ describe('Unit: models/welcome-email-automated-email', function () { }); it('keeps the provided email design setting id', async function () { - const model = models.WelcomeEmailAutomatedEmail.forge({email_design_setting_id: 'custom-setting-id'}); - const baseOnCreating = sinon.stub(Object.getPrototypeOf(models.WelcomeEmailAutomatedEmail.prototype), 'onCreating').resolves(); - const findOne = sinon.stub(models.EmailDesignSetting, 'findOne'); + const model = WelcomeEmailAutomatedEmail.forge({email_design_setting_id: 'custom-setting-id'}); + const baseOnCreating = sinon.stub(Object.getPrototypeOf(WelcomeEmailAutomatedEmail.prototype), 'onCreating').resolves(); + const findOne = sinon.stub(EmailDesignSetting, 'findOne'); await model.onCreating(model, {}, {}); @@ -140,9 +141,9 @@ describe('Unit: models/welcome-email-automated-email', function () { }); it('throws when the default email design setting is missing', async function () { - const model = models.WelcomeEmailAutomatedEmail.forge(); - sinon.stub(Object.getPrototypeOf(models.WelcomeEmailAutomatedEmail.prototype), 'onCreating').resolves(); - sinon.stub(models.EmailDesignSetting, 'findOne').resolves(null); + const model = WelcomeEmailAutomatedEmail.forge(); + sinon.stub(Object.getPrototypeOf(WelcomeEmailAutomatedEmail.prototype), 'onCreating').resolves(); + sinon.stub(EmailDesignSetting, 'findOne').resolves(null); await assert.rejects( () => model.onCreating(model, {}, {}), diff --git a/ghost/core/test/unit/server/models/welcome-email-automation-run.test.js b/ghost/core/test/unit/server/models/welcome-email-automation-run.test.js index 603ac6b78e1..066a2f29dca 100644 --- a/ghost/core/test/unit/server/models/welcome-email-automation-run.test.js +++ b/ghost/core/test/unit/server/models/welcome-email-automation-run.test.js @@ -1,23 +1,23 @@ const assert = require('node:assert/strict'); -const models = require('../../../../core/server/models'); +const {WelcomeEmailAutomationRun} = require('../../../../core/server/models/welcome-email-automation-run'); describe('Unit: models/welcome-email-automation-run', function () { describe('tableName', function () { it('uses the correct table name', function () { - const model = new models.WelcomeEmailAutomationRun(); + const model = new WelcomeEmailAutomationRun(); assert.equal(model.tableName, 'welcome_email_automation_runs'); }); }); describe('defaults', function () { it('sets stepAttempts to 0', function () { - const model = new models.WelcomeEmailAutomationRun(); + const model = new WelcomeEmailAutomationRun(); const defaults = model.defaults(); assert.equal(defaults.stepAttempts, 0); }); it('returns only stepAttempts as a default', function () { - const model = new models.WelcomeEmailAutomationRun(); + const model = new WelcomeEmailAutomationRun(); const defaults = model.defaults(); assert.deepEqual(Object.keys(defaults), ['stepAttempts']); }); @@ -25,17 +25,17 @@ describe('Unit: models/welcome-email-automation-run', function () { describe('relationships', function () { it('has an automation relationship', function () { - const model = new models.WelcomeEmailAutomationRun(); + const model = new WelcomeEmailAutomationRun(); assert.equal(typeof model.automation, 'function'); }); it('has a member relationship', function () { - const model = new models.WelcomeEmailAutomationRun(); + const model = new WelcomeEmailAutomationRun(); assert.equal(typeof model.member, 'function'); }); it('has a nextWelcomeEmailAutomatedEmail relationship', function () { - const model = new models.WelcomeEmailAutomationRun(); + const model = new WelcomeEmailAutomationRun(); assert.equal(typeof model.nextWelcomeEmailAutomatedEmail, 'function'); }); }); diff --git a/ghost/core/test/unit/server/services/auth/api-key/admin.test.js b/ghost/core/test/unit/server/services/auth/api-key/admin.test.js index bc9bf1a432c..5b9e171405e 100644 --- a/ghost/core/test/unit/server/services/auth/api-key/admin.test.js +++ b/ghost/core/test/unit/server/services/auth/api-key/admin.test.js @@ -5,7 +5,7 @@ const errors = require('@tryghost/errors'); const jwt = require('jsonwebtoken'); const sinon = require('sinon'); const apiKeyAuth = require('../../../../../../core/server/services/auth/api-key'); -const models = require('../../../../../../core/server/models'); +const {ApiKey} = require('../../../../../../core/server/models/api-key'); describe('Admin API Key Auth', function () { const ADMIN_API_URL_VERSIONED = '/ghost/api/v4/admin/'; @@ -26,7 +26,7 @@ describe('Admin API Key Auth', function () { }; secret = Buffer.from(fakeApiKey.secret, 'hex'); - apiKeyStub = sinon.stub(models.ApiKey, 'findOne'); + apiKeyStub = sinon.stub(ApiKey, 'findOne'); apiKeyStub.resolves(); apiKeyStub.withArgs({id: fakeApiKey.id}).resolves(fakeApiKey); }); diff --git a/ghost/core/test/unit/server/services/auth/api-key/content.test.js b/ghost/core/test/unit/server/services/auth/api-key/content.test.js index f2e0e16182f..a7094c3e6f2 100644 --- a/ghost/core/test/unit/server/services/auth/api-key/content.test.js +++ b/ghost/core/test/unit/server/services/auth/api-key/content.test.js @@ -3,7 +3,7 @@ const deferred = require('../../../../../utils/deferred'); const {assertExists} = require('../../../../../utils/assertions'); const errors = require('@tryghost/errors'); const {authenticateContentApiKey} = require('../../../../../../core/server/services/auth/api-key/content'); -const models = require('../../../../../../core/server/models'); +const {ApiKey} = require('../../../../../../core/server/models/api-key'); const sinon = require('sinon'); describe('Content API Key Auth', function () { @@ -20,7 +20,7 @@ describe('Content API Key Auth', function () { } }; - apiKeyStub = sinon.stub(models.ApiKey, 'findOne'); + apiKeyStub = sinon.stub(ApiKey, 'findOne'); apiKeyStub.returns(Promise.resolve()); apiKeyStub.withArgs({secret: fakeApiKey.secret}).returns(Promise.resolve(fakeApiKey)); }); diff --git a/ghost/core/test/unit/server/services/auth/session/store.test.js b/ghost/core/test/unit/server/services/auth/session/store.test.js index 9b90d92e57f..a8261415e85 100644 --- a/ghost/core/test/unit/server/services/auth/session/store.test.js +++ b/ghost/core/test/unit/server/services/auth/session/store.test.js @@ -1,7 +1,7 @@ const assert = require('node:assert/strict'); const deferred = require('../../../../../utils/deferred'); const SessionStore = require('../../../../../../core/server/services/auth/session/session-store'); -const models = require('../../../../../../core/server/models'); +const {Session} = require('../../../../../../core/server/models/session'); const EventEmitter = require('events'); const {Store} = require('express-session'); const sinon = require('sinon'); @@ -26,10 +26,10 @@ describe('Auth Service SessionStore', function () { describe('SessionStore#destroy', function () { it('calls destroy on the model with the session_id `sid`', function () { const {promise, done} = deferred(); - const destroyStub = sinon.stub(models.Session, 'destroy') + const destroyStub = sinon.stub(Session, 'destroy') .resolves(); - const store = new SessionStore(models.Session); + const store = new SessionStore(Session); const sid = 1; store.destroy(sid, function () { const destroyStubCall = destroyStub.getCall(0); @@ -41,10 +41,10 @@ describe('Auth Service SessionStore', function () { it('calls back with null if destroy resolve', function () { const {promise, done} = deferred(); - sinon.stub(models.Session, 'destroy') + sinon.stub(Session, 'destroy') .resolves(); - const store = new SessionStore(models.Session); + const store = new SessionStore(Session); const sid = 1; store.destroy(sid, function (err) { assert.equal(err, null); @@ -56,10 +56,10 @@ describe('Auth Service SessionStore', function () { it('calls back with the error if destroy errors', function () { const {promise, done} = deferred(); const error = new Error('beam me up scotty'); - sinon.stub(models.Session, 'destroy') + sinon.stub(Session, 'destroy') .rejects(error); - const store = new SessionStore(models.Session); + const store = new SessionStore(Session); const sid = 1; store.destroy(sid, function (err) { assert.equal(err, error); @@ -72,10 +72,10 @@ describe('Auth Service SessionStore', function () { describe('SessionStore#get', function () { it('calls findOne on the model with the session_id `sid`', function () { const {promise, done} = deferred(); - const findOneStub = sinon.stub(models.Session, 'findOne') + const findOneStub = sinon.stub(Session, 'findOne') .resolves(); - const store = new SessionStore(models.Session); + const store = new SessionStore(Session); const sid = 1; store.get(sid, function () { const findOneStubCall = findOneStub.getCall(0); @@ -87,10 +87,10 @@ describe('Auth Service SessionStore', function () { it('callsback with null, null if findOne does not return a model', function () { const {promise, done} = deferred(); - sinon.stub(models.Session, 'findOne') + sinon.stub(Session, 'findOne') .resolves(null); - const store = new SessionStore(models.Session); + const store = new SessionStore(Session); const sid = 1; store.get(sid, function (err, session) { assert.equal(err, null); @@ -102,15 +102,15 @@ describe('Auth Service SessionStore', function () { it('callsback with null, model.session_data if findOne does return a model', function () { const {promise, done} = deferred(); - const model = models.Session.forge({ + const model = Session.forge({ session_data: { ice: 'cube' } }); - sinon.stub(models.Session, 'findOne') + sinon.stub(Session, 'findOne') .resolves(model); - const store = new SessionStore(models.Session); + const store = new SessionStore(Session); const sid = 1; store.get(sid, function (err, session) { assert.equal(err, null); @@ -125,10 +125,10 @@ describe('Auth Service SessionStore', function () { it('callsback with an error if the findOne does error', function () { const {promise, done} = deferred(); const error = new Error('hot damn'); - sinon.stub(models.Session, 'findOne') + sinon.stub(Session, 'findOne') .rejects(error); - const store = new SessionStore(models.Session); + const store = new SessionStore(Session); const sid = 1; store.get(sid, function (err) { assert.equal(err, error); @@ -141,10 +141,10 @@ describe('Auth Service SessionStore', function () { describe('SessionStore#set', function () { it('calls upsert on the model with the session_id and the session_data', function () { const {promise, done} = deferred(); - const upsertStub = sinon.stub(models.Session, 'upsert') + const upsertStub = sinon.stub(Session, 'upsert') .resolves(); - const store = new SessionStore(models.Session); + const store = new SessionStore(Session); const sid = 1; const session_data = {user_id: 100}; store.set(sid, session_data, function () { @@ -159,10 +159,10 @@ describe('Auth Service SessionStore', function () { it('calls back with an error if upsert errors', function () { const {promise, done} = deferred(); const error = new Error('huuuuuurrr'); - sinon.stub(models.Session, 'upsert') + sinon.stub(Session, 'upsert') .rejects(error); - const store = new SessionStore(models.Session); + const store = new SessionStore(Session); const sid = 1; const session_data = {user_id: 100}; store.set(sid, session_data, function (err) { @@ -174,10 +174,10 @@ describe('Auth Service SessionStore', function () { it('calls back with null, null if upsert succeed', function () { const {promise, done} = deferred(); - sinon.stub(models.Session, 'upsert') + sinon.stub(Session, 'upsert') .resolves('success'); - const store = new SessionStore(models.Session); + const store = new SessionStore(Session); const sid = 1; const session_data = {user_id: 100}; store.set(sid, session_data, function (err, data) { 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 4a23a41bf21..4afc4a1fcda 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 @@ -1248,6 +1248,85 @@ describe('Email renderer', function () { let response = await emailRenderer.getSegments(post); assert.deepEqual(response, ['status:free', 'status:-free']); }); + + it('returns tier access and no-access segments for a tiers post with a paywall', async function () { + emailRenderer = new EmailRenderer({ + renderers: { + lexical: { + render: () => { + return '

preview

members

'; + } + } + }, + getPostUrl: () => { + return 'http://example.com/post-id'; + }, + labs: { + isSet: () => false + } + }); + + const post = { + get: (key) => { + if (key === 'lexical') { + return '{}'; + } + if (key === 'visibility') { + return 'tiers'; + } + }, + related: (key) => { + if (key === 'tiers') { + return {toJSON: () => [{slug: 'gold'}, {slug: 'silver'}]}; + } + } + }; + const response = await emailRenderer.getSegments(post); + assert.deepEqual(response, [ + 'product:\'gold\',product:\'silver\'', + 'product:-\'gold\'+product:-\'silver\'' + ]); + }); + + it('splits no-access into free/paid when a tiers post also has preview cards', async function () { + emailRenderer = new EmailRenderer({ + renderers: { + lexical: { + render: () => { + return '
upsell

members

'; + } + } + }, + getPostUrl: () => { + return 'http://example.com/post-id'; + }, + labs: { + isSet: () => false + } + }); + + const post = { + get: (key) => { + if (key === 'lexical') { + return '{}'; + } + if (key === 'visibility') { + return 'tiers'; + } + }, + related: (key) => { + if (key === 'tiers') { + return {toJSON: () => [{slug: 'gold'}]}; + } + } + }; + const response = await emailRenderer.getSegments(post); + assert.deepEqual(response, [ + 'status:free', + 'status:-free+(product:\'gold\')', + 'status:-free+(product:-\'gold\')' + ]); + }); }); describe('renderBody', function () { @@ -2193,6 +2272,54 @@ describe('Email renderer', function () { assert(!responsePaid.html.includes('Become a paid member of Test Blog to get access to all')); }); + it('paywalls a tiers post for the no-access segment and not for the access segment', async function () { + renderedPost = '
Lexical Test
some text for both finishing part only for members'; + const post = { + related: (key) => { + if (key === 'tiers') { + return {toJSON: () => [{slug: 'gold'}]}; + } + return null; + }, + get: (key) => { + if (key === 'lexical') { + return '{}'; + } + if (key === 'visibility') { + return 'tiers'; + } + if (key === 'title') { + return 'Test Post'; + } + }, + getLazyRelation: () => { + return {models: [{get: k => (k === 'name' ? 'Test Author' : undefined)}]}; + } + }; + const newsletter = { + get: (key) => { + if (key === 'show_post_title_section') { + return true; + } + if (key === 'feedback_enabled') { + return true; + } + return false; + } + }; + + // Member NOT on the post's tier -> public preview + paywall + const noAccess = await emailRenderer.renderBody(post, newsletter, 'product:-\'gold\'', {}); + assert(noAccess.html.includes('some text for both')); + assert(!noAccess.html.includes('finishing part only for members')); + assert(noAccess.html.includes('Become a paid member of Test Blog to get access to all')); + + // Member ON the post's tier -> full content, no paywall + const access = await emailRenderer.renderBody(post, newsletter, 'product:\'gold\'', {}); + assert(access.html.includes('finishing part only for members')); + assert(!access.html.includes('Become a paid member of Test Blog to get access to all')); + }); + it('should output valid HTML and escape HTML characters in mobiledoc', async function () { const post = createModel({ ...basePost, diff --git a/ghost/core/test/unit/server/services/members/content-gating.test.js b/ghost/core/test/unit/server/services/members/content-gating.test.js index 5536d9ecb77..b5ae20fe70b 100644 --- a/ghost/core/test/unit/server/services/members/content-gating.test.js +++ b/ghost/core/test/unit/server/services/members/content-gating.test.js @@ -1,7 +1,39 @@ const assert = require('node:assert/strict'); -const {checkPostAccess, checkGatedBlockAccess} = require('../../../../../core/server/services/members/content-gating'); +const {getPostAccessFilter, checkPostAccess, checkGatedBlockAccess} = require('../../../../../core/server/services/members/content-gating'); describe('Members Service - Content gating', function () { + describe('getPostAccessFilter', function () { + it('returns status:-free for paid posts', function () { + assert.equal(getPostAccessFilter({visibility: 'paid'}), 'status:-free'); + }); + + it('returns a product OR filter for tiers posts', function () { + const filter = getPostAccessFilter({visibility: 'tiers', tiers: [{slug: 'gold'}, {slug: 'silver'}]}); + assert.equal(filter, 'product:\'gold\',product:\'silver\''); + }); + + it('returns null for a tiers post with no tiers relation', function () { + assert.equal(getPostAccessFilter({visibility: 'tiers'}), null); + }); + + it('returns null for a tiers post with an empty tiers list', function () { + assert.equal(getPostAccessFilter({visibility: 'tiers', tiers: []}), null); + }); + + it('passes through other visibility values', function () { + assert.equal(getPostAccessFilter({visibility: 'members'}), 'members'); + assert.equal(getPostAccessFilter({visibility: 'public'}), 'public'); + }); + + it('agrees with checkPostAccess for tiers posts', function () { + const post = {visibility: 'tiers', tiers: [{slug: 'gold'}]}; + const onTier = {id: 'a', status: 'paid', products: [{slug: 'gold'}]}; + const offTier = {id: 'b', status: 'paid', products: [{slug: 'silver'}]}; + assert.equal(checkPostAccess(post, onTier), true); + assert.equal(checkPostAccess(post, offTier), false); + }); + }); + describe('checkPostAccess', function () { let post; let member; diff --git a/ghost/core/test/unit/server/services/milestones/bookshelf-milestone-repository.test.js b/ghost/core/test/unit/server/services/milestones/bookshelf-milestone-repository.test.js index 92a8c61b0ba..c48994141a9 100644 --- a/ghost/core/test/unit/server/services/milestones/bookshelf-milestone-repository.test.js +++ b/ghost/core/test/unit/server/services/milestones/bookshelf-milestone-repository.test.js @@ -1,6 +1,6 @@ const assert = require('node:assert/strict'); -const models = require('../../../../../core/server/models'); +const {Milestone} = require('../../../../../core/server/models/milestone'); const DomainEvents = require('@tryghost/domain-events'); describe('BookshelfMilestoneRepository', function () { @@ -10,7 +10,7 @@ describe('BookshelfMilestoneRepository', function () { const BookshelfMilestoneRepository = require('../../../../../core/server/services/milestones/bookshelf-milestone-repository'); repository = new BookshelfMilestoneRepository({ DomainEvents, - MilestoneModel: models.Milestone + MilestoneModel: Milestone }); assert.ok(repository.save); diff --git a/ghost/core/test/unit/server/services/newsletters/service.test.js b/ghost/core/test/unit/server/services/newsletters/service.test.js index 528ab7f3956..3e7d6c9dc85 100644 --- a/ghost/core/test/unit/server/services/newsletters/service.test.js +++ b/ghost/core/test/unit/server/services/newsletters/service.test.js @@ -2,7 +2,8 @@ const sinon = require('sinon'); const assert = require('node:assert/strict'); // DI requirements -const models = require('../../../../../core/server/models'); +const {Newsletter} = require('../../../../../core/server/models/newsletter'); +const {Member} = require('../../../../../core/server/models/member'); const mail = require('../../../../../core/server/services/mail'); // Mocked utilities @@ -35,8 +36,8 @@ describe('NewslettersService', function () { }; newsletterService = new NewslettersService({ - NewsletterModel: models.Newsletter, - MemberModel: models.Member, + NewsletterModel: Newsletter, + MemberModel: Member, mail, singleUseTokenProvider: tokenProvider, urlUtils: urlUtils.stubUrlUtilsFromConfig(), @@ -87,7 +88,7 @@ describe('NewslettersService', function () { let findOneStub; beforeEach(function () { // Stub edit as a function that returns its first argument - findOneStub = sinon.stub(models.Newsletter, 'findOne').returns({get: getStub, id: 'test'}); + findOneStub = sinon.stub(Newsletter, 'findOne').returns({get: getStub, id: 'test'}); }); it('returns the result of findOne', async function () { @@ -106,7 +107,7 @@ describe('NewslettersService', function () { // @TODO replace this with a specific function for fetching all available newsletters describe('browse', function () { it('lists all newsletters by calling findPage', async function () { - const findAllStub = sinon.stub(models.Newsletter, 'findPage').returns({data: []}); + const findAllStub = sinon.stub(Newsletter, 'findPage').returns({data: []}); await newsletterService.browse({}); @@ -121,10 +122,10 @@ describe('NewslettersService', function () { subscribeStub = sinon.stub().returns(fakeMemberIds); // Stub add as a function that returns get & subscribeMembersById methods - addStub = sinon.stub(models.Newsletter, 'add').returns({get: getStub, id: 'test', subscribeMembersById: subscribeStub}); - fetchMembersStub = sinon.stub(models.Member, 'fetchAllSubscribed').returns([]); - getNextAvailableSortOrderStub = sinon.stub(models.Newsletter, 'getNextAvailableSortOrder').returns(1); - findOneStub = sinon.stub(models.Newsletter, 'findOne').returns({get: getStub, id: 'test', subscribeMembersById: subscribeStub}); + addStub = sinon.stub(Newsletter, 'add').returns({get: getStub, id: 'test', subscribeMembersById: subscribeStub}); + fetchMembersStub = sinon.stub(Member, 'fetchAllSubscribed').returns([]); + getNextAvailableSortOrderStub = sinon.stub(Newsletter, 'getNextAvailableSortOrder').returns(1); + findOneStub = sinon.stub(Newsletter, 'findOne').returns({get: getStub, id: 'test', subscribeMembersById: subscribeStub}); }); it('rejects if the limit services determines it would be over the limit', async function () { @@ -224,8 +225,8 @@ describe('NewslettersService', function () { let editStub, findOneStub; beforeEach(function () { // Stub edit as a function that returns its first argument - editStub = sinon.stub(models.Newsletter, 'edit').returns({get: getStub, id: 'test'}); - findOneStub = sinon.stub(models.Newsletter, 'findOne').returns({get: getStub, id: 'test'}); + editStub = sinon.stub(Newsletter, 'edit').returns({get: getStub, id: 'test'}); + findOneStub = sinon.stub(Newsletter, 'findOne').returns({get: getStub, id: 'test'}); }); it('rejects if called with no data', async function () { @@ -259,7 +260,7 @@ describe('NewslettersService', function () { let editStub; beforeEach(function () { - editStub = sinon.stub(models.Newsletter, 'edit').returns({get: getStub}); + editStub = sinon.stub(Newsletter, 'edit').returns({get: getStub}); sinon.assert.notCalled(editStub); }); diff --git a/ghost/core/test/unit/server/services/outbox/handlers/member-created.test.js b/ghost/core/test/unit/server/services/outbox/handlers/member-created.test.js index b30133ccf69..cfe5693882c 100644 --- a/ghost/core/test/unit/server/services/outbox/handlers/member-created.test.js +++ b/ghost/core/test/unit/server/services/outbox/handlers/member-created.test.js @@ -3,7 +3,8 @@ const sinon = require('sinon'); const {captureLoggerOutput, findByEvent} = require('../../../../../utils/logging-utils'); const handler = require('../../../../../../core/server/services/outbox/handlers/member-created.js'); const memberWelcomeEmailService = require('../../../../../../core/server/services/member-welcome-emails/service'); -const {Automation, AutomatedEmailRecipient} = require('../../../../../../core/server/models'); +const {Automation} = require('../../../../../../core/server/models/automation'); +const {AutomatedEmailRecipient} = require('../../../../../../core/server/models/automated-email-recipient'); describe('member-created handler', function () { let memberWelcomeEmailServiceSendStub; diff --git a/ghost/core/test/unit/server/services/post-scheduling/post-scheduling.test.js b/ghost/core/test/unit/server/services/post-scheduling/post-scheduling.test.js index dd3987d88a1..4f1e8727f61 100644 --- a/ghost/core/test/unit/server/services/post-scheduling/post-scheduling.test.js +++ b/ghost/core/test/unit/server/services/post-scheduling/post-scheduling.test.js @@ -2,7 +2,7 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); const moment = require('moment'); const testUtils = require('../../../../utils'); -const models = require('../../../../../core/server/models'); +const {Post} = require('../../../../../core/server/models/post'); const events = require('../../../../../core/server/lib/common/events'); const schedulingUtils = require('../../../../../core/server/adapters/scheduling/utils'); const SchedulingDefault = require('../../../../../core/server/adapters/scheduling/scheduling-default'); @@ -31,7 +31,7 @@ describe('PostScheduling', function () { describe('constructor', function () { it('wires event handlers and starts the adapter', async function () { - const post = models.Post.forge(testUtils.DataGenerator.forKnex.createPost({ + const post = Post.forge(testUtils.DataGenerator.forKnex.createPost({ id: 1337, mobiledoc: testUtils.DataGenerator.markdownToMobiledoc('something') })); @@ -58,11 +58,11 @@ describe('PostScheduling', function () { describe('rescheduleAll', function () { function stubScheduledPost() { - const post = models.Post.forge(testUtils.DataGenerator.forKnex.createPost({ + const post = Post.forge(testUtils.DataGenerator.forKnex.createPost({ id: 4004, mobiledoc: testUtils.DataGenerator.markdownToMobiledoc('something') })); - sinon.stub(models.Post, 'findAll').callsFake(({filter}) => { + sinon.stub(Post, 'findAll').callsFake(({filter}) => { return Promise.resolve(filter.includes('type:post') ? [post] : []); }); return post; diff --git a/ghost/core/test/unit/server/services/settings/settings-service.test.js b/ghost/core/test/unit/server/services/settings/settings-service.test.js index 3f4f9b980fc..9e93f2ec3ea 100644 --- a/ghost/core/test/unit/server/services/settings/settings-service.test.js +++ b/ghost/core/test/unit/server/services/settings/settings-service.test.js @@ -3,7 +3,7 @@ const assert = require('node:assert/strict'); const configUtils = require('../../../../utils/config-utils'); const settingsCache = require('../../../../../core/shared/settings-cache'); const logging = require('@tryghost/logging'); -const models = require('../../../../../core/server/models'); +const {Settings} = require('../../../../../core/server/models/settings'); const adapterManager = require('../../../../../core/server/services/adapter-manager'); const limits = require('../../../../../core/server/services/limits'); @@ -103,8 +103,8 @@ describe('UNIT: Settings Service', function () { configUtils.set('hostSettings:limits:customIntegrations:disabled', true); sinon.stub(adapterManager, 'getAdapter').withArgs('cache:settings').returns(cacheStore); - sinon.stub(models.Settings, 'populateDefaults').resolves(); - sinon.stub(models.Settings, 'findAll').resolves(settingsCollection); + sinon.stub(Settings, 'populateDefaults').resolves(); + sinon.stub(Settings, 'findAll').resolves(settingsCollection); const initStub = sinon.stub(settingsCache, 'init'); await settingsService.init(); @@ -126,8 +126,8 @@ describe('UNIT: Settings Service', function () { }); sinon.stub(adapterManager, 'getAdapter').withArgs('cache:settings').returns(cacheStore); - sinon.stub(models.Settings, 'populateDefaults').resolves(); - sinon.stub(models.Settings, 'findAll').resolves(settingsCollection); + sinon.stub(Settings, 'populateDefaults').resolves(); + sinon.stub(Settings, 'findAll').resolves(settingsCollection); const initStub = sinon.stub(settingsCache, 'init'); await settingsService.init(); @@ -148,13 +148,13 @@ describe('UNIT: Settings Service', function () { beforeEach(function () { sinon.stub(adapterManager, 'getAdapter').withArgs('cache:settings').returns({}); sinon.stub(settingsCache, 'init'); - sinon.stub(models.Settings, 'populateDefaults').resolves(); - sinon.stub(models.Settings, 'findAll').resolves({}); + sinon.stub(Settings, 'populateDefaults').resolves(); + sinon.stub(Settings, 'findAll').resolves({}); }); it('is a no-op when the limit is not disabled', async function () { sinon.stub(limits, 'isDisabled').withArgs('publicSiteAccess').returns(false); - const editStub = sinon.stub(models.Settings, 'edit').resolves(); + const editStub = sinon.stub(Settings, 'edit').resolves(); await settingsService.init(); @@ -163,10 +163,10 @@ describe('UNIT: Settings Service', function () { it('persists is_private = true and a generated access code when both are missing', async function () { sinon.stub(limits, 'isDisabled').withArgs('publicSiteAccess').returns(true); - const findOneStub = sinon.stub(models.Settings, 'findOne'); + const findOneStub = sinon.stub(Settings, 'findOne'); findOneStub.withArgs({key: 'is_private'}).resolves(fakeSettingRow(false)); findOneStub.withArgs({key: 'password'}).resolves(fakeSettingRow('')); - const editStub = sinon.stub(models.Settings, 'edit').resolves(); + const editStub = sinon.stub(Settings, 'edit').resolves(); await settingsService.init(); @@ -180,10 +180,10 @@ describe('UNIT: Settings Service', function () { it('only writes the missing values when one is already enforced', async function () { sinon.stub(limits, 'isDisabled').withArgs('publicSiteAccess').returns(true); - const findOneStub = sinon.stub(models.Settings, 'findOne'); + const findOneStub = sinon.stub(Settings, 'findOne'); findOneStub.withArgs({key: 'is_private'}).resolves(fakeSettingRow(true)); findOneStub.withArgs({key: 'password'}).resolves(fakeSettingRow('')); - const editStub = sinon.stub(models.Settings, 'edit').resolves(); + const editStub = sinon.stub(Settings, 'edit').resolves(); await settingsService.init(); @@ -194,10 +194,10 @@ describe('UNIT: Settings Service', function () { it('does not write when both values are already enforced', async function () { sinon.stub(limits, 'isDisabled').withArgs('publicSiteAccess').returns(true); - const findOneStub = sinon.stub(models.Settings, 'findOne'); + const findOneStub = sinon.stub(Settings, 'findOne'); findOneStub.withArgs({key: 'is_private'}).resolves(fakeSettingRow(true)); findOneStub.withArgs({key: 'password'}).resolves(fakeSettingRow('anchor042')); - const editStub = sinon.stub(models.Settings, 'edit').resolves(); + const editStub = sinon.stub(Settings, 'edit').resolves(); await settingsService.init(); @@ -206,10 +206,10 @@ describe('UNIT: Settings Service', function () { it('treats a whitespace-only access code as missing', async function () { sinon.stub(limits, 'isDisabled').withArgs('publicSiteAccess').returns(true); - const findOneStub = sinon.stub(models.Settings, 'findOne'); + const findOneStub = sinon.stub(Settings, 'findOne'); findOneStub.withArgs({key: 'is_private'}).resolves(fakeSettingRow(true)); findOneStub.withArgs({key: 'password'}).resolves(fakeSettingRow(' ')); - const editStub = sinon.stub(models.Settings, 'edit').resolves(); + const editStub = sinon.stub(Settings, 'edit').resolves(); await settingsService.init(); diff --git a/ghost/core/test/unit/server/services/webhooks/serialize.test.js b/ghost/core/test/unit/server/services/webhooks/serialize.test.js index 502d47c496a..17df05c2821 100644 --- a/ghost/core/test/unit/server/services/webhooks/serialize.test.js +++ b/ghost/core/test/unit/server/services/webhooks/serialize.test.js @@ -1,7 +1,8 @@ const assert = require('node:assert/strict'); const sinon = require('sinon'); -const models = require('../../../../../core/server/models'); +const {Post} = require('../../../../../core/server/models/post'); +const {Member} = require('../../../../../core/server/models/member'); const serialize = require('../../../../../core/server/services/webhooks/serialize'); @@ -60,7 +61,7 @@ describe('WebhookService - Serialize', function () { it('can serialize a new post', async function () { const post = fixtureManager.get('posts', 1); - const postModel = new models.Post(post); + const postModel = new Post(post); const result = await serialize('post.added', postModel); @@ -72,7 +73,7 @@ describe('WebhookService - Serialize', function () { it('can serialize an edited post', async function () { const post = fixtureManager.get('posts', 1); - const postModel = new models.Post(post); + const postModel = new Post(post); // We use both _previousAttributes and _changed in the webhook serializer postModel._previousAttributes.title = post.title; @@ -90,7 +91,7 @@ describe('WebhookService - Serialize', function () { it('can serialize reconstructed member.edited model event state', async function () { const previousUpdatedAt = new Date('2026-04-28T15:55:45.000Z'); const currentUpdatedAt = new Date('2026-05-29T00:00:00.000Z'); - const memberModel = new models.Member({ + const memberModel = new Member({ id: 'member-id', uuid: 'member-uuid', email: 'member@example.com', diff --git a/ghost/core/test/utils/vitest-setup-db.ts b/ghost/core/test/utils/vitest-setup-db.ts index 510f0fbca5d..47859135977 100644 --- a/ghost/core/test/utils/vitest-setup-db.ts +++ b/ghost/core/test/utils/vitest-setup-db.ts @@ -42,20 +42,46 @@ process.env.WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'TEST_STRIPE_WEBHOOK_ // instance. // // Each worker is its own process, so it gets its own database — that's what lets -// the DB suites run fork-parallel (PLA-156). The per-process sessionId is -// appended even to a CI-pinned *base*: the sqlite leg exports a single +// the DB suites run fork-parallel (PLA-156). The per-fork sessionId is appended +// even to a CI-pinned *base*: the sqlite leg exports a single // database__connection__filename=/dev/shm/ghost-test.db for the whole job, so // without a unique suffix every fork would hammer the same file. (The mysql leg // pins only host/port, so the database name is generated outright here.) -const sessionId = crypto.randomBytes(4).toString('hex'); +// +// sqlite names are keyed on VITEST_POOL_ID (1..poolSize, like the port below) so +// a run reuses ~poolSize stable files instead of leaving a fresh random DB in +// /tmp every run — that bounded reuse is what stops local /tmp accumulation. +// A reused file still holds the prior fork's data, though, and Ghost reads it at +// boot (settings cache, url service) before the suite resets — which corrupts +// whichever file lands on the slot (null Owner, stale URLs, bad export). So the +// file is deleted just below, before Ghost loads, so a reused slot boots from +// nothing exactly as a fresh name would. mysql keeps a random per-fork name: it +// has no /tmp to bound (CI databases die with the job) and a random name sidesteps +// the same stale-reuse hazard without a pre-boot DROP. (PLA-168) +const poolSlot = parseInt(process.env.VITEST_POOL_ID || '', 10); +const sqliteId = Number.isInteger(poolSlot) + ? `pool_${poolSlot}` + : crypto.randomBytes(4).toString('hex'); const sqliteBase = process.env.database__connection__filename; process.env.database__connection__filename = sqliteBase - ? `${sqliteBase.replace(/\.db$/i, '')}-${sessionId}.db` - : `/tmp/ghost-test-${sessionId}.db`; + ? `${sqliteBase.replace(/\.db$/i, '')}-${sqliteId}.db` + : `/tmp/ghost-test-${sqliteId}.db`; +const mysqlId = crypto.randomBytes(4).toString('hex'); const mysqlBase = process.env.database__connection__database; process.env.database__connection__database = mysqlBase - ? `${mysqlBase}_${sessionId}` - : `ghost_testing_${sessionId}`; + ? `${mysqlBase}_${mysqlId}` + : `ghost_testing_${mysqlId}`; + +// Delete this slot's leftover sqlite file (+ sidecars) before Ghost loads, so a +// reused pool name boots from a clean slate — see the note above. force:true +// makes it a no-op on first use and on the mysql leg (no such file). +for (const suffix of ['', '-journal', '-wal', '-shm', '-orig']) { + try { + require('fs').rmSync(process.env.database__connection__filename + suffix, {force: true}); + } catch (e) { + // best effort — a fresh boot recreates it + } +} // Flush this worker's V8 coverage after every file. The external c8 collector // reads NODE_V8_COVERAGE, which Node writes only on a clean process exit — but @@ -76,12 +102,13 @@ if (process.env.NODE_V8_COVERAGE) { }); } -// NOTE: each fork leaves its per-process DB behind (sqlite file / mysql db). -// vitest force-terminates forks (which is also why the forks-teardown deadlock -// doesn't bite), so a process 'exit' handler can't reclaim them. On CI both are -// ephemeral (the runner's /dev/shm and the mysql container die with the job); -// locally they accumulate under /tmp. Proper reclamation (a globalSetup teardown -// that sweeps the run's DBs) is tracked in PLA-156. +// NOTE: each fork still leaves a DB behind — vitest force-terminates forks (which +// is also why the forks-teardown deadlock doesn't bite), so a process 'exit' +// handler can't reclaim them. sqlite stays bounded: the next fork on a slot +// deletes the file at boot (see the derivation above) and recreates it, so a run +// reuses at most ~poolSize files in /tmp instead of leaving a fresh random one +// behind every run. mysql names are random per fork but ephemeral on CI (the +// container dies with the job); locally the mysql suite is rarely run. (PLA-168) const canonicalTestPort = 2369; // The per-fork port must be unique among forks running concurrently. Each test diff --git a/ghost/i18n/locales/fa/comments.json b/ghost/i18n/locales/fa/comments.json index 7f572b4fc90..8103b89ad42 100644 --- a/ghost/i18n/locales/fa/comments.json +++ b/ghost/i18n/locales/fa/comments.json @@ -1,88 +1,88 @@ { - "{amount} characters left": "{amount} حرف باقی\u200cمانده است", + "{amount} characters left": "{amount} حرف باقی مانده است", "{amount} comments": "{amount} دیدگاه", "{amount} hrs ago": "{amount} ساعت پیش", "{amount} mins ago": "{amount} دقیقه پیش", - "{amount} more": "{amount} بیشتر", - "1 comment": "یک دیدگاه", - "Add comment": "دیدگاهی بفرستید", - "Add context to your comment, share your name and expertise to foster a healthy discussion.": "به دیدگاه خود موضوعیت ببخشید و با به اشتراک\u200cگذاری نام و تخصص خود یک گفتمان سالم ایجاد کنید.", - "Add reply": "پاسخی بدهید", - "Add your expertise": "", + "{amount} more": "{amount} مورد دیگر", + "1 comment": "۱ دیدگاه", + "Add comment": "افزودن دیدگاه", + "Add context to your comment, share your name and expertise to foster a healthy discussion.": "به دیدگاه خود زمینه بدهید و با به\u200cاشتراک\u200cگذاری نام و تخصص خود، گفت\u200cوگوی سالمی ایجاد کنید.", + "Add reply": "افزودن پاسخ", + "Add your expertise": "افزودن تخصص شما", "Already a member?": "عضو هستید؟", "Anonymous": "ناشناس", - "Are you sure?": "", - "Back": "", - "Become a member of {publication} to start commenting.": "عضو وب\u200cسایت {publication} شوید تا مجوز ارسال دیدگاه دریافت کنید.", - "Become a paid member of {publication} to start commenting.": "حق اشتراک وب\u200cسایت {publication} را پرداخت کنید تا مجوز ارسال دیدگاه دریافت کنید.", - "Best": "", - "Cancel": "انصراف", + "Are you sure?": "مطمئن هستید؟", + "Back": "بازگشت", + "Become a member of {publication} to start commenting.": "برای شروع دیدگاه\u200cگذاری، عضو {publication} شوید.", + "Become a paid member of {publication} to start commenting.": "برای شروع دیدگاه\u200cگذاری، عضو ویژه {publication} شوید.", + "Best": "بهترین", + "Cancel": "لغو", "Comment": "دیدگاه", - "Commenting disabled": "", + "Commenting disabled": "دیدگاه\u200cگذاری غیرفعال است", "Complete your profile": "نمایه خود را کامل کنید", - "Delete": "پاک کردن", - "Deleted": "", - "Deleted member": "حساب پاک\u200cشده", - "Deleting": "", - "Discussion": "گفتمان", - "Dislike": "", + "Delete": "حذف", + "Deleted": "حذف شد", + "Deleted member": "عضو حذف\u200cشده", + "Deleting": "در حال حذف", + "Discussion": "گفت\u200cوگو", + "Dislike": "نپسندیدن", "Edit": "ویرایش", "Edit this comment": "ویرایش این دیدگاه", "edited": "ویرایش\u200cشده", - "Enter your name": "نام خود را بنویسید", + "Enter your name": "نام خود را وارد کنید", "Expertise": "تخصص", - "Founder @ Acme Inc": "مدیر عامل یک شرکت خیالی", - "Full-time parent": " خانه\u200cدار تمام وقت", - "Head of Marketing at Acme, Inc": "سرپرست بخش بازاریابی یک شرکت خیالی", - "Hidden for members": "", - "Hide comment": "مخفی کردن دیدگاه", + "Founder @ Acme Inc": "بنیان\u200cگذار @ Acme Inc", + "Full-time parent": "خانه\u200cدار تمام\u200cوقت", + "Head of Marketing at Acme, Inc": "مدیر بازاریابی Acme, Inc", + "Hidden for members": "برای اعضا پنهان است", + "Hide comment": "پنهان\u200cسازی دیدگاه", "Jamie Larson": "جیمی لارسن", - "Join the discussion": "به گفتمان بپیوندید", - "Just now": "پیوستن", - "Like": "", - "Load more ({amount})": "", + "Join the discussion": "به گفت\u200cوگو بپیوندید", + "Just now": "همین الان", + "Like": "پسندیدن", + "Load more ({amount})": "بارگذاری ({amount}) مورد دیگر", "Local resident": "ساکن محلی", - "Member discussion": "گفتمان کاربران", + "Member discussion": "گفت\u200cوگوی اعضا", "Name": "نام", "Neurosurgeon": "جراح مغز و اعصاب", - "Newest": "", - "Oldest": "", - "Once deleted, this comment can’t be recovered.": "", - "One hour ago": "یک ساعت پیش", - "One min ago": "یک دقیقه پیش", - "Pin comment": "", - "Pinned": "", - "Read more replies": "", - "Remove dislike": "", - "Remove like": "", - "removed": "", - "Replied to": "", - "Reply": "پاسخ دادن", - "Reply to": "", - "Reply to comment": "پاسخ دادن به دیدگاه", + "Newest": "جدیدترین", + "Oldest": "قدیمی\u200cترین", + "Once deleted, this comment can’t be recovered.": "پس از حذف، این دیدگاه قابل بازیابی نخواهد بود.", + "One hour ago": "۱ ساعت پیش", + "One min ago": "۱ دقیقه پیش", + "Pin comment": "سنجاق کردن دیدگاه", + "Pinned": "سنجاق\u200cشده", + "Read more replies": "خواندن پاسخ\u200cهای بیشتر", + "Remove dislike": "برگرداندن نپسندیدن", + "Remove like": "برگرداندن پسندیدن", + "removed": "حذف\u200cشده", + "Replied to": "پاسخ به", + "Reply": "پاسخ", + "Reply to": "پاسخ به", + "Reply to comment": "پاسخ به دیدگاه", "Report": "گزارش", "Report comment": "گزارش دیدگاه", - "Report this comment?": "می\u200cخواهید این دیدگاه را گزارش کنید؟", - "Save": "ذخیره کردن", - "See full discussion": "", + "Report this comment?": "این دیدگاه گزارش شود؟", + "Save": "ذخیره", + "See full discussion": "مشاهده گفت\u200cوگوی کامل", "Sending": "در حال ارسال", "Sent": "ارسال شد", - "Show {amount} more replies": "نمایش {amount} پاسخ بیشتر", - "Show 1 more reply": "نمایش یک دیدگاه بیشتر", + "Show {amount} more replies": "نمایش {amount} پاسخ دیگر", + "Show 1 more reply": "نمایش ۱ پاسخ دیگر", "Show comment": "نمایش دیدگاه", - "Sign in": "ورود به حساب", - "Sign up now": "ایجاد حساب", - "Sort by": "", - "Start the conversation": "گفت\u200cوگویی شروع کنید", - "The linked comment is no longer available.": "", - "This comment has been hidden.": "این دیدگاه مخفی شده است", - "This comment has been removed.": "این دیدگاه پاک شده است", - "Unpin": "", - "Unpin comment": "", - "Upgrade now": "ارتقاء دهید", - "View in admin": "", + "Sign in": "ورود", + "Sign up now": "همین حالا ثبت\u200c نام کنید", + "Sort by": "مرتب\u200cسازی بر اساس", + "Start the conversation": "گفت\u200cوگو را شروع کنید", + "The linked comment is no longer available.": "دیدگاه پیوندشده دیگر در دسترس نیست.", + "This comment has been hidden.": "این دیدگاه پنهان شده است.", + "This comment has been removed.": "این دیدگاه حذف شده است.", + "Unpin": "برداشتن سنجاق", + "Unpin comment": "برداشتن سنجاق دیدگاه", + "Upgrade now": "همین حالا ارتقا دهید", + "View in admin": "مشاهده در مدیریت", "Yesterday": "دیروز", - "You can't post comments in this publication.": "", - "You can't post comments in this publication. Contact support for more information.": "", - "Your request will be sent to the owner of this site.": "درخواست شما برای مدیر این وب\u200cسایت ارسال خواهد شد." + "You can't post comments in this publication.": "شما نمی\u200cتوانید در این نشریه دیدگاه بگذارید.", + "You can't post comments in this publication. Contact support for more information.": "شما نمی\u200cتوانید در این نشریه دیدگاه بگذارید. برای اطلاعات بیشتر با پشتیبانی تماس بگیرید.", + "Your request will be sent to the owner of this site.": "درخواست شما برای مالک این سایت ارسال خواهد شد." } diff --git a/ghost/i18n/locales/fa/ghost.json b/ghost/i18n/locales/fa/ghost.json index 7c814443eb9..e985d5ceadf 100644 --- a/ghost/i18n/locales/fa/ghost.json +++ b/ghost/i18n/locales/fa/ghost.json @@ -1,110 +1,110 @@ { - "{count} month_one": "", - "{count} month_other": "", - "{count} year_one": "", - "{count} year_other": "", + "{count} month_one": "{count} ماه", + "{count} month_other": "{count} ماه", + "{count} year_one": "{count} سال", + "{count} year_other": "{count} سال", "{date}": "{date}", - "{tierName} membership": "", + "{tierName} membership": "عضویت {tierName}", "All the best!": "با آرزوی بهترین\u200cها!", - "Become a paid member of {site} to get access to all premium content.": "", - "By {authors}": "", - "Comment": "", + "Become a paid member of {site} to get access to all premium content.": "برای دسترسی به تمام محتوای ویژه، عضو ویژه {site} شوید.", + "By {authors}": "توسط {authors}", + "Comment": "دیدگاه", "Complete signup for {siteTitle}!": "عضویت در {siteTitle} را تکمیل کنید!", - "Complete your sign up to {siteTitle}!": "عضویت خود را در {siteTitle} تکمیل کنید!", - "complimentary": "", - "Confirm email address": "تأیید آدرس ایمیل", - "Confirm signup": "تأیید عضویت", - "Confirm your email address": "آدرس ایمیل خود را تأیید کنید", - "Confirm your email update for {siteTitle}!": "به\u200cروزرسانی آدرس ایمیل خود را برای {siteTitle} تأیید کنید!", + "Complete your sign up to {siteTitle}!": "ثبت\u200c نام خود را در {siteTitle} تکمیل کنید!", + "complimentary": "اهدایی", + "Confirm email address": "تأیید نشانی ایمیل", + "Confirm signup": "تأیید ثبت\u200c نام", + "Confirm your email address": "نشانی ایمیل خود را تأیید کنید", + "Confirm your email update for {siteTitle}!": "به\u200cروزرسانی ایمیل خود را برای {siteTitle} تأیید کنید!", "Confirm your subscription to {siteTitle}": "اشتراک خود را در {siteTitle} تأیید کنید", - "Continue subscription": "", - "Device:": "", - "Email": "", - "For security verification, enter the code below to sign in to {siteTitle}:": "", - "For your security, the link will expire in 24 hours time.": "به خاطر حفظ امنیت شما، این پیوند بعد از ۲۴ ساعت منقضی می\u200cشود. ", - "free": "", - "Gift subscription": "", - "Here's how to keep your {tierName} membership.": "", - "Here's your code to login to {siteTitle}": "", + "Continue subscription": "ادامه اشتراک", + "Device:": "دستگاه:", + "Email": "ایمیل", + "For security verification, enter the code below to sign in to {siteTitle}:": "برای تأیید امنیتی، کد زیر را برای ورود به {siteTitle} وارد کنید:", + "For your security, the link will expire in 24 hours time.": "برای حفظ امنیت شما، این پیوند پس از ۲۴ ساعت منقضی خواهد شد.", + "free": "رایگان", + "Gift subscription": "اشتراک هدیه", + "Here's how to keep your {tierName} membership.": "این روشی است که می\u200cتوانید اشتراک {tierName} خود را حفظ کنید.", + "Here's your code to login to {siteTitle}": "این کد ورود شما به {siteTitle} است", "Hey there,": "سلام،", "Hey there!": "سلام!", - "Hi {firstName},": "", - "If you did not make this request, you can safely ignore this email.": "در صورتی که شما این درخواست را نداده\u200cاید، می\u200cتوانید با خیال راحت از آن چشم\u200cپوشی کنید.", - "If you did not make this request, you can simply delete this message.": "در صورتی که شما این درخواست را نداده\u200cاید، می\u200cتوانید با خیال راحت این پیام را پاک کنید.", - "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "", - "Keep reading": "", - "Less like this": "", - "Manage subscription": "", - "Manage your preferences": "", - "Member since": "", - "More like this": "", - "Name": "", - "New comment on {postTitle}": "", - "New reply to your comment on {siteTitle}": "", - "one-month": "", - "one-year": "", - "Open this link to redeem your gift.": "", - "Or use this link to securely sign in": "", - "Or, skip the code and sign in directly": "", - "paid": "", - "Please confirm your email address with this link:": "خواهشمند است از طریق این پیوند آدرس ایمیل خود را تأیید کنید:", - "Redeem your gift subscription": "", - "Secure sign in link for {siteTitle}": "پیوند امن ورود به وب\u200cسایت {siteTitle}", - "See you soon!": "به زودی می\u200cبینمت!", - "Sent to {email}": "ارسال شده به {email} ", - "Share": "", + "Hi {firstName},": "{firstName} عزیز،", + "If you did not make this request, you can safely ignore this email.": "اگر شما این درخواست را نداده\u200cاید، می\u200cتوانید با خیال راحت این ایمیل را نادیده بگیرید.", + "If you did not make this request, you can simply delete this message.": "اگر شما این درخواست را نداده\u200cاید، می\u200cتوانید به سادگی این پیام را حذف کنید.", + "If you didn't try to sign in recently, you can safely ignore this email to deny access.": "اگر اخیراً تلاشی برای ورود نکرده\u200cاید، می\u200cتوانید با خیال راحت این ایمیل را نادیده بگیرید تا دسترسی رد شود.", + "Keep reading": "ادامه مطالعه", + "Less like this": "کمتر از این موارد", + "Manage subscription": "مدیریت اشتراک", + "Manage your preferences": "مدیریت تنظیمات شما", + "Member since": "عضو از", + "More like this": "بیشتر از این موارد", + "Name": "نام", + "New comment on {postTitle}": "دیدگاه جدید در {postTitle}", + "New reply to your comment on {siteTitle}": "پاسخ جدید به دیدگاه شما در {siteTitle}", + "one-month": "یک\u200cماهه", + "one-year": "یک\u200cساله", + "Open this link to redeem your gift.": "برای فعال\u200cسازی هدیه خود، این پیوند را باز کنید.", + "Or use this link to securely sign in": "یا از این پیوند برای ورود امن استفاده کنید", + "Or, skip the code and sign in directly": "یا از کد صرف\u200cنظر کنید و مستقیماً وارد شوید", + "paid": "ویژه", + "Please confirm your email address with this link:": "لطفاً نشانی ایمیل خود را با این پیوند تأیید کنید:", + "Redeem your gift subscription": "اشتراک هدیه خود را فعال کنید", + "Secure sign in link for {siteTitle}": "پیوند ورود امن به {siteTitle}", + "See you soon!": "به\u200cزودی می\u200cبینمتان!", + "Sent to {email}": "ارسال شده به {email}", + "Share": "اشتراک\u200cگذاری", "Sign in": "ورود", - "Sign in now": "", - "Sign in to {siteTitle}": "ورود به وب\u200cسایت {siteTitle}", - "Sign in to {siteTitle} with code {otc}": "", - "Sign in verification": "", - "Someone just replied to your comment": "", - "Someone just replied to your comment on {postTitle}.": "", - "Subscription details": "", - "Support {siteTitle}": "", - "Tap the link below to complete the signup process for {siteTitle}, and be automatically signed in:": "برای تکمیل عضویت خود برای وب\u200cسایت {siteTitle} برروی پیوند زیر کلیک کنید، پس از آن شما به صورت خودکار وارد حساب خود خواهید شد:", - "Thank you for signing up to {siteTitle}!": "با سپاس از عضویت شما برای وب\u200cسایت {siteTitle}!", - "Thank you for subscribing to {siteTitle}!": "با سپاس از مشترک شدن شما به برای وب\u200cسایت {siteTitle}!", - "Thank you for subscribing to {siteTitle}.": "با سپاس از مشترک شدن شما به برای وب\u200cسایت {siteTitle}.", - "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "با سپاس از مشترک شدن شما به برای وب\u200cسایت {siteTitle}. برروی لینک زیر کلیک کنید تا به صورت خودکار به حساب خود وارد شوید:", - "Thank you for supporting {siteTitle}.": "", - "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "", - "Thanks for reading {siteTitle}.": "", - "The link expires on {expiresAt} and can only be redeemed once.": "", - "This email address will not be used.": "این آدرس ایمیل جایی استفاده نخواهد شد.", - "This message was sent from {siteDomain} to {email}.": "", - "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "", - "trialing": "", - "Unsubscribe": "", - "Unsubscribe from comment reply notifications": "", - "Upgrade": "", - "Upgrade to continue reading.": "", - "View comments": "", - "View in browser": "", - "Welcome back to {siteTitle}!": "بازگشت شما را به {siteTitle} خوش آمد می\u200cگوییم!", - "Welcome back to {siteTitle}! Your verification code is {otc}.": "", - "Welcome back! Here's your code to sign in to {siteTitle}": "", - "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "بازگشتتان را خوش\u200cآمد می\u200cگوییم! از این پیوند برای ورود امن به حساب کاربری وب\u200cسایت {siteTitle} استفاده کنید:", - "When:": "", - "Where:": "", - "You are receiving this because you are a %%{status}%% subscriber to {site}.": "", - "You can also copy & paste this URL into your browser:": "همچنین می\u200cتوانید این پیوند را کپی و در مرورگر خود استفاده کنید:", - "You can unsubscribe from these notifications at {profileUrl}.": "", - "You just tried to access your account from a new device.": "", - "You will not be signed up, and no account will be created for you.": "شما عضویتی نخواهید داشت و هیچ حسابی برای شما ایجاد نخواهد شد.", + "Sign in now": "همین حالا وارد شوید", + "Sign in to {siteTitle}": "ورود به {siteTitle}", + "Sign in to {siteTitle} with code {otc}": "ورود به {siteTitle} با کد {otc}", + "Sign in verification": "تأیید ورود", + "Someone just replied to your comment": "کسی به دیدگاه شما پاسخ داد", + "Someone just replied to your comment on {postTitle}.": "کسی به دیدگاه شما در {postTitle} پاسخ داد.", + "Subscription details": "جزئیات اشتراک", + "Support {siteTitle}": "حمایت از {siteTitle}", + "Tap the link below to complete the signup process for {siteTitle}, and be automatically signed in:": "برای تکمیل فرایند ثبت\u200c نام در {siteTitle} روی پیوند زیر بزنید تا به\u200cطور خودکار وارد شوید:", + "Thank you for signing up to {siteTitle}!": "از ثبت\u200c نام شما در {siteTitle} سپاسگزاریم!", + "Thank you for subscribing to {siteTitle}!": "از مشترک شدن شما در {siteTitle} سپاسگزاریم!", + "Thank you for subscribing to {siteTitle}.": "از مشترک شدن شما در {siteTitle} سپاسگزاریم.", + "Thank you for subscribing to {siteTitle}. Tap the link below to be automatically signed in:": "از مشترک شدن شما در {siteTitle} سپاسگزاریم. برای ورود خودکار، پیوند زیر را باز کنید:", + "Thank you for supporting {siteTitle}.": "از حمایت شما از {siteTitle} سپاسگزاریم.", + "Thank you for your support. Share the link below with whoever you'd like to gift them a {cadenceLabel} {tierName} membership to {siteTitle}.": "از حمایت شما سپاسگزاریم. پیوند زیر را با هر کسی که می\u200cخواهید یک اشتراک {tierName} با {cadenceLabel} از {siteTitle} به او هدیه دهید، به اشتراک بگذارید.", + "Thanks for reading {siteTitle}.": "از مطالعه {siteTitle} سپاسگزاریم.", + "The link expires on {expiresAt} and can only be redeemed once.": "این پیوند در {expiresAt} منقضی می\u200cشود و فقط یک\u200cبار قابل استفاده است.", + "This email address will not be used.": "این نشانی ایمیل استفاده نخواهد شد.", + "This message was sent from {siteDomain} to {email}.": "این پیام از {siteDomain} به {email} ارسال شده است.", + "To keep your {tierName} membership, continue with a paid subscription today and we'll automatically add the rest of your gift period as a free trial.": "برای حفظ عضویت {tierName} خود، امروز با یک اشتراک ویژه ادامه دهید تا باقی دوره هدیه شما به\u200cطور خودکار به دوره رایگان اضافه شود.", + "trialing": "آزمایشی", + "Unsubscribe": "لغو اشتراک", + "Unsubscribe from comment reply notifications": "لغو اشتراک اعلان\u200cهای پاسخ به دیدگاه", + "Upgrade": "ارتقاء", + "Upgrade to continue reading.": "برای ادامه مطالعه، ارتقاء دهید.", + "View comments": "مشاهده دیدگاه\u200cها", + "View in browser": "مشاهده در مرورگر", + "Welcome back to {siteTitle}!": "بازگشت شما را به {siteTitle} خوش\u200cآمد می\u200cگوییم!", + "Welcome back to {siteTitle}! Your verification code is {otc}.": "بازگشت شما را به {siteTitle} خوش\u200cآمد می\u200cگوییم! کد تأیید شما {otc} است.", + "Welcome back! Here's your code to sign in to {siteTitle}": "بازگشتتان خوش\u200cآمد! این کد شما برای ورود به {siteTitle} است", + "Welcome back! Use this link to securely sign in to your {siteTitle} account:": "بازگشتتان خوش\u200cآمد! از این پیوند برای ورود امن به حساب کاربری {siteTitle} خود استفاده کنید:", + "When:": "زمان:", + "Where:": "مکان:", + "You are receiving this because you are a %%{status}%% subscriber to {site}.": "این ایمیل را دریافت کرده\u200cاید چون مشترک %%{status}%% {site} هستید.", + "You can also copy & paste this URL into your browser:": "همچنین می\u200cتوانید این نشانی را کپی و در مرورگر خود جای\u200cگذاری کنید:", + "You can unsubscribe from these notifications at {profileUrl}.": "می\u200cتوانید دریافت این اعلان\u200cها را از {profileUrl} لغو کنید.", + "You just tried to access your account from a new device.": "شما به\u200cتازگی تلاش کرده\u200cاید از دستگاه جدیدی به حساب خود دسترسی پیدا کنید.", + "You will not be signed up, and no account will be created for you.": "شما ثبت\u200c نام نخواهید شد و هیچ حساب کاربری برای شما ایجاد نخواهد شد.", "You will not be subscribed.": "شما مشترک نخواهید شد.", - "You're one tap away from subscribing to {siteTitle} — please confirm your email address with this link:": "شما در یک قدمی دریافت اشتراک وب\u200cسایت {siteTitle} هستید - خواهشمند است آدرس ایمیل خود را از طریق این لینک تأیید کنید:", - "You're one tap away from subscribing to {siteTitle}!": "شما در یک قدمی دریافت اشتراک وب\u200cسایت {siteTitle} هستید!", - "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "", - "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "", - "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "", - "Your gift is ready": "", - "Your gift subscription confirmation": "", - "Your gift subscription is ending soon": "", - "Your gift subscription to {siteTitle} ends on {consumesAt}.": "", - "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "", - "Your subscription has expired.": "", - "Your subscription will expire on {date}.": "", - "Your subscription will renew on {date}.": "", - "Your verification code for {siteTitle}": "" + "You're one tap away from subscribing to {siteTitle} — please confirm your email address with this link:": "شما یک کلیک با مشترک شدن در {siteTitle} فاصله دارید — لطفاً نشانی ایمیل خود را با این پیوند تأیید کنید:", + "You're one tap away from subscribing to {siteTitle}!": "شما یک کلیک با مشترک شدن در {siteTitle} فاصله دارید!", + "You've been gifted a {duration}-month {tierName} membership to {siteTitle}": "یک اشتراک {duration}-ماهه {tierName} از {siteTitle} به شما هدیه داده شده است", + "You've been gifted a {duration}-year {tierName} membership to {siteTitle}": "یک اشتراک {duration}-ساله {tierName} از {siteTitle} به شما هدیه داده شده است", + "Your free trial ends on {date}, at which time you will be charged the regular price. You can always cancel before then.": "دوره رایگان شما در تاریخ {date} به پایان می\u200cرسد و در آن زمان مبلغ عادی از شما کسر خواهد شد. همیشه می\u200cتوانید پیش از آن لغو کنید.", + "Your gift is ready": "هدیه شما آماده است", + "Your gift subscription confirmation": "تأیید اشتراک هدیه شما", + "Your gift subscription is ending soon": "اشتراک هدیه شما به\u200cزودی به پایان می\u200cرسد", + "Your gift subscription to {siteTitle} ends on {consumesAt}.": "اشتراک هدیه شما در {siteTitle} در تاریخ {consumesAt} به پایان می\u200cرسد.", + "Your subscription has been canceled and will expire on {date}. You can resume your subscription via your account settings.": "اشتراک شما لغو شده است و در تاریخ {date} منقضی خواهد شد. می\u200cتوانید از طریق تنظیمات حساب کاربری خود اشتراک را از سر بگیرید.", + "Your subscription has expired.": "اشتراک شما منقضی شده است.", + "Your subscription will expire on {date}.": "اشتراک شما در تاریخ {date} منقضی خواهد شد.", + "Your subscription will renew on {date}.": "اشتراک شما در تاریخ {date} تمدید خواهد شد.", + "Your verification code for {siteTitle}": "کد تأیید شما برای {siteTitle}" } diff --git a/ghost/i18n/locales/fa/portal.json b/ghost/i18n/locales/fa/portal.json index 0e0eaa41941..e8e79118b85 100644 --- a/ghost/i18n/locales/fa/portal.json +++ b/ghost/i18n/locales/fa/portal.json @@ -1,301 +1,301 @@ { - "(save {highestYearlyDiscount}%)": "{highestYearlyDiscount}٪ تخفیف", + "(save {highestYearlyDiscount}%)": "(تا {highestYearlyDiscount}٪ تخفیف)", "{amount} days free": "{amount} روز رایگان", "{amount} off": "{amount} تخفیف", - "{amount} off for first {number} months.": "{amount} تخفیف برای اولین {number} ماه.", - "{amount} off for first {period}.": "{amount} تخقیف برای اولین {period}.", - "{amount} off forever.": "{amount} تخفیف برای همیشه.", + "{amount} off for first {number} months.": "{amount} تخفیف برای {number} ماه اول.", + "{amount} off for first {period}.": "{amount} تخفیف برای اولین {period}.", + "{amount} off forever.": "برای همیشه {amount} تخفیف.", "{discount}% discount": "{discount}٪ تخفیف", - "{memberEmail} will no longer receive {newsletterName} newsletter.": "آدرس {memberEmail} در آینده خبرنامه\u200cی {newsletterName} را دریافت نخواهد کرد.", - "{memberEmail} will no longer receive emails when someone replies to your comments.": "آدرس {memberEmail} دیگر زمانی که کسی به کامنت شما پاسخی بدهد، ایمیلی دریافت نخواهد کرد.", - "{memberEmail} will no longer receive this newsletter.": "آدرس {memberEmail} دیگر این خبرنامه را دریافت نخواهد کرد.", - "{memberEmail} will no longer receive updates & announcements.": "", - "{months} months": "", - "{months} months free": "", - "{tierName} membership": "", + "{memberEmail} will no longer receive {newsletterName} newsletter.": "{memberEmail} دیگر خبرنامه {newsletterName} را دریافت نخواهد کرد.", + "{memberEmail} will no longer receive emails when someone replies to your comments.": "{memberEmail} دیگر زمانی که کسی به دیدگاه\u200cهای شما پاسخ دهد، ایمیلی دریافت نخواهد کرد.", + "{memberEmail} will no longer receive this newsletter.": "{memberEmail} دیگر این خبرنامه را دریافت نخواهد کرد.", + "{memberEmail} will no longer receive updates & announcements.": "{memberEmail} دیگر به\u200cروزرسانی\u200cها و اطلاعیه\u200cها را دریافت نخواهد کرد.", + "{months} months": "{months} ماه", + "{months} months free": "{months} ماه رایگان", + "{tierName} membership": "اشتراک {tierName}", "{trialDays} days free": "{trialDays} روز رایگان", - "{years} years": "", - "+1 (123) 456-7890": "", - "1 month": "", - "1 month free": "", - "1 year": "", - "A gift, just for you": "", - "Access your RSS feeds": "", + "{years} years": "{years} سال", + "+1 (123) 456-7890": "+1 (۱۲۳) ۴۵۶-۷۸۹۰", + "1 month": "۱ ماه", + "1 month free": "۱ ماه رایگان", + "1 year": "۱ سال", + "A gift, just for you": "هدیه\u200cای فقط برای شما", + "Access your RSS feeds": "به فیدهای RSS خود دسترسی پیدا کنید", "Account": "حساب کاربری", - "Account details updated successfully": "", + "Account details updated successfully": "جزئیات حساب کاربری با موفقیت به\u200cروزرسانی شد", "Account settings": "تنظیمات حساب کاربری", - "Add a personal note": "", - "After a free trial ends, you will be charged the regular price for the tier you've chosen. You can always cancel before then.": "پس از این\u200cکه دوره رایکان شما پایان یابد، براساس بسته\u200cی انتخابی شما مبلغی از حساب شما برداشت می\u200cشود. شما همیشه می\u200cتوانید قبل از آن تاریخ، بسته\u200cی خود را تغییر و یا لغو کنید.", + "Add a personal note": "افزودن یادداشت شخصی", + "After a free trial ends, you will be charged the regular price for the tier you've chosen. You can always cancel before then.": "پس از پایان دوره رایگان، مبلغ عادی سطحی که انتخاب کرده\u200cاید از شما کسر خواهد شد. همیشه می\u200cتوانید پیش از آن لغو کنید.", "Already a member?": "عضو هستید؟", - "An error occurred": "", - "An unexpected error occured. Please try again or contact support if the error persists.": "خطایی غیرمنتظره رخ داد. خواهشمند است که دوباره تلاش کنید و یا با پشتیبانی در صورتی که خطا ادامه\u200cدار بود تماس بگیرید.", + "An error occurred": "خطایی رخ داد", + "An unexpected error occured. Please try again or contact support if the error persists.": "خطای غیرمنتظره\u200cای رخ داد. لطفاً دوباره تلاش کنید یا در صورت ادامه خطا، با پشتیبانی تماس بگیرید.", "Back": "بازگشت", - "Back to Log in": "بازگشت به برگه ورود", - "Before you go": "", - "Billing info & receipts": "", + "Back to Log in": "بازگشت به ورود", + "Before you go": "پیش از رفتن", + "Billing info & receipts": "اطلاعات صورتحساب و رسیدها", "Black Friday": "جمعه سیاه", - "Bluesky": "", + "Bluesky": "بلواسکای", "Cancel anytime.": "در هر زمان قابل لغو است.", "Cancel subscription": "لغو اشتراک", - "Canceled": "", - "Cancellation reason": "دلیل لغو اشتراکتان چیست؟", + "Canceled": "لغو شد", + "Cancellation reason": "دلیل لغو", "Change": "تغییر", - "Change plan": "", - "Check spam & promotions folders": "پوشه اسپم و یا تبلیغات خود را بررسی کنید", - "Check with your mail provider": "موضوع را با ارائه\u200cدهنده ایمیل خود بررسی کنید", - "Check your inbox to verify email update": "", + "Change plan": "تغییر بسته", + "Check spam & promotions folders": "پوشه هرزنامه و تبلیغات را بررسی کنید", + "Check with your mail provider": "با ارائه\u200cدهنده ایمیل خود بررسی کنید", + "Check your inbox to verify email update": "برای تأیید به\u200cروزرسانی ایمیل، صندوق ورودی خود را بررسی کنید", "Choose": "انتخاب", - "Choose a different plan": "بسته\u200cای دیگر انتخاب کنید", - "Choose a plan": "", - "Choose your newsletters": "خبرنامه\u200cی خود را انتخاب کنید", - "Click here to retry": "برای تلاش دوباره اینجا را کلیک کنید", - "Click the confirmation link in your inbox to finish redeeming your membership. If it doesn't arrive within 3 minutes, check your spam folder.": "", + "Choose a different plan": "بسته دیگری انتخاب کنید", + "Choose a plan": "یک بسته انتخاب کنید", + "Choose your newsletters": "خبرنامه\u200cهای خود را انتخاب کنید", + "Click here to retry": "برای تلاش دوباره اینجا بزنید", + "Click the confirmation link in your inbox to finish redeeming your membership. If it doesn't arrive within 3 minutes, check your spam folder.": "برای تکمیل فعال\u200cسازی اشتراک خود، روی پیوند تأیید در صندوق ورودی بزنید. اگر تا ۳ دقیقه نرسید، پوشه هرزنامه را بررسی کنید.", "Close": "بستن", - "Code": "", - "Comment preferences updated.": "", + "Code": "کد", + "Comment preferences updated.": "تنظیمات دیدگاه به\u200cروزرسانی شد.", "Comments": "دیدگاه\u200cها", - "Complimentary": "تعریف\u200cها", + "Complimentary": "هدیه", "Confirm": "تأیید", "Confirm cancellation": "تأیید لغو", "Confirm subscription": "تأیید اشتراک", "Contact support": "تماس با پشتیبانی", - "Continue": "ادامه دادن", + "Continue": "ادامه", "Continue subscription": "ادامه اشتراک", - "Continue with a paid subscription anytime. Your remaining gift period will be added as a free trial.": "", - "Copied": "", - "Copy": "", - "Copy link": "", - "Could not create Stripe billing portal session": "", - "Could not create Stripe checkout session": "", - "Could not sign in. Login link expired.": "امکان ورود وجود نداشت، پیوند ورود منقضی شده است.", - "Could not update email! Invalid link.": "امکان به\u200cروزرسانی ایمیل وجود نداشت! پیوند اشتباه بود.", - "Create a new contact": "مخاطبی تازه بسازید", - "Current plan": "بسته\u200cی فعلی", - "Delete account": "پاک کردن حساب", - "Didn't mean to do this? Manage your preferences .": "آیا از این کار اطمینان دارید؟ شما می\u200cتوانید تنظیمات خود را از تغییر دهید.", + "Continue with a paid subscription anytime. Your remaining gift period will be added as a free trial.": "هر زمان که یک اشتراک ویژه تهیه کنید. مدت زمان باقی\u200cمانده دوره هدیه شما به عنوان دوره آزمایشی رایگان به اشتراکتان اضافه خواهد شد.", + "Copied": "کپی شد", + "Copy": "کپی", + "Copy link": "کپی پیوند", + "Could not create Stripe billing portal session": "ایجاد نشست پورتال صورتحساب Stripe ناموفق بود", + "Could not create Stripe checkout session": "ایجاد نشست پرداخت Stripe ناموفق بود", + "Could not sign in. Login link expired.": "ورود انجام نشد. پیوند ورود منقضی شده است.", + "Could not update email! Invalid link.": "به\u200cروزرسانی ایمیل انجام نشد! پیوند نامعتبر است.", + "Create a new contact": "ایجاد مخاطب جدید", + "Current plan": "بسته فعلی", + "Delete account": "حذف حساب کاربری", + "Didn't mean to do this? Manage your preferences .": "از این کار منصرف شدید؟ تنظیمات خود را از مدیریت کنید.", "Don't have an account?": "حساب کاربری ندارید؟", "Edit": "ویرایش", "Email": "ایمیل", - "Email confirmation link expired.": "", - "Email newsletter": "ایمیل خبرنامه", - "Email newsletter settings updated": "", + "Email confirmation link expired.": "پیوند تأیید ایمیل منقضی شده است.", + "Email newsletter": "خبرنامه ایمیلی", + "Email newsletter settings updated": "تنظیمات خبرنامه ایمیلی به\u200cروزرسانی شد", "Email preferences": "تنظیمات ایمیل", - "Email preferences updated.": "", + "Email preferences updated.": "تنظیمات ایمیل به\u200cروزرسانی شد.", "Emails": "ایمیل\u200cها", "Emails disabled": "ایمیل\u200cها غیرفعال هستند", - "Ends {offerEndDate}": "در {offerEndDate} تمام می\u200cشود", - "Enjoy {amountOff} off forever.": "", - "Enjoy {months} free months on us.": "", - "Enjoy {months} free months on us. You won't be charged until {newBillingDate}.": "", - "Enjoy a free month on us.": "", - "Enjoy a free month on us. You won't be charged until {newBillingDate}.": "", - "Enter code above": "", - "Enter your email address": "", - "Enter your name": "", + "Ends {offerEndDate}": "در {offerEndDate} پایان می\u200cیابد", + "Enjoy {amountOff} off forever.": "برای همیشه از {amountOff} تخفیف بهره\u200cمند شوید.", + "Enjoy {months} free months on us.": "از {months} ماه رایگان ما بهره\u200cمند شوید.", + "Enjoy {months} free months on us. You won't be charged until {newBillingDate}.": "از {months} ماه رایگان ما بهره\u200cمند شوید. تا {newBillingDate} از شما وجهی کسر نخواهد شد.", + "Enjoy a free month on us.": "از یک ماه رایگان ما بهره\u200cمند شوید.", + "Enjoy a free month on us. You won't be charged until {newBillingDate}.": "از یک ماه رایگان ما بهره\u200cمند شوید. تا {newBillingDate} از شما وجهی کسر نخواهد شد.", + "Enter code above": "کد را در بالا وارد کنید", + "Enter your email address": "نشانی ایمیل خود را وارد کنید", + "Enter your name": "نام خود را وارد کنید", "Error": "خطا", "Expires {expiryDate}": "در {expiryDate} منقضی می\u200cشود", - "Facebook": "", - "Failed to cancel subscription, please try again": "", - "Failed to log in, please try again": "", - "Failed to log out, please try again": "", - "Failed to open billing portal, please try again": "", - "Failed to process checkout, please try again": "", - "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 verify code, please try again": "", + "Facebook": "فیس\u200cبوک", + "Failed to cancel subscription, please try again": "لغو اشتراک ناموفق بود، لطفاً دوباره تلاش کنید", + "Failed to log in, please try again": "ورود ناموفق بود، لطفاً دوباره تلاش کنید", + "Failed to log out, please try again": "خروج ناموفق بود، لطفاً دوباره تلاش کنید", + "Failed to open billing portal, please try again": "باز کردن پورتال صورتحساب ناموفق بود، لطفاً دوباره تلاش کنید", + "Failed to process checkout, please try again": "پردازش پرداخت ناموفق بود، لطفاً دوباره تلاش کنید", + "Failed to send magic link email": "ارسال ایمیل پیوند جادویی ناموفق بود", + "Failed to send verification email": "ارسال ایمیل تأیید ناموفق بود", + "Failed to sign up, please try again": "ثبت\u200c نام ناموفق بود، لطفاً دوباره تلاش کنید", + "Failed to update account data": "به\u200cروزرسانی داده\u200cهای حساب کاربری ناموفق بود", + "Failed to update account details": "به\u200cروزرسانی جزئیات حساب کاربری ناموفق بود", + "Failed to update billing information, please try again": "به\u200cروزرسانی اطلاعات صورتحساب ناموفق بود، لطفاً دوباره تلاش کنید", + "Failed to update newsletter settings": "به\u200cروزرسانی تنظیمات خبرنامه ناموفق بود", + "Failed to update subscription, please try again": "به\u200cروزرسانی اشتراک ناموفق بود، لطفاً دوباره تلاش کنید", + "Failed to verify code, please try again": "تأیید کد ناموفق بود، لطفاً دوباره تلاش کنید", "Forever": "برای همیشه", - "Free Trial – Ends {trialEnd}": "دوره رایگان - بعد از {trialEnd} تمام می\u200cشود", - "Get help": "پشتیبانی بگیرید", - "Get in touch for help": "برای دریافت پشتیبانی تماس بگیرید", - "Get notified when someone replies to your comment": "زمانی که کسی به دیدگاه شما پاسخی می\u200cدهد، اعلان دریافت کنید", - "Gift a membership": "", - "Gift could not be redeemed": "", - "Gift details": "", - "Gift redeemed! You're all set.": "", - "Gift subscription": "", - "Gift subscriptions are not available right now.": "", - "Gift value": "", - "Give feedback on this post": "بازخوردی به این نوشته بدهید", + "Free Trial – Ends {trialEnd}": "دوره رایگان — پس از {trialEnd} پایان می\u200cیابد.", + "Get help": "کمک بگیرید", + "Get in touch for help": "برای دریافت کمک تماس بگیرید", + "Get notified when someone replies to your comment": "وقتی کسی به دیدگاه شما پاسخ داد، اعلان دریافت کنید", + "Gift a membership": "اشتراک هدیه دهید", + "Gift could not be redeemed": "هدیه فعال نشد", + "Gift details": "جزئیات هدیه", + "Gift redeemed! You're all set.": "هدیه فعال شد! همه\u200cچیز آماده است.", + "Gift subscription": "اشتراک هدیه", + "Gift subscriptions are not available right now.": "اشتراک هدیه در حال حاضر در دسترس نیست.", + "Gift value": "ارزش هدیه", + "Give feedback on this post": "بازخوردی درباره این نوشته بدهید", "Help! I'm not receiving emails": "کمک! من ایمیلی دریافت نمی\u200cکنم", - "Here are a few other sites you may enjoy.": "وب\u200cسایت\u200cهای که ممکن است شما بپسندید.", - "Hide details": "", - "If a newsletter is flagged as spam, emails are automatically disabled for that address to make sure you no longer receive any unwanted messages.": "اگر کاربری خبرنامه را به عنوان اسپم معرفی کند، ایمیل\u200cهای خبرنامه به صورت خودکار دیگر به آن آدرس ارسال نخواهند شد تا مطمئن شویم هیچ کسی ایمیل ناخواسته\u200cای دریافت نمی\u200cکند. ", - "If the spam complaint was accidental, or you would like to begin receiving emails again, you can resubscribe to emails by clicking the button on the previous screen.": "در صورتی که گزارش اسپم ارسالی اتفاقی بوده، یا این که تمایل داشتید که دوباره ایمیل\u200cها را دریافت کنید، می\u200cتوانید دوباره با کلیک برروی گزینه دریافت اشتراک در برگه قبلی ایمیل\u200cها را دریافت کنید.", - "If you cancel your subscription now, you will continue to have access until {periodEnd}.": "در صورتی که اشتراک خود را لغو کنید تا تاریخ {periodEnd} کماکان به بسته خود دسترسی خواهید داشت.", - "If you have a corporate or government email account, reach out to your IT department and ask them to allow emails to be received from {senderEmail}": "در صورتی که از آدرس ایمیل شرکتی و یا دولتی استفاده می\u200cکنید، با بخش آی\u200cتی خود تماس بگیرید و درخواست کنید که ایمیل\u200cهای ارسالی از طرف {senderEmail} را در لیست سفید قرار دهند.", - "If you have an account, a login link has been sent to your inbox. If it doesn't arrive in 3 minutes, be sure to check your spam folder.": "یک پیوند ورود برای ایمیل شما ارسال شد. در صورتی که به دست شما نرسید، پوشه اسپم خود را برررسی کنید", - "If you have an account, an email has been sent to {submittedEmailOrInbox}. Click the link inside or enter your code below.": "", - "If you would like to start receiving emails again, the best next steps are to check your email address on file for any issues and then click resubscribe on the previous screen.": "در صورتی که مایل هستید که دوباره ایمیل\u200cها را دریافت کید، بهترین راه\u200cکار بررسی آدرس ایمیل خود و سپس کلیک برروی گزینه دریافت اشتراک در صفحه قبلی است.", - "If you're not receiving the email newsletter you've subscribed to, here are a few things to check.": "در صورتی که شما ایمیل خبرنامه\u200cهایی که مشترک آن\u200cها شده\u200cاید را دریافت نمی\u200cکنید، این موارد را بررسی کنید.", - "If you've completed all these checks and you're still not receiving emails, you can reach out to get support by contacting {supportAddress}.": "در صورتی که شما تمامی این کارها را انجام داده و همچنان ایمیلی دریافت نمی\u200cکنید، می\u200cتوانید با پشتیبانی از طریق {supportAddress} تماس بگیرید.", - "In the event a permanent failure is received when attempting to send a newsletter, emails will be disabled on the account.": "در صورتی که ارسال خبرنامه با یک خطای دائمی روبرو شود، ارسال ایمیل\u200cها برای آن حساب متوقف خواهد شد.", - "In your email client add {senderEmail} to your contacts list. This signals to your mail provider that emails sent from this address should be trusted.": "در برنامه\u200cی ایمیل خود آدرس {senderEmail} را به عنوان مخاطب ذخیره کنید. این کار باعث می\u200cشود که سرویس\u200cدهنده ایمیل شما متوجه شود که این آدرس باید مورد تأیید قرار گیرد.", - "Invalid email address": "", - "Invalid verification code": "", - "Jamie Larson": "", - "jamie@example.com": "", - "Less like this": "کمتر موردی مثل این نشان بده", - "LinkedIn": "", - "Make sure emails aren't accidentally ending up in the Spam or Promotions folders of your inbox. If they are, click on \"Mark as not spam\" and/or \"Move to inbox\".": "اطمینان حاصل کنید که ایمیل\u200cها به صورت اتفاقی در پوشه اسپم یا تبلیغاتی شما قرار نگرفته\u200cاند. در صورتی که آنجا باشند، برروی «اسپم نیست» و/یا «انتقال به صندوق ورودی» کلیک کنید.", + "Here are a few other sites you may enjoy.": "این چند سایت دیگر نیز ممکن است برایتان جالب باشند.", + "Hide details": "پنهان\u200cسازی جزئیات", + "If a newsletter is flagged as spam, emails are automatically disabled for that address to make sure you no longer receive any unwanted messages.": "اگر خبرنامه\u200cای به\u200cعنوان هرزنامه علامت\u200cگذاری شود، ایمیل\u200cهای مربوط به آن نشانی به\u200cطور خودکار غیرفعال می\u200cشوند تا مطمئن شویم هیچ پیام ناخواسته\u200cای دریافت نمی\u200cکنید.", + "If the spam complaint was accidental, or you would like to begin receiving emails again, you can resubscribe to emails by clicking the button on the previous screen.": "اگر گزارش هرزنامه به\u200cاشتباه بوده، یا می\u200cخواهید دوباره ایمیل دریافت کنید، می\u200cتوانید با زدن دکمه در صفحه قبلی، اشتراک ایمیل را از سر بگیرید.", + "If you cancel your subscription now, you will continue to have access until {periodEnd}.": "اگر اکنون اشتراک خود را لغو کنید، تا {periodEnd} همچنان به آن دسترسی خواهید داشت.", + "If you have a corporate or government email account, reach out to your IT department and ask them to allow emails to be received from {senderEmail}": "اگر از نشانی ایمیل سازمانی یا دولتی استفاده می\u200cکنید، با بخش فناوری اطلاعات خود تماس بگیرید و درخواست کنید ایمیل\u200cهای ارسالی از {senderEmail} را مجاز کنند.", + "If you have an account, a login link has been sent to your inbox. If it doesn't arrive in 3 minutes, be sure to check your spam folder.": "اگر حساب کاربری دارید، پیوند ورود به صندوق ورودی شما ارسال شده است. اگر تا ۳ دقیقه نرسید، پوشه هرزنامه را بررسی کنید.", + "If you have an account, an email has been sent to {submittedEmailOrInbox}. Click the link inside or enter your code below.": "اگر حساب کاربری دارید، ایمیلی به {submittedEmailOrInbox} ارسال شده است. روی پیوند درون آن بزنید یا کد را در پایین وارد کنید.", + "If you would like to start receiving emails again, the best next steps are to check your email address on file for any issues and then click resubscribe on the previous screen.": "اگر می\u200cخواهید دوباره ایمیل دریافت کنید، بهترین کار بررسی نشانی ایمیل ثبت\u200cشده برای هرگونه مشکل و سپس زدن دکمه ازسرگیری اشتراک در صفحه قبلی است.", + "If you're not receiving the email newsletter you've subscribed to, here are a few things to check.": "اگر خبرنامه ایمیلی را که مشترک آن شده\u200cاید دریافت نمی\u200cکنید، این چند مورد را بررسی کنید.", + "If you've completed all these checks and you're still not receiving emails, you can reach out to get support by contacting {supportAddress}.": "اگر همه این بررسی\u200cها را انجام دادید و همچنان ایمیلی دریافت نمی\u200cکنید، می\u200cتوانید با تماس با {supportAddress} از پشتیبانی کمک بگیرید.", + "In the event a permanent failure is received when attempting to send a newsletter, emails will be disabled on the account.": "در صورت دریافت خطای دائمی هنگام تلاش برای ارسال خبرنامه، ارسال ایمیل برای آن حساب غیرفعال خواهد شد.", + "In your email client add {senderEmail} to your contacts list. This signals to your mail provider that emails sent from this address should be trusted.": "در سرویس\u200cگیرنده ایمیل خود، {senderEmail} را به فهرست مخاطبان اضافه کنید. این کار به ارائه\u200cدهنده ایمیل شما نشان می\u200cدهد که ایمیل\u200cهای ارسالی از این نشانی قابل اعتماد هستند.", + "Invalid email address": "نشانی ایمیل نامعتبر", + "Invalid verification code": "کد تأیید نامعتبر", + "Jamie Larson": "آرش کمانگر", + "jamie@example.com": "arash@example.com", + "Less like this": "کمتر از این موارد", + "LinkedIn": "لینکدین", + "Make sure emails aren't accidentally ending up in the Spam or Promotions folders of your inbox. If they are, click on \"Mark as not spam\" and/or \"Move to inbox\".": "مطمئن شوید ایمیل\u200cها به\u200cطور تصادفی در پوشه هرزنامه یا تبلیغات صندوق ورودی شما قرار نمی\u200cگیرند. در صورت قرار گرفتن، روی «علامت\u200cگذاری به\u200cعنوان غیرهرزنامه» و/یا «انتقال به صندوق ورودی» بزنید.", "Manage": "مدیریت", "Maybe later": "شاید بعداً", - "Membership details": "", - "Memberships from this email domain are currently restricted.": "", - "Memberships unavailable, contact the owner for access.": "عضویت غیرقابل دسترس است، با مالک برای دسترسی تماس بگیرید.", - "month": "", + "Membership details": "جزئیات اشتراک", + "Memberships from this email domain are currently restricted.": "اشتراک\u200cها از این دامنه ایمیل در حال حاضر محدود هستند.", + "Memberships unavailable, contact the owner for access.": "اشتراک\u200cها در دسترس نیستند، برای دسترسی با مالک تماس بگیرید.", + "month": "ماه", "Monthly": "ماهانه", - "More like this": "مواردی بیشتری مثل این نشان بده", - "More options": "", + "More like this": "بیشتر از این موارد", + "More options": "گزینه\u200cهای بیشتر", "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).": "خبرنامه\u200cها ممکن است به خاطر دو دلیل برای شما غیرفعال شده باشند: ایمیلی که قبلاً برای شما ارسال شده به عنوان اسپم علامت\u200cگذاری شده باشد و یا این که با یک شکست دائمی (bounce) روبرو شده باشد.", - "No member exists with this e-mail address.": "", - "No thanks, I want to cancel": "", - "Not ready to share? We've also emailed a copy to your inbox.": "", + "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).": "خبرنامه\u200cها در حساب شما به دو دلیل ممکن است غیرفعال شده باشند: ایمیل قبلی به\u200cعنوان هرزنامه علامت\u200cگذاری شده است، یا تلاش برای ارسال ایمیل به خطای دائمی (bounce) منجر شده است.", + "No member exists with this e-mail address.": "هیچ عضوی با این نشانی ایمیل وجود ندارد.", + "No thanks, I want to cancel": "نه سپاس، می\u200cخواهم لغو کنم", + "Not ready to share? We've also emailed a copy to your inbox.": "آماده اشتراک\u200cگذاری نیستید؟ ما یک نسخه هم به صندوق ورودی شما ایمیل کرده\u200cایم.", "Not receiving emails?": "ایمیلی دریافت نمی\u200cکنید؟", - "Now check your email!": "حالا صندوق ورودی ایمیل خود را بررسی کنید!", - "Occasional updates from {siteTitle}": "", - "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.": "پس از دریافت مجدد اشتراک، در صورتی که کماکان ایمیل\u200cها را در ایمیل خود نمی\u200cبینید، پوشه اسپم را بررسی کنید. برخی از سرویس\u200cدهندگان تاریخچه گزارش اسپم را نگهداری می\u200cکنند و همچنان ایمیل\u200cها را به عنوان اسپم علامت\u200cگذاری می\u200cکنند. در صورتی که این مورد وجود داشت، ایمیل را با عنوان «اسپم نیست» علامت\u200cگذاری کنید تا آن را به صندوق ورودی انتقال دهد.", - "Open AOL Mail": "", - "Open email": "", - "Open Feedbin": "", - "Open Gmail": "", - "Open Hey": "", - "Open iCloud Mail": "", - "Open Mail.ru": "", - "Open Outlook": "", - "Open Proton Mail": "", - "Open Yahoo Mail": "", - "Permanent failure (bounce)": "خظای دائمی (bounce)", - "Phone number": "", + "Now check your email!": "حالا ایمیل خود را بررسی کنید!", + "Occasional updates from {siteTitle}": "به\u200cروزرسانی\u200cهای گاه\u200cبه\u200cگاه از {siteTitle}", + "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.": "پس از ازسرگیری اشتراک، اگر همچنان ایمیل\u200cها را در صندوق ورودی خود نمی\u200cبینید، پوشه هرزنامه را بررسی کنید. برخی ارائه\u200cدهندگان ایمیل سابقه شکایت\u200cهای هرزنامه را نگه می\u200cدارند و همچنان ایمیل\u200cها را علامت\u200cگذاری می\u200cکنند. در این صورت، آخرین خبرنامه را به\u200cعنوان «غیرهرزنامه» علامت بزنید تا به صندوق ورودی اصلی شما برگردد.", + "Open AOL Mail": "باز کردن AOL Mail", + "Open email": "باز کردن ایمیل", + "Open Feedbin": "باز کردن Feedbin", + "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)": "خطای دائمی (bounce)", + "Phone number": "شماره تلفن", "Plan": "بسته", - "Plan checkout was cancelled.": "تسویه صورت\u200cحساب بسته لفو شد.", - "Plan upgrade was cancelled.": "ارتقاء بسته لفو شد.", - "Please contact {supportAddress} to adjust your complimentary subscription.": "خواهشمند است که با آدرس {supportAddress} تماس بگیرید تا اشتراک رایگان شما را تنظیم کند.", - "Please enter {fieldName}": "", - "Please fill in required fields": "خواهشمند است که موارد الزامی را وارد کنید", - "Podcasts": "", - "Re-enable emails": "فعال\u200cسازی ایمیل\u200cها", - "Recommendations": "پیشنهادات", - "Redeem your membership": "", - "Redeeming...": "", - "Renews at {price}.": "با قیمت {price} تمدید خواهد شد.", - "Resume subscription": "", + "Plan checkout was cancelled.": "پرداخت بسته لغو شد.", + "Plan upgrade was cancelled.": "ارتقای بسته لغو شد.", + "Please contact {supportAddress} to adjust your complimentary subscription.": "لطفاً برای تنظیم اشتراک رایگان خود با {supportAddress} تماس بگیرید.", + "Please enter {fieldName}": "لطفاً {fieldName} را وارد کنید", + "Please fill in required fields": "لطفاً فیلدهای الزامی را پر کنید", + "Podcasts": "پادکست\u200cها", + "Re-enable emails": "فعال\u200cسازی مجدد ایمیل\u200cها", + "Recommendations": "پیشنهادها", + "Redeem your membership": "اشتراک خود را فعال کنید", + "Redeeming...": "در حال فعال\u200cسازی...", + "Renews at {price}.": "با قیمت {price} تمدید می\u200cشود.", + "Resume subscription": "ازسرگیری اشتراک", "Retry": "تلاش دوباره", "Save": "ذخیره", - "Save {amountOff} on your next {durationInMonths} billing cycles. Then {currency}{originalPrice}/{cadence}.": "", - "Save {amountOff} on your next billing cycle. Then {currency}{originalPrice}/{cadence}.": "", - "Send an email and say hi!": "ایمیلی بفرستید و سلامی بفرستید!", - "Send an email to {senderEmail} and say hello. This can also help signal to your mail provider that emails to and from this address should be trusted.": "ایمیلی به آدرس {senderEmail} بفرستید و بنویسید سلام. این کار باعث می\u200cشود که سرویس\u200cدهنده ایمیل شما متوجه شود که این آدرس باید مورد تأیید قرار گیرد.", - "Send the link below to share it with whoever you'd like.": "", + "Save {amountOff} on your next {durationInMonths} billing cycles. Then {currency}{originalPrice}/{cadence}.": "در {durationInMonths} دوره صورتحساب بعدی خود {amountOff} صرفه\u200cجویی کنید. سپس {currency}{originalPrice}/{cadence}.", + "Save {amountOff} on your next billing cycle. Then {currency}{originalPrice}/{cadence}.": "در دوره صورتحساب بعدی خود {amountOff} صرفه\u200cجویی کنید. سپس {currency}{originalPrice}/{cadence}.", + "Send an email and say hi!": "ایمیلی بفرستید و سلامی بگویید!", + "Send an email to {senderEmail} and say hello. This can also help signal to your mail provider that emails to and from this address should be trusted.": "به {senderEmail} ایمیل بزنید و سلامی بگویید. این کار به ارائه\u200cدهنده ایمیل شما نشان می\u200cدهد که ایمیل\u200cهای رفت\u200cوبرگشت به این نشانی قابل اعتماد هستند.", + "Send the link below to share it with whoever you'd like.": "پیوند زیر را برای اشتراک\u200cگذاری با هر کسی که می\u200cخواهید بفرستید.", "Sending login link...": "در حال ارسال پیوند ورود...", "Sending...": "در حال ارسال...", - "Share": "", - "Share a full membership to {siteTitle} with a friend or colleague": "", + "Share": "اشتراک\u200cگذاری", + "Share a full membership to {siteTitle} with a friend or colleague": "یک اشتراک کامل {siteTitle} را با دوست یا همکار خود به اشتراک بگذارید", "Show all": "نمایش همه", "Sign in": "ورود", - "Sign out": "بیرون رفتن", - "Sign up": "ثبت نام", - "Signup error: Invalid link": "خطای ثبت نام: پیوند معتبر نیست", - "Signups from this email domain are currently restricted.": "", - "Something went wrong, please try again later.": "", - "Sorry, no paid plans are available.": "", - "Sorry, no recommendations are available right now.": "", - "Sorry, that didn’t work.": "پوزش می\u200cخواهیم، آن کار انجام نشد.", - "Spam complaints": "گزارش\u200cهای اسپم", - "Start {amount}-day free trial": "شروع {amount} روز دوره رایگان", + "Sign out": "خروج", + "Sign up": "ثبت\u200c نام", + "Signup error: Invalid link": "خطای ثبت\u200c نام: پیوند نامعتبر", + "Signups from this email domain are currently restricted.": "ثبت\u200c نام از این دامنه ایمیل در حال حاضر محدود است.", + "Something went wrong, please try again later.": "مشکلی پیش آمد، لطفاً بعداً دوباره تلاش کنید.", + "Sorry, no paid plans are available.": "با عرض پوزش، هیچ بسته ویژه\u200cای در دسترس نیست.", + "Sorry, no recommendations are available right now.": "با عرض پوزش، در حال حاضر هیچ پیشنهادی در دسترس نیست.", + "Sorry, that didn’t work.": "با عرض پوزش، کار نکرد.", + "Spam complaints": "گزارش\u200cهای هرزنامه", + "Start {amount}-day free trial": "شروع دوره رایگان {amount} روزه", "Starting {startDate}": "شروع از {startDate}", "Starting today": "شروع از امروز", - "Submit feedback": "ثبت بازخورد", - "Subscribe": "دریافت اشتراک", - "Subscribed": "مشترک هستید", - "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.": "انجام شد! حساب کاربری شما به طور کامل فعال شد، شما حالا می\u200cتوانید به تمام محتواها دسترسی داشته باشید.", - "Success! Your email is updated.": "انجام شد! ایمیل شما به روز شد.", - "Successfully unsubscribed": "لغو اشتراک با موفقیت انجام شد", - "Thank you for subscribing. Before you start reading, below are a few other sites you may enjoy.": "با سپاس از اشتراک شما. پیش از این که شروع به خواندن کنید چند وب\u200cسایت دیگر که ممکن است آن\u200cها را بپسندید در زیر برای شما قرار گرفته\u200cاند.", - "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} است - اگر که این آدرس درست نیست می\u200cتوانید در آن را به\u200cروز کنید.", - "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.": "", - "There was an error processing your payment. Please try again.": "", - "There was an error sending the email, please try again": "", - "This gift has already been consumed.": "", - "This gift has already been redeemed.": "", - "This gift has been refunded.": "", - "This gift has expired.": "", - "This site is invite-only, contact the owner for access.": "دسترسی به این وب\u200cسایت نیازمند دعوت\u200cنامه است، با مالک آن برای دریافت دسترسی تماس بگیرید.", - "This site is not accepting donations at the moment.": "", - "This site is not accepting payments at the moment.": "", - "This site only accepts paid members.": "", - "Threads": "", - "Tier": "", - "To complete signup, click the confirmation link in your inbox. If it doesn't arrive within 3 minutes, check your spam folder!": "برای تکمیل ثبت نام، برروی پیوند تأیید در صندوق ورودی ایمیل خود کلیک کنید. در صورتی که به دست شما نرسید، پوشه اسپم خود را برررسی کنید!", - "To continue to stay up to date, subscribe to {publication} below.": "", - "Too many attempts try again in {number} days.": "", - "Too many attempts try again in {number} hours.": "", - "Too many attempts try again in {number} minutes.": "", - "Too many different sign-in attempts, try again in {number} days": "", - "Too many different sign-in attempts, try again in {number} hours": "", - "Too many different sign-in attempts, try again in {number} minutes": "", - "Too many sign-up attempts, try again later": "", - "Try free for {amount} days, then {originalPrice}.": "برای {amount} روز به صورت رایگان امتحان کنید، سپس با قیمت {originalPrice}.", - "Unable to initiate checkout session": "", - "Unlock access to all newsletters by becoming a paid subscriber.": "با دریافت اشتراک پولی به شما دسترسی به تمامی خبرنامه\u200cها داده می\u200cشود.", - "Unsubscribe from all emails": "لغو اشتراک دریافت تمامی ایمیل\u200cها", + "Submit feedback": "ارسال بازخورد", + "Subscribe": "اشتراک", + "Subscribed": "مشترک شدید", + "Subscription plan updated successfully": "بسته اشتراک با موفقیت به\u200cروزرسانی شد", + "Success": "موفقیت\u200cآمیز", + "Success! Check your email for magic link to sign-in.": "موفقیت\u200cآمیز! صندوق ورودی خود را برای پیوند جادویی ورود بررسی کنید.", + "Success! Your account is fully activated, you now have access to all content.": "موفقیت\u200cآمیز! حساب کاربری شما کاملاً فعال شد و اکنون به همه محتوا دسترسی دارید.", + "Success! Your email is updated.": "موفقیت\u200cآمیز! ایمیل شما به\u200cروزرسانی شد.", + "Successfully unsubscribed": "اشتراک با موفقیت لغو شد", + "Thank you for subscribing. Before you start reading, below are a few other sites you may enjoy.": "از اشتراک شما سپاسگزاریم. پیش از شروع مطالعه، در ادامه چند سایت دیگر که ممکن است بپسندید معرفی شده\u200cاند.", + "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} است — اگر درست نیست، می\u200cتوانید آن را در به\u200cروز کنید.", + "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.": "هنگام ادامه اشتراک شما خطایی رخ داد، لطفاً دوباره تلاش کنید.", + "There was an error processing your payment. Please try again.": "هنگام پردازش پرداخت شما خطایی رخ داد. لطفاً دوباره تلاش کنید.", + "There was an error sending the email, please try again": "هنگام ارسال ایمیل خطایی رخ داد، لطفاً دوباره تلاش کنید", + "This gift has already been consumed.": "این هدیه قبلاً استفاده شده است.", + "This gift has already been redeemed.": "این هدیه قبلاً فعال شده است.", + "This gift has been refunded.": "این هدیه بازپرداخت شده است.", + "This gift has expired.": "این هدیه منقضی شده است.", + "This site is invite-only, contact the owner for access.": "این سایت فقط با دعوت\u200cنامه قابل دسترسی است، برای دریافت دسترسی با مالک تماس بگیرید.", + "This site is not accepting donations at the moment.": "این سایت در حال حاضر کمک\u200cهای مالی را نمی\u200cپذیرد.", + "This site is not accepting payments at the moment.": "این سایت در حال حاضر پرداخت\u200cها را نمی\u200cپذیرد.", + "This site only accepts paid members.": "این سایت فقط اعضای ویژه را می\u200cپذیرد.", + "Threads": "رشته\u200cها", + "Tier": "سطح", + "To complete signup, click the confirmation link in your inbox. If it doesn't arrive within 3 minutes, check your spam folder!": "برای تکمیل ثبت\u200c نام، روی پیوند تأیید در صندوق ورودی بزنید. اگر تا ۳ دقیقه نرسید، پوشه هرزنامه را بررسی کنید!", + "To continue to stay up to date, subscribe to {publication} below.": "برای ادامه به\u200cروز ماندن، در {publication} مشترک شوید.", + "Too many attempts try again in {number} days.": "تلاش\u200cهای بسیار زیاد، {number} روز دیگر دوباره تلاش کنید.", + "Too many attempts try again in {number} hours.": "تلاش\u200cهای بسیار زیاد، {number} ساعت دیگر دوباره تلاش کنید.", + "Too many attempts try again in {number} minutes.": "تلاش\u200cهای بسیار زیاد، {number} دقیقه دیگر دوباره تلاش کنید.", + "Too many different sign-in attempts, try again in {number} days": "تلاش\u200cهای ورود متعدد بسیار زیاد، {number} روز دیگر دوباره تلاش کنید", + "Too many different sign-in attempts, try again in {number} hours": "تلاش\u200cهای ورود متعدد بسیار زیاد، {number} ساعت دیگر دوباره تلاش کنید", + "Too many different sign-in attempts, try again in {number} minutes": "تلاش\u200cهای ورود متعدد بسیار زیاد، {number} دقیقه دیگر دوباره تلاش کنید", + "Too many sign-up attempts, try again later": "تلاش\u200cهای ثبت\u200c نام بسیار زیاد، بعداً دوباره تلاش کنید", + "Try free for {amount} days, then {originalPrice}.": "برای {amount} روز رایگان امتحان کنید، سپس {originalPrice}.", + "Unable to initiate checkout session": "شروع نشست پرداخت ممکن نیست", + "Unlock access to all newsletters by becoming a paid subscriber.": "با اشتراک ویژه، دسترسی به همه خبرنامه\u200cها را باز کنید.", + "Unsubscribe from all emails": "لغو اشتراک همه ایمیل\u200cها", "Unsubscribed": "اشتراک لغو شد", - "Unsubscribed from all emails.": "اشتراک تمامی ایمیل\u200cها لغو شد.", - "Unsubscribing from emails will not cancel your paid subscription to {title}": "لغو دریافت ایمیل\u200cها باعث لغو اشتراک پولی شما از {title} نخواهد شد.", + "Unsubscribed from all emails.": "اشتراک همه ایمیل\u200cها لغو شد.", + "Unsubscribing from emails will not cancel your paid subscription to {title}": "لغو اشتراک ایمیل، اشتراک ویژه شما در {title} را لغو نخواهد کرد.", "Update": "به\u200cروزرسانی", "Update your preferences": "به\u200cروزرسانی تنظیمات شما", - "Updates & announcements": "", - "Verification link sent, check your inbox": "پیوند تأیید برای شما ارسال شد، ایمیل خود را بررسی کنید", - "Verify your email address is correct": "تأیید کنید که آدرس ایمیل شما درست است", - "Verifying...": "", - "View plans": "نمایش بسته\u200cها", - "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "لغو اشتراک شما به دلیل این که آدرس ایمیل شما پیدا نشد، انجام نشد. خواهشمند است با مالک وب\u200cسایت تماس بگیرید.", - "We'd hate to see you leave. How about a special offer to stay?": "", - "Welcome back, {name}!": "{name} عزیز، خوش برگشتی!", + "Updates & announcements": "به\u200cروزرسانی\u200cها و اطلاعیه\u200cها", + "Verification link sent, check your inbox": "پیوند تأیید ارسال شد، صندوق ورودی خود را بررسی کنید", + "Verify your email address is correct": "تأیید کنید که نشانی ایمیل شما درست است", + "Verifying...": "در حال تأیید...", + "View plans": "مشاهده بسته\u200cها", + "We couldn't unsubscribe you as the email address was not found. Please contact the site owner.": "ما نتوانستیم اشتراک شما را لغو کنیم چون نشانی ایمیل یافت نشد. لطفاً با مالک سایت تماس بگیرید.", + "We'd hate to see you leave. How about a special offer to stay?": "دوست نداریم شما را از دست بدهیم. چطور است با یک پیشنهاد ویژه بمانید؟", + "Welcome back, {name}!": "خوش برگشتید، {name}!", "Welcome back!": "خوش برگشتید!", - "Welcome to {siteTitle}": "به {siteTitle} خوش آمدید", - "When an inbox fails to accept an email it is commonly called a bounce. In many cases, this can be temporary. However, in some cases, a bounced email can be returned as a permanent failure when an email address is invalid or non-existent.": "زمانی که یک صندوق ایمیل از قبول پیام سر باز می\u200cزند به آن معمولا bounce می\u200cگویند. در بسیاری از موارد این موضوع موقتی است و رفع می\u200cشود. اما در برخی موارد، این خطا دائمی می\u200cشود و معمولاً به این خاطر است که آدرس اشتباه بوده و یا وجود ندارد.", - "Why has my email been disabled?": "چرا آدرس ایمیل من غیرفعال شده است؟", - "X (Twitter)": "", - "year": "", + "Welcome to {siteTitle}": "به {siteTitle} خوش\u200cآمدید", + "When an inbox fails to accept an email it is commonly called a bounce. In many cases, this can be temporary. However, in some cases, a bounced email can be returned as a permanent failure when an email address is invalid or non-existent.": "وقتی یک صندوق ورودی ایمیلی را نمی\u200cپذیرد، معمولاً به آن bounce گفته می\u200cشود. در بسیاری از موارد، این موضوع موقتی است. اما در برخی موارد، وقتی نشانی ایمیل نامعتبر یا ناموجود باشد، ایمیل برگشتی به\u200cصورت خطای دائمی بازمی\u200cگردد.", + "Why has my email been disabled?": "چرا ایمیل من غیرفعال شده است؟", + "X (Twitter)": "X (توییتر)", + "year": "سال", "Yearly": "سالانه", - "You already have an active subscription.": "", - "You currently have a free membership, upgrade to a paid subscription for full access.": "شما در حال حاضر از بسته رایگان استفاده می\u200cکنید، حساب خود را به یک اشتراک پولی برای دریافت دسترسی کامل ارتقاء دهید.", - "You have been successfully resubscribed": "شما با موفقیت دوباره مشترک شدید", - "You now have access to {tierName} until {expiryDate}. Enjoy!": "", - "You're currently not receiving emails": "شما در حال حاضر هیچ ایمیلی دریافت نمی\u200cکنید", - "You're not receiving emails": "شما هیچ ایمیلی دریافت نمی\u200cکنید", - "You're not receiving emails because you either marked a recent message as spam, or because messages could not be delivered to your provided email address.": "شما به خاطر این که آخرین ایمیلی که دریافت کرده\u200cاید به عنوان اسپم علامت\u200cگذاری شده است و یا این که مشکلی برای تحویل ایمیل به آدرسی که وارد کرده\u200cاید وجود دارد، ایمیلی دریافت نمی\u200cکنید.", - "You've been gifted a membership": "", - "You've been gifted a membership to {siteTitle}": "", - "You've successfully signed in.": "شما با موفقیت وارد شدید.", - "You've successfully subscribed to {siteTitle}": "شما با موفقیت مشترک این موارد شدید: {siteTitle}", + "You already have an active subscription.": "شما از قبل یک اشتراک فعال دارید.", + "You currently have a free membership, upgrade to a paid subscription for full access.": "در حال حاضر عضو رایگان هستید، برای دسترسی کامل به یک اشتراک ویژه ارتقا دهید.", + "You have been successfully resubscribed": "اشتراک شما با موفقیت از سر گرفته شد", + "You now have access to {tierName} until {expiryDate}. Enjoy!": "اکنون تا {expiryDate} به {tierName} دسترسی دارید. لذت ببرید!", + "You're currently not receiving emails": "در حال حاضر ایمیلی دریافت نمی\u200cکنید", + "You're not receiving emails": "شما ایمیلی دریافت نمی\u200cکنید", + "You're not receiving emails because you either marked a recent message as spam, or because messages could not be delivered to your provided email address.": "ایمیلی دریافت نمی\u200cکنید چون یا پیام اخیری را به\u200cعنوان هرزنامه علامت\u200cگذاری کرده\u200cاید، یا پیام\u200cها به نشانی ایمیل وارد شده تحویل داده نشده\u200cاند.", + "You've been gifted a membership": "به شما یک اشتراک هدیه داده شده است", + "You've been gifted a membership to {siteTitle}": "به شما یک اشتراک در {siteTitle} هدیه داده شده است", + "You've successfully signed in.": "با موفقیت وارد شدید.", + "You've successfully subscribed to {siteTitle}": "با موفقیت در {siteTitle} مشترک شدید", "Your account": "حساب کاربری شما", - "Your email": "", - "Your email has failed to resubscribe, please try again": "", - "Your gift is ready": "", - "Your gift subscription will expire on {expiryDate}": "", - "your inbox": "", - "Your input helps shape what gets published.": "تلاش شما به آنچه که منتشر می\u200cشود، شکل می\u200cدهد.", - "Your name": "", - "Your subscription has been canceled and will expire on {expiryDate}.": "", - "Your subscription will expire on {expiryDate}": "اشتراک شما در تاریخ {expiryDate} منقضی می\u200cشود", - "Your subscription will renew on {renewalDate}": "اشتراک شما در تاریخ {renewalDate} تمدید می\u200cشود", - "Your subscription will start on {subscriptionStart}": "اشتراک شما از تاریخ {subscriptionStart} شروع می\u200cشود" + "Your email": "ایمیل شما", + "Your email has failed to resubscribe, please try again": "ازسرگیری اشتراک ایمیل شما ناموفق بود، لطفاً دوباره تلاش کنید", + "Your gift is ready": "هدیه شما آماده است", + "Your gift subscription will expire on {expiryDate}": "اشتراک هدیه شما در {expiryDate} منقضی خواهد شد", + "your inbox": "صندوق ورودی شما", + "Your input helps shape what gets published.": "نظرات شما به شکل\u200cگیری محتوای منتشر شده کمک می\u200cکند.", + "Your name": "نام شما", + "Your subscription has been canceled and will expire on {expiryDate}.": "اشتراک شما لغو شده است و در {expiryDate} منقضی خواهد شد.", + "Your subscription will expire on {expiryDate}": "اشتراک شما در {expiryDate} منقضی خواهد شد", + "Your subscription will renew on {renewalDate}": "اشتراک شما در {renewalDate} تمدید خواهد شد", + "Your subscription will start on {subscriptionStart}": "اشتراک شما از {subscriptionStart} شروع خواهد شد" } diff --git a/ghost/i18n/locales/fa/search.json b/ghost/i18n/locales/fa/search.json index 8902015528f..59efe027dc7 100644 --- a/ghost/i18n/locales/fa/search.json +++ b/ghost/i18n/locales/fa/search.json @@ -1,9 +1,9 @@ { - "Authors": "", - "Cancel": "", - "No matches found": "", - "Posts": "", - "Search posts, tags and authors": "", - "Show more results": "", - "Tags": "" + "Authors": "نویسندگان", + "Cancel": "لغو", + "No matches found": "موردی یافت نشد", + "Posts": "نوشته\u200cها", + "Search posts, tags and authors": "جست\u200cوجوی نوشته\u200cها، برچسب\u200cها و نویسندگان", + "Show more results": "نمایش نتایج بیشتر", + "Tags": "برچسب\u200cها" } diff --git a/ghost/i18n/locales/fa/signup-form.json b/ghost/i18n/locales/fa/signup-form.json index d217c1160ea..41f5cdbe24b 100644 --- a/ghost/i18n/locales/fa/signup-form.json +++ b/ghost/i18n/locales/fa/signup-form.json @@ -1,9 +1,9 @@ { "Email sent": "ایمیل ارسال شد", - "Now check your email!": "صندوق ایمیل خود را بررسی کنید!", - "Please enter a valid email address": "خواهشمند است یک آدرس ایمیل معتبر وارد کنید", - "Something went wrong, please try again.": "مشکلی پیش آمد، خواهشمند است دوباره تلاش کنید.", - "Subscribe": "دریافت اشتراک", - "To complete signup, click the confirmation link in your inbox. If it doesn't arrive within 3 minutes, check your spam folder!": "برای تکمیل ثبت نام، روی پیوند تأیید در صندوق ورودی ایمیل خود کلیک کنید. اگر تا ۳ دقیقه به دست شما نرسید، پوشه اسپم خود را بررسی کنید!", - "Your email address": "آدرس ایمیل شما" + "Now check your email!": "حالا ایمیل خود را بررسی کنید!", + "Please enter a valid email address": "لطفاً یک نشانی ایمیل معتبر وارد کنید", + "Something went wrong, please try again.": "مشکلی پیش آمد، لطفاً دوباره تلاش کنید.", + "Subscribe": "اشتراک", + "To complete signup, click the confirmation link in your inbox. If it doesn't arrive within 3 minutes, check your spam folder!": "برای تکمیل ثبت\u200c نام، روی پیوند تأیید در صندوق ورودی بزنید. اگر تا ۳ دقیقه نرسید، پوشه هرزنامه را بررسی کنید!", + "Your email address": "نشانی ایمیل شما" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4c93b58896b..a0b0932f299 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2432,8 +2432,8 @@ importers: specifier: 1.0.43 version: 1.0.43 '@tryghost/mongo-utils': - specifier: 0.6.4 - version: 0.6.4 + specifier: 0.6.5 + version: 0.6.5 '@tryghost/mw-error-handler': specifier: 1.0.13 version: 1.0.13 @@ -2946,8 +2946,8 @@ importers: specifier: 'catalog:' version: 7.2.2 tmp: - specifier: 0.2.6 - version: 0.2.6 + specifier: 0.2.7 + version: 0.2.7 tsx: specifier: 'catalog:' version: 4.22.4 @@ -8882,9 +8882,6 @@ packages: '@tryghost/mongo-knex@0.9.5': resolution: {integrity: sha512-etO6Kz8a1dgMV6yapD2C2JsoeLrUjkIoIUdXrkV9ELHQsMjMhtI2SAUnMV9AcLhxv0P7h8UWWD2rlNBwo8JJig==} - '@tryghost/mongo-utils@0.6.4': - resolution: {integrity: sha512-4ZGl9QXoMTQb9qb5HGjMjVLkV39FO0F/0cM4Z3i/9gUXXfdmGZwPdcAMBRT7oRhqng3/4uSRpvQ/Rk0oASM+kg==} - '@tryghost/mongo-utils@0.6.5': resolution: {integrity: sha512-fMEfdlVaVkr7SJwVxBxVDfUQ+x4DVF4PMet698PLzabqSnGsaWcruBaQlOuNvtf8ITNXKFUAqZMdmSUTIAHU+Q==} @@ -21064,6 +21061,10 @@ packages: resolution: {integrity: sha512-5sJPdPjfI5Kx+qbrDesxkglRBxW//g7hCsqspEjwkewGvBMGIKMOTKzLt1hFVJzyadba3lDUN20O9qhvbQUSTA==} engines: {node: '>=14.14'} + tmp@0.2.7: + resolution: {integrity: sha512-e0votIpp4Uo2AJYSzVHV6xCcawuiez3DzqDAbrTc3YxBkplN6e+dM13ZeIcZnDg/QpSuU2zfZ3rzwY8ukEnaXw==} + engines: {node: '>=14.14'} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -29035,10 +29036,6 @@ snapshots: transitivePeerDependencies: - supports-color - '@tryghost/mongo-utils@0.6.4': - dependencies: - lodash: 4.18.1 - '@tryghost/mongo-utils@0.6.5': dependencies: lodash: 4.18.1 @@ -29127,7 +29124,7 @@ snapshots: '@tryghost/nql@0.12.11': dependencies: '@tryghost/mongo-knex': 0.9.5 - '@tryghost/mongo-utils': 0.6.4 + '@tryghost/mongo-utils': 0.6.5 '@tryghost/nql-lang': 0.6.6 lodash: 4.18.1 mingo: 2.5.3 @@ -29137,7 +29134,7 @@ snapshots: '@tryghost/nql@0.13.0': dependencies: '@tryghost/mongo-knex': 0.10.1 - '@tryghost/mongo-utils': 0.6.4 + '@tryghost/mongo-utils': 0.6.5 '@tryghost/nql-lang': 0.6.6 lodash: 4.18.1 mingo: 2.5.3 @@ -32472,7 +32469,7 @@ snapshots: resolve-path: 1.4.0 rimraf: 3.0.2 sane: 4.1.0 - tmp: 0.2.6 + tmp: 0.2.7 tree-sync: 2.1.0 underscore.string: 3.3.6 watch-detector: 1.0.2 @@ -32778,7 +32775,7 @@ snapshots: can-symlink@1.0.0: dependencies: - tmp: 0.2.6 + tmp: 0.2.7 caniuse-api@3.0.0: dependencies: @@ -35732,7 +35729,7 @@ snapshots: globby: 11.1.0 ora: 5.4.1 slash: 3.0.0 - tmp: 0.2.6 + tmp: 0.2.7 workerpool: 6.5.1 transitivePeerDependencies: - supports-color @@ -36782,7 +36779,7 @@ snapshots: dependencies: chardet: 0.7.0 iconv-lite: 0.4.24 - tmp: 0.2.6 + tmp: 0.2.7 extract-stack@2.0.0: {} @@ -37081,12 +37078,12 @@ snapshots: fixturify-project@1.10.0: dependencies: fixturify: 1.3.0 - tmp: 0.2.6 + tmp: 0.2.7 fixturify-project@2.1.1: dependencies: fixturify: 2.1.1 - tmp: 0.2.6 + tmp: 0.2.7 type-fest: 0.11.0 fixturify@1.3.0: @@ -45492,7 +45489,7 @@ snapshots: spawn-args: 0.2.0 styled_string: 0.0.1 tap-parser: 7.0.0 - tmp: 0.2.6 + tmp: 0.2.7 transitivePeerDependencies: - '@babel/core' - arc-templates @@ -45644,6 +45641,8 @@ snapshots: tmp@0.2.6: {} + tmp@0.2.7: {} + tmpl@1.0.5: {} to-arraybuffer@1.0.1: {} @@ -46588,7 +46587,7 @@ snapshots: dependencies: heimdalljs-logger: 0.1.10 silent-error: 1.1.1 - tmp: 0.2.6 + tmp: 0.2.7 transitivePeerDependencies: - supports-color