From d5ad5d2dd03793c215cb954f4a5e3c3a94eaf5c5 Mon Sep 17 00:00:00 2001 From: Sag Date: Tue, 19 Sep 2023 15:56:26 +0200 Subject: [PATCH] Added read endpoint to the Recommendations Admin API (#18221) closes https://github.com/TryGhost/Product/issues/3910 --- .../server/api/endpoints/recommendations.js | 14 + .../server/web/api/endpoints/admin/routes.js | 1 + .../recommendations.test.js.snap | 956 ++++++++++++++++++ .../e2e-api/admin/recommendations.test.js | 823 ++++++++------- .../src/RecommendationController.ts | 11 + .../src/RecommendationService.ts | 12 + 6 files changed, 1435 insertions(+), 382 deletions(-) diff --git a/ghost/core/core/server/api/endpoints/recommendations.js b/ghost/core/core/server/api/endpoints/recommendations.js index 9744a24f23..1010b1d540 100644 --- a/ghost/core/core/server/api/endpoints/recommendations.js +++ b/ghost/core/core/server/api/endpoints/recommendations.js @@ -19,6 +19,20 @@ module.exports = { } }, + read: { + headers: { + cacheInvalidate: false + }, + options: [ + 'id' + ], + permissions: true, + validation: {}, + async query(frame) { + return await recommendations.controller.read(frame); + } + }, + add: { statusCode: 201, headers: { diff --git a/ghost/core/core/server/web/api/endpoints/admin/routes.js b/ghost/core/core/server/web/api/endpoints/admin/routes.js index e036695640..626b8657ac 100644 --- a/ghost/core/core/server/web/api/endpoints/admin/routes.js +++ b/ghost/core/core/server/web/api/endpoints/admin/routes.js @@ -349,6 +349,7 @@ module.exports = function apiRoutes() { // Recommendations router.get('/recommendations', mw.authAdminApi, http(api.recommendations.browse)); + router.get('/recommendations/:id', mw.authAdminApi, http(api.recommendations.read)); router.post('/recommendations', mw.authAdminApi, http(api.recommendations.add)); router.put('/recommendations/:id', mw.authAdminApi, http(api.recommendations.edit)); router.del('/recommendations/:id', mw.authAdminApi, http(api.recommendations.destroy)); 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 333ec6f781..06de1a1b20 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 @@ -1144,3 +1144,959 @@ Object { "x-powered-by": "Express", } `; + +exports[`Recommendations Admin API add Can add a full recommendation 1: [body] 1`] = ` +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", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Because dogs are cute", + "title": "Dog Pictures", + "updated_at": null, + "url": "https://dogpictures.com/", + }, + ], +} +`; + +exports[`Recommendations Admin API add Can add a full recommendation 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": "354", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/recommendations\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API add Can add a minimal recommendation 1: [body] 1`] = ` +Object { + "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": null, + "title": "Dog Pictures", + "updated_at": null, + "url": "https://dogpictures.com/", + }, + ], +} +`; + +exports[`Recommendations Admin API add Can add a minimal recommendation 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": "263", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "location": StringMatching /https\\?:\\\\/\\\\/\\.\\*\\?\\\\/recommendations\\\\/\\[a-f0-9\\]\\{24\\}\\\\//, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API add Cannot add the same recommendation twice 1: [body] 1`] = ` +Object { + "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": null, + "title": "Dog Pictures", + "updated_at": null, + "url": "https://dogpictures.com/", + }, + ], +} +`; + +exports[`Recommendations Admin API add Cannot add the same recommendation twice 2: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "A recommendation with this URL already exists.", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot save recommendation.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Recommendations Admin API browse Can browse 1: [body] 1`] = ` +Object { + "meta": Object { + "pagination": Object { + "limit": 5, + "next": null, + "page": 1, + "pages": 1, + "prev": null, + "total": 1, + }, + }, + "recommendations": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation0.com/favicon.ico", + "featured_image": "https://recommendation0.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 0", + "title": "Recommendation 0", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation0.com/", + }, + ], +} +`; + +exports[`Recommendations Admin API browse 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": "470", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API browse Can fetch recommendations with relations when there are no recommendations 1: [body] 1`] = ` +Object { + "meta": Object { + "pagination": Object { + "limit": 5, + "next": null, + "page": 1, + "pages": 0, + "prev": null, + "total": 0, + }, + }, + "recommendations": Array [], +} +`; + +exports[`Recommendations Admin API browse Can fetch recommendations with relations when there are no recommendations 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": "109", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API browse Can include click and subscribe counts 1: [body] 1`] = ` +Object { + "meta": Object { + "pagination": Object { + "limit": 5, + "next": null, + "page": 1, + "pages": 1, + "prev": null, + "total": 5, + }, + }, + "recommendations": Array [ + Object { + "count": Object { + "clicks": 2, + "subscribers": 3, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation4.com/favicon.ico", + "featured_image": "https://recommendation4.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 4", + "title": "Recommendation 4", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation4.com/", + }, + Object { + "count": Object { + "clicks": 3, + "subscribers": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation3.com/favicon.ico", + "featured_image": "https://recommendation3.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 3", + "title": "Recommendation 3", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation3.com/", + }, + Object { + "count": Object { + "clicks": 0, + "subscribers": 2, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation2.com/favicon.ico", + "featured_image": "https://recommendation2.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 2", + "title": "Recommendation 2", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation2.com/", + }, + Object { + "count": Object { + "clicks": 0, + "subscribers": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation1.com/favicon.ico", + "featured_image": "https://recommendation1.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 1", + "title": "Recommendation 1", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation1.com/", + }, + Object { + "count": Object { + "clicks": 0, + "subscribers": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation0.com/favicon.ico", + "featured_image": "https://recommendation0.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 0", + "title": "Recommendation 0", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation0.com/", + }, + ], +} +`; + +exports[`Recommendations Admin API browse Can include click and subscribe counts 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": "2103", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API browse Can include only clicks 1: [body] 1`] = ` +Object { + "meta": Object { + "pagination": Object { + "limit": 5, + "next": null, + "page": 1, + "pages": 1, + "prev": null, + "total": 5, + }, + }, + "recommendations": Array [ + Object { + "count": Object { + "clicks": 2, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation4.com/favicon.ico", + "featured_image": "https://recommendation4.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 4", + "title": "Recommendation 4", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation4.com/", + }, + Object { + "count": Object { + "clicks": 3, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation3.com/favicon.ico", + "featured_image": "https://recommendation3.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 3", + "title": "Recommendation 3", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation3.com/", + }, + Object { + "count": Object { + "clicks": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation2.com/favicon.ico", + "featured_image": "https://recommendation2.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 2", + "title": "Recommendation 2", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation2.com/", + }, + Object { + "count": Object { + "clicks": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation1.com/favicon.ico", + "featured_image": "https://recommendation1.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 1", + "title": "Recommendation 1", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation1.com/", + }, + Object { + "count": Object { + "clicks": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation0.com/favicon.ico", + "featured_image": "https://recommendation0.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 0", + "title": "Recommendation 0", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation0.com/", + }, + ], +} +`; + +exports[`Recommendations Admin API browse Can include only clicks 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": "2023", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API browse Can include only subscribers 1: [body] 1`] = ` +Object { + "meta": Object { + "pagination": Object { + "limit": 5, + "next": null, + "page": 1, + "pages": 1, + "prev": null, + "total": 5, + }, + }, + "recommendations": Array [ + Object { + "count": Object { + "subscribers": 3, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation4.com/favicon.ico", + "featured_image": "https://recommendation4.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 4", + "title": "Recommendation 4", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation4.com/", + }, + Object { + "count": Object { + "subscribers": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation3.com/favicon.ico", + "featured_image": "https://recommendation3.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 3", + "title": "Recommendation 3", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation3.com/", + }, + Object { + "count": Object { + "subscribers": 2, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation2.com/favicon.ico", + "featured_image": "https://recommendation2.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 2", + "title": "Recommendation 2", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation2.com/", + }, + Object { + "count": Object { + "subscribers": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation1.com/favicon.ico", + "featured_image": "https://recommendation1.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 1", + "title": "Recommendation 1", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation1.com/", + }, + Object { + "count": Object { + "subscribers": 0, + }, + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation0.com/favicon.ico", + "featured_image": "https://recommendation0.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 0", + "title": "Recommendation 0", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation0.com/", + }, + ], +} +`; + +exports[`Recommendations Admin API browse Can include only subscribers 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": "2048", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API browse Can request pages 1: [body] 1`] = ` +Object { + "meta": Object { + "pagination": Object { + "limit": 10, + "next": 2, + "page": 1, + "pages": 2, + "prev": null, + "total": 15, + }, + }, + "recommendations": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation14.com/favicon.ico", + "featured_image": "https://recommendation14.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 14", + "title": "Recommendation 14", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation14.com/", + }, + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation13.com/favicon.ico", + "featured_image": "https://recommendation13.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 13", + "title": "Recommendation 13", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation13.com/", + }, + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation12.com/favicon.ico", + "featured_image": "https://recommendation12.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 12", + "title": "Recommendation 12", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation12.com/", + }, + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation11.com/favicon.ico", + "featured_image": "https://recommendation11.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 11", + "title": "Recommendation 11", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation11.com/", + }, + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation10.com/favicon.ico", + "featured_image": "https://recommendation10.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 10", + "title": "Recommendation 10", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation10.com/", + }, + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation9.com/favicon.ico", + "featured_image": "https://recommendation9.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 9", + "title": "Recommendation 9", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation9.com/", + }, + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation8.com/favicon.ico", + "featured_image": "https://recommendation8.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 8", + "title": "Recommendation 8", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation8.com/", + }, + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation7.com/favicon.ico", + "featured_image": "https://recommendation7.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 7", + "title": "Recommendation 7", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation7.com/", + }, + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation6.com/favicon.ico", + "featured_image": "https://recommendation6.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 6", + "title": "Recommendation 6", + "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": "Test excerpt", + "favicon": "https://recommendation5.com/favicon.ico", + "featured_image": "https://recommendation5.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "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/", + }, + ], +} +`; + +exports[`Recommendations Admin API browse 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": "3752", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API browse Can request pages 3: [body] 1`] = ` +Object { + "meta": Object { + "pagination": Object { + "limit": 10, + "next": null, + "page": 2, + "pages": 2, + "prev": 1, + "total": 15, + }, + }, + "recommendations": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation4.com/favicon.ico", + "featured_image": "https://recommendation4.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 4", + "title": "Recommendation 4", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation4.com/", + }, + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation3.com/favicon.ico", + "featured_image": "https://recommendation3.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 3", + "title": "Recommendation 3", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation3.com/", + }, + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation2.com/favicon.ico", + "featured_image": "https://recommendation2.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 2", + "title": "Recommendation 2", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation2.com/", + }, + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation1.com/favicon.ico", + "featured_image": "https://recommendation1.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 1", + "title": "Recommendation 1", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation1.com/", + }, + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation0.com/favicon.ico", + "featured_image": "https://recommendation0.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 0", + "title": "Recommendation 0", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation0.com/", + }, + ], +} +`; + +exports[`Recommendations Admin API browse 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": "1917", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API browse 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": "1915", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API delete Can delete recommendation 1: [body] 1`] = `Object {}`; + +exports[`Recommendations Admin API delete Can delete recommendation 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-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API edit Can edit recommendation 1: [body] 1`] = ` +Object { + "recommendations": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Cats are cute", + "favicon": "https://catpictures.com/favicon.ico", + "featured_image": "https://catpictures.com/cat.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": false, + "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://dogpictures.com/", + }, + ], +} +`; + +exports[`Recommendations Admin API edit Can edit recommendation 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": "377", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API edit Can edit recommendation and set nullable fields to null 1: [body] 1`] = ` +Object { + "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": true, + "reason": null, + "title": "Recommendation 0", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation0.com/", + }, + ], +} +`; + +exports[`Recommendations Admin API edit Can edit recommendation and set nullable fields to null 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": "292", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API edit Can edit some fields of a recommendation without changing others 1: [body] 1`] = ` +Object { + "recommendations": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation0.com/favicon.ico", + "featured_image": "https://recommendation0.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 0", + "title": "Changed", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation0.com/", + }, + ], +} +`; + +exports[`Recommendations Admin API edit Can edit some fields of a recommendation without changing others 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": "374", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-cache-invalidate": "/*", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API edit Cannot use invalid protocols when editing 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "recommendations.0.featured_image must be a valid URL", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot edit recommendation.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Recommendations Admin API edit Cannot use invalid protocols when editing 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": "283", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API read can get a recommendation by ID 1: [body] 1`] = ` +Object { + "recommendations": Array [ + Object { + "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "excerpt": "Test excerpt", + "favicon": "https://recommendation1.com/favicon.ico", + "featured_image": "https://recommendation1.com/featured.jpg", + "id": StringMatching /\\[a-f0-9\\]\\{24\\}/, + "one_click_subscribe": true, + "reason": "Reason 1", + "title": "Recommendation 1", + "updated_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, + "url": "https://recommendation1.com/", + }, + ], +} +`; + +exports[`Recommendations Admin API read can get a recommendation by ID 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": "383", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; + +exports[`Recommendations Admin API read returns an empty array when the recommendation is not found 1: [body] 1`] = ` +Object { + "errors": Array [ + Object { + "code": null, + "context": "Validation (matches) failed for id undefined.id", + "details": null, + "ghostErrorCode": null, + "help": null, + "id": StringMatching /\\[a-f0-9\\]\\{8\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{4\\}-\\[a-f0-9\\]\\{12\\}/, + "message": "Validation error, cannot read recommendation.", + "property": null, + "type": "ValidationError", + }, + ], +} +`; + +exports[`Recommendations Admin API read returns an empty array when the recommendation is not found 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": "278", + "content-type": "application/json; charset=utf-8", + "content-version": StringMatching /v\\\\d\\+\\\\\\.\\\\d\\+/, + "etag": StringMatching /\\(\\?:W\\\\/\\)\\?"\\(\\?:\\[ !#-\\\\x7E\\\\x80-\\\\xFF\\]\\*\\|\\\\r\\\\n\\[\\\\t \\]\\|\\\\\\\\\\.\\)\\*"/, + "vary": "Accept-Version, Origin, Accept-Encoding", + "x-powered-by": "Express", +} +`; diff --git a/ghost/core/test/e2e-api/admin/recommendations.test.js b/ghost/core/test/e2e-api/admin/recommendations.test.js index 39e50d90c2..66a33e54fd 100644 --- a/ghost/core/test/e2e-api/admin/recommendations.test.js +++ b/ghost/core/test/e2e-api/admin/recommendations.test.js @@ -88,428 +88,487 @@ describe('Recommendations Admin API', function () { mockManager.restore(); }); - it('Can fetch recommendations with relations when there are no recommendations', async function () { - const recommendations = await recommendationsService.repository.getCount(); - assert.equal(recommendations, 0, 'This test expects there to be no recommendations'); + describe('browse', function () { + it('Can browse', async function () { + await addDummyRecommendation(); - const {body: page1} = await agent.get('recommendations/?include=count.clicks,count.subscribers') - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({}); + await agent.get('recommendations/') + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({ + recommendations: [ + { + id: anyObjectId, + created_at: anyISODateTime, + updated_at: anyISODateTime + } + ] + }); + }); - assert.equal(page1.recommendations.length, 0); - }); + it('Can request pages', async function () { + // Add 15 recommendations using the repository + await addDummyRecommendations(15); - it('Can add a minimal recommendation', async function () { - const {body} = await agent.post('recommendations/') - .body({ - recommendations: [{ - title: 'Dog Pictures', - url: 'https://dogpictures.com' - }] - }) - .expectStatus(201) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag, - location: anyLocationFor('recommendations') - }) - .matchBodySnapshot({ - recommendations: [ - { - id: anyObjectId, - created_at: anyISODateTime - } - ] - }); - - // Check everything is set correctly - assert.equal(body.recommendations[0].title, 'Dog Pictures'); - assert.equal(body.recommendations[0].url, 'https://dogpictures.com/'); - assert.equal(body.recommendations[0].reason, null); - assert.equal(body.recommendations[0].excerpt, null); - assert.equal(body.recommendations[0].featured_image, null); - assert.equal(body.recommendations[0].favicon, null); - assert.equal(body.recommendations[0].one_click_subscribe, false); - }); - - it('Can add a full recommendation', async function () { - const {body} = await agent.post('recommendations/') - .body({ - recommendations: [{ - title: 'Dog Pictures', - url: 'https://dogpictures.com', - reason: 'Because dogs are cute', - excerpt: 'Dogs are cute', - featured_image: 'https://dogpictures.com/dog.jpg', - favicon: 'https://dogpictures.com/favicon.ico', - one_click_subscribe: true - }] - }) - .expectStatus(201) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag, - location: anyLocationFor('recommendations') - }) - .matchBodySnapshot({ - recommendations: [ - { - id: anyObjectId, - created_at: anyISODateTime - } - ] - }); - - // Check everything is set correctly - assert.equal(body.recommendations[0].title, 'Dog Pictures'); - assert.equal(body.recommendations[0].url, 'https://dogpictures.com/'); - assert.equal(body.recommendations[0].reason, 'Because dogs are cute'); - assert.equal(body.recommendations[0].excerpt, 'Dogs are cute'); - assert.equal(body.recommendations[0].featured_image, 'https://dogpictures.com/dog.jpg'); - assert.equal(body.recommendations[0].favicon, 'https://dogpictures.com/favicon.ico'); - 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' - }] - }) - .matchBodySnapshot({ - recommendations: [ - { - id: anyObjectId, - created_at: anyISODateTime - } - ] - }); - - await agent.post('recommendations/') - .body({ - recommendations: [{ - title: 'Dog Pictures 2', - url: 'https://dogpictures.com' - }] - }) - .expectStatus(422) - .matchBodySnapshot({ - errors: [ - { - id: anyErrorId - } - ] - }); - }); - - it('Can edit recommendation', async function () { - const id = await addDummyRecommendation(); - const {body} = await agent.put(`recommendations/${id}/`) - .body({ - recommendations: [{ - title: 'Cat Pictures', - url: 'https://dogpictures.com', - reason: 'Because cats are cute', - excerpt: 'Cats are cute', - featured_image: 'https://catpictures.com/cat.jpg', - favicon: 'https://catpictures.com/favicon.ico', - one_click_subscribe: false - }] - }) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - recommendations: [ - { + const {body: page1} = await agent.get('recommendations/?page=1&limit=10') + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({ + recommendations: new Array(10).fill({ id: anyObjectId, created_at: anyISODateTime, updated_at: anyISODateTime - } - ] - }); + }) + }); - // 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://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'); - assert.equal(body.recommendations[0].favicon, 'https://catpictures.com/favicon.ico'); - assert.equal(body.recommendations[0].one_click_subscribe, false); - }); + assert.equal(page1.meta.pagination.page, 1); + assert.equal(page1.meta.pagination.limit, 10); + 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, 15); - it('Can edit recommendation and set nullable fields to null', async function () { - const id = await addDummyRecommendation(); - const {body} = await agent.put(`recommendations/${id}/`) - .body({ - recommendations: [{ - reason: null, - excerpt: null, - featured_image: null, - favicon: null - }] - }) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - recommendations: [ - { + const {body: page2} = await agent.get('recommendations/?page=2&limit=10') + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({ + recommendations: new Array(5).fill({ id: anyObjectId, created_at: anyISODateTime, updated_at: anyISODateTime - } - ] - }); + }) + }); - // Check everything is set correctly - assert.equal(body.recommendations[0].id, id); - assert.equal(body.recommendations[0].reason, null); - assert.equal(body.recommendations[0].excerpt, null); - assert.equal(body.recommendations[0].featured_image, null); - assert.equal(body.recommendations[0].favicon, null); - }); + assert.equal(page2.meta.pagination.page, 2); + assert.equal(page2.meta.pagination.limit, 10); + 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, 15); + }); - it('Can edit some fields of a recommendation without changing others', async function () { - const id = await addDummyRecommendation(); - const {body} = await agent.put(`recommendations/${id}/`) - .body({ - recommendations: [{ - title: 'Changed' - }] - }) - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - recommendations: [ - { + it('Uses default limit of 5', async function () { + await addDummyRecommendations(6); + const {body: page1} = await agent.get('recommendations/') + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }); + + assert.equal(page1.meta.pagination.limit, 5); + }); + + it('Can include click and subscribe counts', async function () { + await addDummyRecommendations(5); + await addClicksAndSubscribers({memberId}); + + const {body: page1} = await agent.get('recommendations/?include=count.clicks,count.subscribers') + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({ + recommendations: new Array(5).fill({ id: anyObjectId, created_at: anyISODateTime, updated_at: anyISODateTime - } - ] - }); + }) + }); - // Check everything is set correctly - assert.equal(body.recommendations[0].id, id); - assert.equal(body.recommendations[0].title, 'Changed'); - assert.equal(body.recommendations[0].url, 'https://recommendation0.com/'); - assert.equal(body.recommendations[0].reason, 'Reason 0'); - assert.equal(body.recommendations[0].excerpt, 'Test excerpt'); - assert.equal(body.recommendations[0].featured_image, 'https://recommendation0.com/featured.jpg'); - assert.equal(body.recommendations[0].favicon, 'https://recommendation0.com/favicon.ico'); - assert.equal(body.recommendations[0].one_click_subscribe, true); - }); + assert.equal(page1.recommendations[0].count.clicks, 2); + assert.equal(page1.recommendations[1].count.clicks, 3); - it('Cannot use invalid protocols when editing', async function () { - const id = await addDummyRecommendation(); + assert.equal(page1.recommendations[0].count.subscribers, 3); + assert.equal(page1.recommendations[1].count.subscribers, 0); + assert.equal(page1.recommendations[2].count.subscribers, 2); + }); - await agent.put(`recommendations/${id}/`) - .body({ - recommendations: [{ - title: 'Cat Pictures', - url: 'https://dogpictures.com', - reason: 'Because cats are cute', - excerpt: 'Cats are cute', - featured_image: 'ftp://dogpictures.com/dog.jpg', - favicon: 'ftp://dogpictures.com/favicon.ico', - one_click_subscribe: false - }] - }) - .expectStatus(422) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - errors: [ - { - id: anyErrorId - } - ] - }); - }); + it('Can include only clicks', async function () { + await addDummyRecommendations(5); + await addClicksAndSubscribers({memberId}); - it('Can delete recommendation', async function () { - const id = await addDummyRecommendation(); - await agent.delete(`recommendations/${id}/`) - .expectStatus(204) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({}); - }); - - it('Can browse', async function () { - await addDummyRecommendation(); - - await agent.get('recommendations/') - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - recommendations: [ - { + const {body: page1} = await agent.get('recommendations/?include=count.clicks') + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({ + recommendations: new Array(5).fill({ id: anyObjectId, created_at: anyISODateTime, updated_at: anyISODateTime - } - ] - }); + }) + }); + + assert.equal(page1.recommendations[0].count.clicks, 2); + assert.equal(page1.recommendations[1].count.clicks, 3); + + assert.equal(page1.recommendations[0].count.subscribers, undefined); + assert.equal(page1.recommendations[1].count.subscribers, undefined); + assert.equal(page1.recommendations[2].count.subscribers, undefined); + }); + + it('Can include only subscribers', async function () { + await addDummyRecommendations(5); + await addClicksAndSubscribers({memberId}); + + const {body: page1} = await agent.get('recommendations/?include=count.subscribers') + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({ + recommendations: new Array(5).fill({ + id: anyObjectId, + created_at: anyISODateTime, + updated_at: anyISODateTime + }) + }); + + assert.equal(page1.recommendations[0].count.clicks, undefined); + assert.equal(page1.recommendations[1].count.clicks, undefined); + + assert.equal(page1.recommendations[0].count.subscribers, 3); + assert.equal(page1.recommendations[1].count.subscribers, 0); + assert.equal(page1.recommendations[2].count.subscribers, 2); + }); + + it('Can fetch recommendations with relations when there are no recommendations', async function () { + const recommendations = await recommendationsService.repository.getCount(); + assert.equal(recommendations, 0, 'This test expects there to be no recommendations'); + + const {body: page1} = await agent.get('recommendations/?include=count.clicks,count.subscribers') + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({}); + + assert.equal(page1.recommendations.length, 0); + }); }); - it('Can request pages', async function () { - // Add 15 recommendations using the repository - await addDummyRecommendations(15); - - const {body: page1} = await agent.get('recommendations/?page=1&limit=10') - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - recommendations: new Array(10).fill({ - id: anyObjectId, - created_at: anyISODateTime, - updated_at: anyISODateTime + describe('read', function () { + it('can get a recommendation by ID', async function () { + const id = await addDummyRecommendation(1); + const {body} = await agent.get(`recommendations/${id}/`) + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag }) - }); + .matchBodySnapshot({ + recommendations: [ + { + id: anyObjectId, + created_at: anyISODateTime, + updated_at: anyISODateTime + } + ] + }); - assert.equal(page1.meta.pagination.page, 1); - assert.equal(page1.meta.pagination.limit, 10); - 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, 15); + // Check data + assert.equal(body.recommendations[0].id, id); + assert.equal(body.recommendations[0].title, 'Recommendation 1'); + assert.equal(body.recommendations[0].url, 'https://recommendation1.com/'); + assert.equal(body.recommendations[0].reason, 'Reason 1'); + assert.equal(body.recommendations[0].excerpt, 'Test excerpt'); + assert.equal(body.recommendations[0].featured_image, 'https://recommendation1.com/featured.jpg'); + assert.equal(body.recommendations[0].favicon, 'https://recommendation1.com/favicon.ico'); + assert.equal(body.recommendations[0].one_click_subscribe, true); + }); - const {body: page2} = await agent.get('recommendations/?page=2&limit=10') - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - recommendations: new Array(5).fill({ - id: anyObjectId, - created_at: anyISODateTime, - updated_at: anyISODateTime + it('returns an empty array when the recommendation is not found', async function () { + const id = 'i-dont-exist'; + const {body} = await agent.get(`recommendations/${id}/`) + .expectStatus(422) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag }) - }); + .matchBodySnapshot({ + errors: [ + { + id: anyErrorId + } + ] + }); - assert.equal(page2.meta.pagination.page, 2); - assert.equal(page2.meta.pagination.limit, 10); - 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, 15); + assert.equal(body.errors[0].type, 'ValidationError'); + assert.equal(body.errors[0].message, 'Validation error, cannot read recommendation.'); + }); }); - it('Uses default limit of 5', async function () { - await addDummyRecommendations(6); - const {body: page1} = await agent.get('recommendations/') - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }); + describe('edit', function () { + it('Can edit recommendation', async function () { + const id = await addDummyRecommendation(); + const {body} = await agent.put(`recommendations/${id}/`) + .body({ + recommendations: [{ + title: 'Cat Pictures', + url: 'https://dogpictures.com', + reason: 'Because cats are cute', + excerpt: 'Cats are cute', + featured_image: 'https://catpictures.com/cat.jpg', + favicon: 'https://catpictures.com/favicon.ico', + one_click_subscribe: false + }] + }) + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({ + recommendations: [ + { + id: anyObjectId, + created_at: anyISODateTime, + updated_at: anyISODateTime + } + ] + }); - assert.equal(page1.meta.pagination.limit, 5); + // 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://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'); + assert.equal(body.recommendations[0].favicon, 'https://catpictures.com/favicon.ico'); + assert.equal(body.recommendations[0].one_click_subscribe, false); + }); + + it('Can edit recommendation and set nullable fields to null', async function () { + const id = await addDummyRecommendation(); + const {body} = await agent.put(`recommendations/${id}/`) + .body({ + recommendations: [{ + reason: null, + excerpt: null, + featured_image: null, + favicon: null + }] + }) + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({ + recommendations: [ + { + id: anyObjectId, + created_at: anyISODateTime, + updated_at: anyISODateTime + } + ] + }); + + // Check everything is set correctly + assert.equal(body.recommendations[0].id, id); + assert.equal(body.recommendations[0].reason, null); + assert.equal(body.recommendations[0].excerpt, null); + assert.equal(body.recommendations[0].featured_image, null); + assert.equal(body.recommendations[0].favicon, null); + }); + + it('Can edit some fields of a recommendation without changing others', async function () { + const id = await addDummyRecommendation(); + const {body} = await agent.put(`recommendations/${id}/`) + .body({ + recommendations: [{ + title: 'Changed' + }] + }) + .expectStatus(200) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({ + recommendations: [ + { + id: anyObjectId, + created_at: anyISODateTime, + updated_at: anyISODateTime + } + ] + }); + + // Check everything is set correctly + assert.equal(body.recommendations[0].id, id); + assert.equal(body.recommendations[0].title, 'Changed'); + assert.equal(body.recommendations[0].url, 'https://recommendation0.com/'); + assert.equal(body.recommendations[0].reason, 'Reason 0'); + assert.equal(body.recommendations[0].excerpt, 'Test excerpt'); + assert.equal(body.recommendations[0].featured_image, 'https://recommendation0.com/featured.jpg'); + assert.equal(body.recommendations[0].favicon, 'https://recommendation0.com/favicon.ico'); + assert.equal(body.recommendations[0].one_click_subscribe, true); + }); + + it('Cannot use invalid protocols when editing', async function () { + const id = await addDummyRecommendation(); + + await agent.put(`recommendations/${id}/`) + .body({ + recommendations: [{ + title: 'Cat Pictures', + url: 'https://dogpictures.com', + reason: 'Because cats are cute', + excerpt: 'Cats are cute', + featured_image: 'ftp://dogpictures.com/dog.jpg', + favicon: 'ftp://dogpictures.com/favicon.ico', + one_click_subscribe: false + }] + }) + .expectStatus(422) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag + }) + .matchBodySnapshot({ + errors: [ + { + id: anyErrorId + } + ] + }); + }); }); - it('Can include click and subscribe counts', async function () { - await addDummyRecommendations(5); - await addClicksAndSubscribers({memberId}); - - const {body: page1} = await agent.get('recommendations/?include=count.clicks,count.subscribers') - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - recommendations: new Array(5).fill({ - id: anyObjectId, - created_at: anyISODateTime, - updated_at: anyISODateTime + describe('add', function () { + it('Can add a minimal recommendation', async function () { + const {body} = await agent.post('recommendations/') + .body({ + recommendations: [{ + title: 'Dog Pictures', + url: 'https://dogpictures.com' + }] }) - }); + .expectStatus(201) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag, + location: anyLocationFor('recommendations') + }) + .matchBodySnapshot({ + recommendations: [ + { + id: anyObjectId, + created_at: anyISODateTime + } + ] + }); - assert.equal(page1.recommendations[0].count.clicks, 2); - assert.equal(page1.recommendations[1].count.clicks, 3); + // Check everything is set correctly + assert.equal(body.recommendations[0].title, 'Dog Pictures'); + assert.equal(body.recommendations[0].url, 'https://dogpictures.com/'); + assert.equal(body.recommendations[0].reason, null); + assert.equal(body.recommendations[0].excerpt, null); + assert.equal(body.recommendations[0].featured_image, null); + assert.equal(body.recommendations[0].favicon, null); + assert.equal(body.recommendations[0].one_click_subscribe, false); + }); - assert.equal(page1.recommendations[0].count.subscribers, 3); - assert.equal(page1.recommendations[1].count.subscribers, 0); - assert.equal(page1.recommendations[2].count.subscribers, 2); + it('Can add a full recommendation', async function () { + const {body} = await agent.post('recommendations/') + .body({ + recommendations: [{ + title: 'Dog Pictures', + url: 'https://dogpictures.com', + reason: 'Because dogs are cute', + excerpt: 'Dogs are cute', + featured_image: 'https://dogpictures.com/dog.jpg', + favicon: 'https://dogpictures.com/favicon.ico', + one_click_subscribe: true + }] + }) + .expectStatus(201) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag, + location: anyLocationFor('recommendations') + }) + .matchBodySnapshot({ + recommendations: [ + { + id: anyObjectId, + created_at: anyISODateTime + } + ] + }); + + // Check everything is set correctly + assert.equal(body.recommendations[0].title, 'Dog Pictures'); + assert.equal(body.recommendations[0].url, 'https://dogpictures.com/'); + assert.equal(body.recommendations[0].reason, 'Because dogs are cute'); + assert.equal(body.recommendations[0].excerpt, 'Dogs are cute'); + assert.equal(body.recommendations[0].featured_image, 'https://dogpictures.com/dog.jpg'); + assert.equal(body.recommendations[0].favicon, 'https://dogpictures.com/favicon.ico'); + 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' + }] + }) + .matchBodySnapshot({ + recommendations: [ + { + id: anyObjectId, + created_at: anyISODateTime + } + ] + }); + + await agent.post('recommendations/') + .body({ + recommendations: [{ + title: 'Dog Pictures 2', + url: 'https://dogpictures.com' + }] + }) + .expectStatus(422) + .matchBodySnapshot({ + errors: [ + { + id: anyErrorId + } + ] + }); + }); }); - it('Can include only clicks', async function () { - await addDummyRecommendations(5); - await addClicksAndSubscribers({memberId}); - - const {body: page1} = await agent.get('recommendations/?include=count.clicks') - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - recommendations: new Array(5).fill({ - id: anyObjectId, - created_at: anyISODateTime, - updated_at: anyISODateTime + describe('delete', function () { + it('Can delete recommendation', async function () { + const id = await addDummyRecommendation(); + await agent.delete(`recommendations/${id}/`) + .expectStatus(204) + .matchHeaderSnapshot({ + 'content-version': anyContentVersion, + etag: anyEtag }) - }); - - assert.equal(page1.recommendations[0].count.clicks, 2); - assert.equal(page1.recommendations[1].count.clicks, 3); - - assert.equal(page1.recommendations[0].count.subscribers, undefined); - assert.equal(page1.recommendations[1].count.subscribers, undefined); - assert.equal(page1.recommendations[2].count.subscribers, undefined); - }); - - it('Can include only subscribers', async function () { - await addDummyRecommendations(5); - await addClicksAndSubscribers({memberId}); - - const {body: page1} = await agent.get('recommendations/?include=count.subscribers') - .expectStatus(200) - .matchHeaderSnapshot({ - 'content-version': anyContentVersion, - etag: anyEtag - }) - .matchBodySnapshot({ - recommendations: new Array(5).fill({ - id: anyObjectId, - created_at: anyISODateTime, - updated_at: anyISODateTime - }) - }); - - assert.equal(page1.recommendations[0].count.clicks, undefined); - assert.equal(page1.recommendations[1].count.clicks, undefined); - - assert.equal(page1.recommendations[0].count.subscribers, 3); - assert.equal(page1.recommendations[1].count.subscribers, 0); - assert.equal(page1.recommendations[2].count.subscribers, 2); + .matchBodySnapshot({}); + }); }); }); diff --git a/ghost/recommendations/src/RecommendationController.ts b/ghost/recommendations/src/RecommendationController.ts index 5444378219..62cca54428 100644 --- a/ghost/recommendations/src/RecommendationController.ts +++ b/ghost/recommendations/src/RecommendationController.ts @@ -18,6 +18,17 @@ export class RecommendationController { this.service = deps.service; } + async read(frame: Frame) { + const options = new UnsafeData(frame.options); + const id = options.key('id').string; + + const recommendation = await this.service.readRecommendation(id); + + return this.#serialize( + [recommendation] + ); + } + async add(frame: Frame) { const data = new UnsafeData(frame.data); const recommendation = data.key('recommendations').index(0); diff --git a/ghost/recommendations/src/RecommendationService.ts b/ghost/recommendations/src/RecommendationService.ts index 56903388cd..b127c0d367 100644 --- a/ghost/recommendations/src/RecommendationService.ts +++ b/ghost/recommendations/src/RecommendationService.ts @@ -89,6 +89,18 @@ export class RecommendationService { }).catch(console.error); // eslint-disable-line no-console } + async readRecommendation(id: string): Promise { + const recommendation = await this.repository.getById(id); + + if (!recommendation) { + throw new errors.NotFoundError({ + message: tpl(messages.notFound, {id}) + }); + } + + return recommendation.plain; + } + async addRecommendation(addRecommendation: AddRecommendation): Promise { const recommendation = Recommendation.create(addRecommendation);