diff --git a/apps/admin-x-settings/package.json b/apps/admin-x-settings/package.json index d2f54e3c09..e9172100a7 100644 --- a/apps/admin-x-settings/package.json +++ b/apps/admin-x-settings/package.json @@ -30,7 +30,7 @@ "build": "tsc && vite build", "lint": "yarn run lint:js", "lint:js": "eslint --ext .js,.ts,.cjs,.tsx --cache src test", - "test:unit": "yarn build", + "test:unit": "yarn build && vitest run", "test:e2e": "NODE_OPTIONS='--experimental-specifier-resolution=node --no-warnings' VITE_TEST=true playwright test", "test:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=100 yarn test:e2e --headed", "test:e2e:full": "ALL_BROWSERS=1 yarn test:e2e", diff --git a/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModal.tsx b/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModal.tsx index 8c125d52a2..daf1c07a3a 100644 --- a/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModal.tsx +++ b/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModal.tsx @@ -6,7 +6,8 @@ import React from 'react'; import URLTextField from '../../../../admin-x-ds/global/form/URLTextField'; import useForm from '../../../../hooks/useForm'; import useRouting from '../../../../hooks/useRouting'; -import {EditOrAddRecommendation} from '../../../../api/recommendations'; +import {EditOrAddRecommendation, useBrowseRecommendations} from '../../../../api/recommendations'; +import {arePathsEqual, trimSearchAndHash} from '../../../../utils/url'; import {showToast} from '../../../../admin-x-ds/global/Toast'; import {toast} from 'react-hot-toast'; import {useExternalGhostSite} from '../../../../api/external-ghost-site'; @@ -22,6 +23,7 @@ const AddRecommendationModal: React.FC = ({recommen const {updateRoute} = useRouting(); const {query: queryOembed} = useGetOembed(); const {query: queryExternalGhostSite} = useExternalGhostSite(); + const {data: {recommendations} = {}} = useBrowseRecommendations(); const {formState, updateForm, handleSave, errors, validate, saveState, clearError} = useForm({ initialState: recommendation ?? { @@ -34,28 +36,19 @@ const AddRecommendationModal: React.FC = ({recommen one_click_subscribe: false }, onSave: async () => { - let validatedUrl: URL | null = null; - try { - validatedUrl = new URL(formState.url); - } catch (e) { - // Ignore - } + let validatedUrl: URL; + validatedUrl = new URL(formState.url); + validatedUrl = trimSearchAndHash(validatedUrl); // First check if it s a Ghost site or not - let externalGhostSite = validatedUrl && validatedUrl.protocol === 'https:' ? (await queryExternalGhostSite('https://' + validatedUrl.host)) : null; - let defaultTitle = formState.title; - if (!defaultTitle) { - if (validatedUrl) { - defaultTitle = validatedUrl.hostname.replace('www.', ''); - } else { - // Ignore - defaultTitle = formState.url; - } - } + let externalGhostSite = validatedUrl.protocol === 'https:' ? (await queryExternalGhostSite('https://' + validatedUrl.host)) : null; + + // Use the hostname as fallback title + const defaultTitle = validatedUrl.hostname.replace('www.', ''); const updatedRecommendation = { ...formState, - title: defaultTitle + url: validatedUrl.toString() }; if (externalGhostSite) { @@ -65,7 +58,6 @@ const AddRecommendationModal: React.FC = ({recommen updatedRecommendation.featured_image = externalGhostSite.site.cover_image?.toString() ?? formState.featured_image ?? null; updatedRecommendation.favicon = externalGhostSite.site.icon?.toString() ?? externalGhostSite.site.logo?.toString() ?? formState.favicon ?? null; updatedRecommendation.one_click_subscribe = externalGhostSite.site.allow_self_signup; - updatedRecommendation.url = externalGhostSite.site.url.toString(); } else { // For non-Ghost sites, we use the Oemebd API to fetch metadata const oembed = await queryOembed({ @@ -95,10 +87,15 @@ const AddRecommendationModal: React.FC = ({recommen // Check domain includes a dot if (!u.hostname.includes('.')) { - newErrors.url = 'Please enter a valid URL'; + newErrors.url = 'Please enter a valid URL.'; + } + + // Check that it doesn't exist already + if (recommendations?.find(r => arePathsEqual(r.url, u.toString()))) { + newErrors.url = 'A recommendation with this URL already exists.'; } } catch (e) { - newErrors.url = 'Please enter a valid URL'; + newErrors.url = 'Please enter a valid URL.'; } return newErrors; @@ -133,18 +130,11 @@ const AddRecommendationModal: React.FC = ({recommen toast.remove(); try { - if (await handleSave({force: true})) { - // Already handled - } else { - showToast({ - type: 'pageError', - message: 'One or more fields have errors, please double check that you\'ve filled all mandatory fields.' - }); - } + await handleSave({force: true}); } catch (e) { showToast({ type: 'pageError', - message: 'Something went wrong while checking this URL, please try again' + message: 'Something went wrong while checking this URL, please try again.' }); } }} diff --git a/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModalConfirm.tsx b/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModalConfirm.tsx index b2906c07e5..f139e0c36d 100644 --- a/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModalConfirm.tsx +++ b/apps/admin-x-settings/src/components/settings/site/recommendations/AddRecommendationModalConfirm.tsx @@ -101,12 +101,12 @@ const AddRecommendationModalConfirm: React.FC = ({r } dismissAllToasts(); - if (await handleSave({force: true})) { - // Already handled - } else { + try { + await handleSave({force: true}); + } catch (e) { showToast({ type: 'pageError', - message: 'One or more fields have errors, please double check that you\'ve filled all mandatory fields.' + message: 'Something went wrong when adding this recommendation, please try again.' }); } }} diff --git a/apps/admin-x-settings/src/utils/url.ts b/apps/admin-x-settings/src/utils/url.ts new file mode 100644 index 0000000000..759b04bdd5 --- /dev/null +++ b/apps/admin-x-settings/src/utils/url.ts @@ -0,0 +1,38 @@ +export function trimSearch(url: URL) { + url.search = ''; + return url; +} + +export function trimHash(url: URL) { + url.hash = ''; + return url; +} + +export function trimSearchAndHash(url: URL) { + url.search = ''; + url.hash = ''; + return url; +} + +/* Compare two URLs based on their hostname and pathname. + * Query params, hash fragements, protocol and www are ignored. + * + * Example: + * - https://a.com, http://a.com, https://www.a.com, https://a.com?param1=value, https://a.com/#segment-1 are all considered equal + * - but, https://a.com/path-1 and https://a.com/path-2 are not + */ +export function arePathsEqual(urlStr1: string, urlStr2: string) { + let url1, url2; + + try { + url1 = new URL(urlStr1); + url2 = new URL(urlStr2); + } catch (e) { + return false; + } + + return ( + url1.hostname.replace('www.', '') === url2.hostname.replace('www.', '') && + url1.pathname === url2.pathname + ); +} diff --git a/apps/admin-x-settings/test/unit/utils/url.test.ts b/apps/admin-x-settings/test/unit/utils/url.test.ts new file mode 100644 index 0000000000..3d1466b822 --- /dev/null +++ b/apps/admin-x-settings/test/unit/utils/url.test.ts @@ -0,0 +1,94 @@ +import * as assert from 'assert/strict'; +import {arePathsEqual, trimHash, trimSearch, trimSearchAndHash} from '../../../src/utils/url'; + +describe('trimSearch', function () { + it('removes the query parameters from a URL', function () { + const url = 'https://example.com/?foo=bar&baz=qux'; + const parsedUrl = new URL(url); + + assert.equal(trimSearch(parsedUrl).toString(), 'https://example.com/'); + }); +}); + +describe('trimHash', function () { + it('removes the hash fragment from a URL', function () { + const url = 'https://example.com/path#section-1'; + const parsedUrl = new URL(url); + + assert.equal(trimHash(parsedUrl).toString(), 'https://example.com/path'); + }); +}); + +describe('trimSearchAndHash', function () { + it('removes the hash fragment from a URL', function () { + const url = 'https://example.com/path#section-1?foo=bar&baz=qux'; + const parsedUrl = new URL(url); + + assert.equal(trimSearchAndHash(parsedUrl).toString(), 'https://example.com/path'); + }); +}); + +describe('arePathsEqual', function () { + it('returns false if one of the param is not a URL', function () { + const url1 = 'foo'; + const url2 = 'https://example.com'; + + assert.equal(arePathsEqual(url1, url2), false); + }); + + it('returns false if hostnames are different', function () { + const url1 = 'https://a.com'; + const url2 = 'https://b.com'; + + assert.equal(arePathsEqual(url1, url2), false); + }); + + it('returns false if top level domains are different', function () { + const url1 = 'https://a.io'; + const url2 = 'https://a.com'; + + assert.equal(arePathsEqual(url1, url2), false); + }); + + it('returns false if sub domains are different', function () { + const url1 = 'https://sub.a.com'; + const url2 = 'https://subdiff.a.com'; + + assert.equal(arePathsEqual(url1, url2), false); + }); + + it('returns false if paths are different', function () { + const url1 = 'https://a.com/path-1'; + const url2 = 'https://a.com/path-2'; + + assert.equal(arePathsEqual(url1, url2), false); + }); + + it('returns true even if protocols are different', function () { + const url1 = 'http://a.com'; + const url2 = 'https://a.com'; + + assert.equal(arePathsEqual(url1, url2), true); + }); + + it('returns true even if www is used in one of the urls', function () { + const url1 = 'https://www.a.com'; + const url2 = 'https://a.com'; + + assert.equal(arePathsEqual(url1, url2), true); + }); + + it('returns true even if query parameters are different', function () { + const url1 = 'http://a.com?foo=bar'; + const url2 = 'http://a.com'; + + assert.equal(arePathsEqual(url1, url2), true); + }); + + it('returns true even if hash segments are different', function () { + const url1 = 'http://a.com#segment-1'; + const url2 = 'http://a.com'; + + assert.equal(arePathsEqual(url1, url2), true); + }); +}); diff --git a/apps/admin-x-settings/vite.config.ts b/apps/admin-x-settings/vite.config.ts index 611dac74ea..55a6e86227 100644 --- a/apps/admin-x-settings/vite.config.ts +++ b/apps/admin-x-settings/vite.config.ts @@ -74,8 +74,7 @@ export default (function viteConfig() { test: { globals: true, // required for @testing-library/jest-dom extensions environment: 'jsdom', - setupFiles: './test/test-setup.js', - include: ['./test/unit/*'], + include: ['./test/unit/**/*'], testTimeout: process.env.TIMEOUT ? parseInt(process.env.TIMEOUT) : 10000, ...(process.env.CI && { // https://github.com/vitest-dev/vitest/issues/1674 minThreads: 1, diff --git a/ghost/core/test/e2e-api/admin/__snapshots__/recommendations.test.js.snap b/ghost/core/test/e2e-api/admin/__snapshots__/recommendations.test.js.snap index 68abcff152..e31b6d1f18 100644 --- a/ghost/core/test/e2e-api/admin/__snapshots__/recommendations.test.js.snap +++ b/ghost/core/test/e2e-api/admin/__snapshots__/recommendations.test.js.snap @@ -115,12 +115,12 @@ Object { "recommendations": Array [ Object { "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "excerpt": "Dogs are cute", - "favicon": "https://dogpictures.com/favicon.ico", - "featured_image": "https://dogpictures.com/dog.jpg", + "excerpt": null, + "favicon": null, + "featured_image": null, "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "one_click_subscribe": true, - "reason": "Because dogs are cute", + "one_click_subscribe": false, + "reason": null, "title": "Dog Pictures", "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "url": "https://dogpictures.com/", @@ -133,7 +133,7 @@ exports[`Recommendations Admin API Can browse 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "463", + "content-length": "372", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -169,7 +169,7 @@ Object { "reason": "Because cats are cute", "title": "Cat Pictures", "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "https://catpictures.com/", + "url": "https://dogpictures.com/", }, ], } @@ -198,22 +198,10 @@ Object { "page": 1, "pages": 2, "prev": null, - "total": 16, + "total": 15, }, }, "recommendations": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "excerpt": "Dogs are cute", - "favicon": "https://dogpictures.com/favicon.ico", - "featured_image": "https://dogpictures.com/dog.jpg", - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "one_click_subscribe": true, - "reason": "Because dogs are cute", - "title": "Dog Pictures", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "https://dogpictures.com/", - }, Object { "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "excerpt": null, @@ -322,6 +310,18 @@ Object { "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "url": "https://recommendation6.com/", }, + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": null, + "favicon": null, + "featured_image": null, + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": false, + "reason": "Reason 5", + "title": "Recommendation 5", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation5.com/", + }, ], } `; @@ -330,7 +330,7 @@ exports[`Recommendations Admin API Can request pages 2: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "2979", + "content-length": "2902", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -348,22 +348,10 @@ Object { "page": 2, "pages": 2, "prev": 1, - "total": 16, + "total": 15, }, }, "recommendations": Array [ - Object { - "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "excerpt": null, - "favicon": null, - "featured_image": null, - "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, - "one_click_subscribe": false, - "reason": "Reason 5", - "title": "Recommendation 5", - "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "url": "https://recommendation5.com/", - }, Object { "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, "excerpt": null, @@ -432,7 +420,7 @@ exports[`Recommendations Admin API Can request pages 4: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "1775", + "content-length": "1497", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -507,7 +495,7 @@ exports[`Recommendations Admin API Uses default limit of 5 1: [headers] 1`] = ` Object { "access-control-allow-origin": "http://127.0.0.1:2369", "cache-control": "no-cache, private, no-store, must-revalidate, max-stale=0, post-check=0, pre-check=0", - "content-length": "1585", + "content-length": "109", "content-type": "application/json; charset=utf-8", "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/e2e-api/admin/recommendations.test.js b/ghost/core/test/e2e-api/admin/recommendations.test.js index bb6937ac0c..0ea79db1e5 100644 --- a/ghost/core/test/e2e-api/admin/recommendations.test.js +++ b/ghost/core/test/e2e-api/admin/recommendations.test.js @@ -4,6 +4,17 @@ const assert = require('assert/strict'); const recommendationsService = require('../../../core/server/services/recommendations'); const {Recommendation} = require('@tryghost/recommendations'); +async function addDummyRecommendation(agent) { + await agent.post('recommendations/').body({ + recommendations: [{ + title: 'Dog Pictures', + url: 'https://dogpictures.com' + }] + }); + const id = (await recommendationsService.repository.getAll())[0].id; + return id; +} + describe('Recommendations Admin API', function () { let agent; @@ -11,15 +22,14 @@ describe('Recommendations Admin API', function () { agent = await agentProvider.getAdminAPIAgent(); await fixtureManager.init('posts'); await agent.loginAsOwner(); + }); - // Clear placeholders + afterEach(async function () { for (const recommendation of (await recommendationsService.repository.getAll())) { recommendation.delete(); await recommendationsService.repository.save(recommendation); } - }); - afterEach(function () { mockManager.restore(); }); @@ -94,13 +104,32 @@ describe('Recommendations Admin API', function () { assert.equal(body.recommendations[0].one_click_subscribe, true); }); + it('Cannot add the same recommendation twice', async function () { + await agent.post('recommendations/') + .body({ + recommendations: [{ + title: 'Dog Pictures', + url: 'https://dogpictures.com' + }] + }); + + await agent.post('recommendations/') + .body({ + recommendations: [{ + title: 'Dog Pictures 2', + url: 'https://dogpictures.com' + }] + }) + .expectStatus(422); + }); + it('Can edit recommendation', async function () { - const id = (await recommendationsService.repository.getAll())[0].id; + const id = await addDummyRecommendation(agent); const {body} = await agent.put(`recommendations/${id}/`) .body({ recommendations: [{ title: 'Cat Pictures', - url: 'https://catpictures.com', + url: 'https://dogpictures.com', reason: 'Because cats are cute', excerpt: 'Cats are cute', featured_image: 'https://catpictures.com/cat.jpg', @@ -126,7 +155,7 @@ describe('Recommendations Admin API', function () { // Check everything is set correctly assert.equal(body.recommendations[0].id, id); assert.equal(body.recommendations[0].title, 'Cat Pictures'); - assert.equal(body.recommendations[0].url, 'https://catpictures.com/'); + assert.equal(body.recommendations[0].url, 'https://dogpictures.com/'); assert.equal(body.recommendations[0].reason, 'Because cats are cute'); assert.equal(body.recommendations[0].excerpt, 'Cats are cute'); assert.equal(body.recommendations[0].featured_image, 'https://catpictures.com/cat.jpg'); @@ -135,16 +164,17 @@ describe('Recommendations Admin API', function () { }); it('Cannot use invalid protocols when editing', async function () { - const id = (await recommendationsService.repository.getAll())[0].id; + const id = await addDummyRecommendation(agent); + await agent.put(`recommendations/${id}/`) .body({ recommendations: [{ title: 'Cat Pictures', - url: 'https://catpictures.com', + url: 'https://dogpictures.com', reason: 'Because cats are cute', excerpt: 'Cats are cute', - featured_image: 'ftp://catpictures.com/cat.jpg', - favicon: 'ftp://catpictures.com/favicon.ico', + featured_image: 'ftp://dogpictures.com/dog.jpg', + favicon: 'ftp://dogpictures.com/favicon.ico', one_click_subscribe: false }] }) @@ -163,7 +193,7 @@ describe('Recommendations Admin API', function () { }); it('Can delete recommendation', async function () { - const id = (await recommendationsService.repository.getAll())[0].id; + const id = await addDummyRecommendation(agent); await agent.delete(`recommendations/${id}/`) .expectStatus(204) .matchHeaderSnapshot({ @@ -174,6 +204,8 @@ describe('Recommendations Admin API', function () { }); it('Can browse', async function () { + await addDummyRecommendation(agent); + await agent.get('recommendations/') .expectStatus(200) .matchHeaderSnapshot({ @@ -227,7 +259,7 @@ describe('Recommendations Admin API', function () { assert.equal(page1.meta.pagination.pages, 2); assert.equal(page1.meta.pagination.next, 2); assert.equal(page1.meta.pagination.prev, null); - assert.equal(page1.meta.pagination.total, 16); + assert.equal(page1.meta.pagination.total, 15); const {body: page2} = await agent.get('recommendations/?page=2&limit=10') .expectStatus(200) @@ -236,7 +268,7 @@ describe('Recommendations Admin API', function () { etag: anyEtag }) .matchBodySnapshot({ - recommendations: new Array(6).fill({ + recommendations: new Array(5).fill({ id: anyObjectId, created_at: anyISODateTime, updated_at: anyISODateTime @@ -248,7 +280,7 @@ describe('Recommendations Admin API', function () { assert.equal(page2.meta.pagination.pages, 2); assert.equal(page2.meta.pagination.next, null); assert.equal(page2.meta.pagination.prev, 1); - assert.equal(page2.meta.pagination.total, 16); + assert.equal(page2.meta.pagination.total, 15); }); it('Uses default limit of 5', async function () { diff --git a/ghost/recommendations/src/BookshelfRecommendationRepository.ts b/ghost/recommendations/src/BookshelfRecommendationRepository.ts index 5f2b76dbd5..96b1ad7aad 100644 --- a/ghost/recommendations/src/BookshelfRecommendationRepository.ts +++ b/ghost/recommendations/src/BookshelfRecommendationRepository.ts @@ -7,10 +7,19 @@ type Sentry = { captureException(err: unknown): void; } +type RecommendationFindOneData = { + id?: T; + url?: string; +}; + +type RecommendationModelClass = ModelClass & { + findOne: (data: RecommendationFindOneData, options?: { require?: boolean }) => Promise | null>; +}; + export class BookshelfRecommendationRepository extends BookshelfRepository implements RecommendationRepository { sentry?: Sentry; - constructor(Model: ModelClass, deps: {sentry?: Sentry} = {}) { + constructor(Model: RecommendationModelClass, deps: {sentry?: Sentry} = {}) { super(Model); this.sentry = deps.sentry; } @@ -65,4 +74,9 @@ export class BookshelfRecommendationRepository extends BookshelfRepository; } + + async getByUrl(url: URL): Promise { + const model = await (this.Model as RecommendationModelClass).findOne({url: url.toString()}, {require: false}); + return model ? this.modelToEntity(model) : null; + } } diff --git a/ghost/recommendations/src/InMemoryRecommendationRepository.ts b/ghost/recommendations/src/InMemoryRecommendationRepository.ts index a1ba20507d..95835e1ff4 100644 --- a/ghost/recommendations/src/InMemoryRecommendationRepository.ts +++ b/ghost/recommendations/src/InMemoryRecommendationRepository.ts @@ -6,4 +6,10 @@ export class InMemoryRecommendationRepository extends InMemoryRepository { + return this.getAll().then((recommendations) => { + return recommendations.find(recommendation => recommendation.url.toString() === url.toString()) || null; + }); + } } diff --git a/ghost/recommendations/src/Recommendation.ts b/ghost/recommendations/src/Recommendation.ts index 8c76e72bea..4fe75e05ea 100644 --- a/ghost/recommendations/src/Recommendation.ts +++ b/ghost/recommendations/src/Recommendation.ts @@ -100,10 +100,18 @@ export class Recommendation { this.reason = null; } + this.url = this.cleanURL(this.url); this.createdAt.setMilliseconds(0); this.updatedAt?.setMilliseconds(0); } + cleanURL(url: URL) { + url.search = ''; + url.hash = ''; + + return url; + }; + static create(data: RecommendationCreateData) { const id = data.id ?? ObjectId().toString(); @@ -123,6 +131,7 @@ export class Recommendation { this.validate(d); const recommendation = new Recommendation(d); recommendation.clean(); + return recommendation; } diff --git a/ghost/recommendations/src/RecommendationRepository.ts b/ghost/recommendations/src/RecommendationRepository.ts index c89b26535e..a569a7f3aa 100644 --- a/ghost/recommendations/src/RecommendationRepository.ts +++ b/ghost/recommendations/src/RecommendationRepository.ts @@ -4,6 +4,7 @@ import {Recommendation} from './Recommendation'; export interface RecommendationRepository { save(entity: Recommendation): Promise; getById(id: string): Promise; + getByUrl(url: URL): Promise; getAll({filter, order}?: {filter?: string, order?: OrderOption}): Promise; getPage({filter, order, page, limit}: { filter?: string; diff --git a/ghost/recommendations/src/RecommendationService.ts b/ghost/recommendations/src/RecommendationService.ts index 41ae3f4664..c9385df214 100644 --- a/ghost/recommendations/src/RecommendationService.ts +++ b/ghost/recommendations/src/RecommendationService.ts @@ -76,6 +76,15 @@ export class RecommendationService { async addRecommendation(addRecommendation: AddRecommendation) { const recommendation = Recommendation.create(addRecommendation); + + // If a recommendation with this URL already exists, throw an error + const existing = await this.repository.getByUrl(recommendation.url); + if (existing) { + throw new errors.ValidationError({ + message: 'A recommendation with this URL already exists.' + }); + } + await this.repository.save(recommendation); const recommendations = await this.listRecommendations();