diff --git a/ghost/core/core/server/api/endpoints/recommendations-public.js b/ghost/core/core/server/api/endpoints/recommendations-public.js index c88e2dded3..974466d815 100644 --- a/ghost/core/core/server/api/endpoints/recommendations-public.js +++ b/ghost/core/core/server/api/endpoints/recommendations-public.js @@ -15,7 +15,7 @@ module.exports = { permissions: true, validation: {}, async query(frame) { - return await recommendations.controller.listRecommendations(frame); + return await recommendations.controller.browse(frame); } }, diff --git a/ghost/core/core/server/api/endpoints/recommendations.js b/ghost/core/core/server/api/endpoints/recommendations.js index 5910689523..9744a24f23 100644 --- a/ghost/core/core/server/api/endpoints/recommendations.js +++ b/ghost/core/core/server/api/endpoints/recommendations.js @@ -15,7 +15,7 @@ module.exports = { permissions: true, validation: {}, async query(frame) { - return await recommendations.controller.listRecommendations(frame); + return await recommendations.controller.browse(frame); } }, @@ -28,7 +28,7 @@ module.exports = { validation: {}, permissions: true, async query(frame) { - return await recommendations.controller.addRecommendation(frame); + return await recommendations.controller.add(frame); } }, @@ -48,7 +48,7 @@ module.exports = { }, permissions: true, async query(frame) { - return await recommendations.controller.editRecommendation(frame); + return await recommendations.controller.edit(frame); } }, @@ -69,7 +69,7 @@ module.exports = { }, permissions: true, query(frame) { - return recommendations.controller.deleteRecommendation(frame); + return recommendations.controller.destroy(frame); } } }; 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 3933eb8ab6..333ec6f781 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,11 +115,11 @@ 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, + "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": false, + "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/, @@ -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": "386", + "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 \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -189,6 +189,72 @@ Object { } `; +exports[`Recommendations Admin API 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 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 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 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 Can fetch recommendations when there are none 1: [body] 1`] = ` Object { "meta": Object { @@ -295,11 +361,11 @@ Object { "subscribers": 3, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "excerpt": null, - "favicon": null, - "featured_image": null, + "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": false, + "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/, @@ -311,11 +377,11 @@ Object { "subscribers": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "excerpt": null, - "favicon": null, - "featured_image": null, + "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": false, + "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/, @@ -327,11 +393,11 @@ Object { "subscribers": 2, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "excerpt": null, - "favicon": null, - "featured_image": null, + "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": false, + "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/, @@ -343,11 +409,11 @@ Object { "subscribers": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "excerpt": null, - "favicon": null, - "featured_image": null, + "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": false, + "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/, @@ -359,11 +425,11 @@ Object { "subscribers": 0, }, "created_at": StringMatching /\\\\d\\{4\\}-\\\\d\\{2\\}-\\\\d\\{2\\}T\\\\d\\{2\\}:\\\\d\\{2\\}:\\\\d\\{2\\}\\\\\\.000Z/, - "excerpt": null, - "favicon": null, - "featured_image": null, + "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": false, + "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/, @@ -377,7 +443,7 @@ exports[`Recommendations Admin API Can include click and subscribe counts 2: [he 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": "1683", + "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 \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -504,6 +570,216 @@ Object { } `; +exports[`Recommendations Admin API 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 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 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 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 Can request pages 1: [body] 1`] = ` Object { "meta": Object { @@ -519,11 +795,11 @@ 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, + "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": false, + "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/, @@ -531,11 +807,11 @@ Object { }, 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, + "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": false, + "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/, @@ -543,11 +819,11 @@ Object { }, 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, + "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": false, + "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/, @@ -555,11 +831,11 @@ Object { }, 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, + "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": false, + "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/, @@ -567,11 +843,11 @@ Object { }, 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, + "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": false, + "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/, @@ -579,11 +855,11 @@ Object { }, 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, + "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": false, + "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/, @@ -591,11 +867,11 @@ Object { }, 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, + "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": false, + "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/, @@ -603,11 +879,11 @@ Object { }, 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, + "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": false, + "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/, @@ -615,11 +891,11 @@ Object { }, 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, + "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": false, + "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/, @@ -627,11 +903,11 @@ Object { }, 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, + "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": false, + "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/, @@ -645,7 +921,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": "2902", + "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 \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -669,11 +945,11 @@ 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, + "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": false, + "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/, @@ -681,11 +957,11 @@ Object { }, 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, + "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": false, + "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/, @@ -693,11 +969,11 @@ Object { }, 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, + "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": false, + "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/, @@ -705,11 +981,11 @@ Object { }, 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, + "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": false, + "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/, @@ -717,11 +993,11 @@ Object { }, 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, + "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": false, + "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/, @@ -735,7 +1011,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": "1497", + "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 \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -817,7 +1093,7 @@ Object { "errors": Array [ Object { "code": null, - "context": "Featured image must be a valid URL", + "context": "recommendations.0.featured_image must be a valid URL", "details": null, "ghostErrorCode": null, "help": null, @@ -834,7 +1110,7 @@ exports[`Recommendations Admin API Cannot use invalid protocols when editing 2: 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": "265", + "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 \\]\\|\\\\\\\\\\.\\)\\*"/, @@ -847,7 +1123,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": "1495", + "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 \\]\\|\\\\\\\\\\.\\)\\*"/, diff --git a/ghost/core/test/e2e-api/admin/recommendations.test.js b/ghost/core/test/e2e-api/admin/recommendations.test.js index 63eba72985..39e50d90c2 100644 --- a/ghost/core/test/e2e-api/admin/recommendations.test.js +++ b/ghost/core/test/e2e-api/admin/recommendations.test.js @@ -9,10 +9,10 @@ async function addDummyRecommendation(i = 0) { title: `Recommendation ${i}`, reason: `Reason ${i}`, url: new URL(`https://recommendation${i}.com`), - favicon: null, - featuredImage: null, - excerpt: null, - oneClickSubscribe: false, + favicon: new URL(`https://recommendation${i}.com/favicon.ico`), + featuredImage: new URL(`https://recommendation${i}.com/featured.jpg`), + excerpt: 'Test excerpt', + oneClickSubscribe: true, createdAt: new Date(i * 5000) // Reliable ordering }); @@ -27,6 +27,48 @@ async function addDummyRecommendations(amount = 15) { } } +async function addClicksAndSubscribers({memberId}) { + const recommendations = await recommendationsService.repository.getAll({order: [{field: 'createdAt', direction: 'desc'}]}); + + // Create 2 clicks for 1st + for (let i = 0; i < 2; i++) { + const clickEvent = ClickEvent.create({ + recommendationId: recommendations[0].id + }); + + await recommendationsService.clickEventRepository.save(clickEvent); + } + + // Create 3 clicks for 2nd + for (let i = 0; i < 3; i++) { + const clickEvent = ClickEvent.create({ + recommendationId: recommendations[1].id + }); + + await recommendationsService.clickEventRepository.save(clickEvent); + } + + // Create 3 subscribers for 1st + for (let i = 0; i < 3; i++) { + const subscribeEvent = SubscribeEvent.create({ + recommendationId: recommendations[0].id, + memberId + }); + + await recommendationsService.subscribeEventRepository.save(subscribeEvent); + } + + // Create 2 subscribers for 3rd + for (let i = 0; i < 2; i++) { + const subscribeEvent = SubscribeEvent.create({ + recommendationId: recommendations[2].id, + memberId + }); + + await recommendationsService.subscribeEventRepository.save(subscribeEvent); + } +} + describe('Recommendations Admin API', function () { let agent, memberId; @@ -206,6 +248,74 @@ describe('Recommendations Admin API', function () { 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(); @@ -327,45 +437,7 @@ describe('Recommendations Admin API', function () { it('Can include click and subscribe counts', async function () { await addDummyRecommendations(5); - const recommendations = await recommendationsService.repository.getAll({order: [{field: 'createdAt', direction: 'desc'}]}); - - // Create 2 clicks for 1st - for (let i = 0; i < 2; i++) { - const clickEvent = ClickEvent.create({ - recommendationId: recommendations[0].id - }); - - await recommendationsService.clickEventRepository.save(clickEvent); - } - - // Create 3 clicks for 2nd - for (let i = 0; i < 3; i++) { - const clickEvent = ClickEvent.create({ - recommendationId: recommendations[1].id - }); - - await recommendationsService.clickEventRepository.save(clickEvent); - } - - // Create 3 subscribers for 1st - for (let i = 0; i < 3; i++) { - const subscribeEvent = SubscribeEvent.create({ - recommendationId: recommendations[0].id, - memberId - }); - - await recommendationsService.subscribeEventRepository.save(subscribeEvent); - } - - // Create 2 subscribers for 3rd - for (let i = 0; i < 2; i++) { - const subscribeEvent = SubscribeEvent.create({ - recommendationId: recommendations[2].id, - memberId - }); - - await recommendationsService.subscribeEventRepository.save(subscribeEvent); - } + await addClicksAndSubscribers({memberId}); const {body: page1} = await agent.get('recommendations/?include=count.clicks,count.subscribers') .expectStatus(200) @@ -388,4 +460,56 @@ describe('Recommendations Admin API', function () { assert.equal(page1.recommendations[1].count.subscribers, 0); assert.equal(page1.recommendations[2].count.subscribers, 2); }); + + 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 + }) + }); + + 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); + }); }); diff --git a/ghost/recommendations/src/BookshelfRecommendationRepository.ts b/ghost/recommendations/src/BookshelfRecommendationRepository.ts index 96b1ad7aad..d64083ce8e 100644 --- a/ghost/recommendations/src/BookshelfRecommendationRepository.ts +++ b/ghost/recommendations/src/BookshelfRecommendationRepository.ts @@ -46,9 +46,9 @@ export class BookshelfRecommendationRepository extends BookshelfRepository { - entity: T; - includes: Map = new Map(); - - private constructor(entity: T) { - this.entity = entity; - } - - // eslint-disable-next-line no-shadow - static create(entity: Entity): EntityWithIncludes { - return new EntityWithIncludes(entity); - } - - setInclude(include: Includes, value: unknown) { - this.includes.set(include, value); - } -} diff --git a/ghost/recommendations/src/Recommendation.ts b/ghost/recommendations/src/Recommendation.ts index 4fe75e05ea..29915850ab 100644 --- a/ghost/recommendations/src/Recommendation.ts +++ b/ghost/recommendations/src/Recommendation.ts @@ -1,19 +1,38 @@ import ObjectId from 'bson-objectid'; import errors from '@tryghost/errors'; +import {UnsafeData} from './UnsafeData'; -export type AddRecommendation = { +/** + * We never expose Entities outside of services. Because we should never expose the bussiness logic methods. The plain objects are used for that + */ +export type RecommendationPlain = { + id: string, title: string reason: string|null excerpt: string|null // Fetched from the site meta data featuredImage: URL|null // Fetched from the site meta data favicon: URL|null // Fetched from the site meta data url: URL - oneClickSubscribe: boolean + oneClickSubscribe: boolean, + createdAt: Date, + updatedAt: Date|null } +export type RecommendationCreateData = { + id?: string + title: string + reason: string|null + excerpt: string|null // Fetched from the site meta data + featuredImage: URL|string|null // Fetched from the site meta data + favicon: URL|string|null // Fetched from the site meta data + url: URL|string + oneClickSubscribe: boolean + createdAt?: Date + updatedAt?: Date|null +} + +export type AddRecommendation = Omit export type EditRecommendation = Partial -type RecommendationConstructorData = AddRecommendation & {id: string, createdAt: Date, updatedAt: Date|null} -export type RecommendationCreateData = AddRecommendation & {id?: string, createdAt?: Date, updatedAt?: Date|null} export class Recommendation { id: string; @@ -33,7 +52,7 @@ export class Recommendation { return this.#deleted; } - private constructor(data: RecommendationConstructorData) { + private constructor(data: RecommendationPlain) { this.id = data.id; this.title = data.title; this.reason = data.reason; @@ -48,28 +67,6 @@ export class Recommendation { } static validate(properties: AddRecommendation) { - if (properties.url.protocol !== 'http:' && properties.url.protocol !== 'https:') { - throw new errors.ValidationError({ - message: 'url must be a valid URL' - }); - } - - if (properties.featuredImage !== null) { - if (properties.featuredImage.protocol !== 'http:' && properties.featuredImage.protocol !== 'https:') { - throw new errors.ValidationError({ - message: 'Featured image must be a valid URL' - }); - } - } - - if (properties.favicon !== null) { - if (properties.favicon.protocol !== 'http:' && properties.favicon.protocol !== 'https:') { - throw new errors.ValidationError({ - message: 'Favicon must be a valid URL' - }); - } - } - if (properties.title.length === 0) { throw new errors.ValidationError({ message: 'Title must not be empty' @@ -120,9 +117,9 @@ export class Recommendation { title: data.title, reason: data.reason, excerpt: data.excerpt, - featuredImage: data.featuredImage, - favicon: data.favicon, - url: data.url, + featuredImage: new UnsafeData(data.featuredImage).nullable.url, + favicon: new UnsafeData(data.favicon).nullable.url, + url: new UnsafeData(data.url).url, oneClickSubscribe: data.oneClickSubscribe, createdAt: data.createdAt ?? new Date(), updatedAt: data.updatedAt ?? null @@ -135,11 +132,38 @@ export class Recommendation { return recommendation; } - edit(properties: EditRecommendation) { - Recommendation.validate({...this, ...properties}); + get plain(): RecommendationPlain { + return { + id: this.id, + title: this.title, + reason: this.reason, + excerpt: this.excerpt, + featuredImage: this.featuredImage, + favicon: this.favicon, + url: this.url, + oneClickSubscribe: this.oneClickSubscribe, + createdAt: this.createdAt, + updatedAt: this.updatedAt + }; + } - Object.assign(this, properties); - this.clean(); + /** + * Change the specified properties. Properties that are set to undefined will not be changed + */ + edit(properties: EditRecommendation) { + // Delete undefined properties + const newProperties = this.plain; + + for (const key of Object.keys(properties) as (keyof EditRecommendation)[]) { + if (Object.prototype.hasOwnProperty.call(properties, key) && properties[key] !== undefined) { + (newProperties as Record)[key] = properties[key] as unknown; + } + } + + newProperties.updatedAt = new Date(); + + const created = Recommendation.create(newProperties); + Object.assign(this, created); } delete() { diff --git a/ghost/recommendations/src/RecommendationController.ts b/ghost/recommendations/src/RecommendationController.ts index 38f7eb5c36..5444378219 100644 --- a/ghost/recommendations/src/RecommendationController.ts +++ b/ghost/recommendations/src/RecommendationController.ts @@ -1,91 +1,16 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ -import {EntityWithIncludes} from './EntityWithIncludes'; -import {AddRecommendation, EditRecommendation, Recommendation} from './Recommendation'; -import {RecommendationInclude, RecommendationService} from './RecommendationService'; import errors from '@tryghost/errors'; +import {AddRecommendation, RecommendationPlain} from './Recommendation'; +import {RecommendationIncludeFields, RecommendationService, RecommendationWithIncludes} from './RecommendationService'; +import {UnsafeData} from './UnsafeData'; type Frame = { - data: any, - options: any, - user: any, - member: any, + data: unknown, + options: unknown, + user: unknown, + member: unknown, }; -function validateString(object: any, key: string, {required = true, nullable = false} = {}): string|undefined|null { - if (typeof object !== 'object' || object === null) { - throw new errors.BadRequestError({message: `${key} must be an object`}); - } - - if (nullable && object[key] === null) { - return null; - } - - if (object[key] !== undefined && object[key] !== null) { - if (typeof object[key] !== 'string') { - throw new errors.BadRequestError({message: `${key} must be a string`}); - } - return object[key]; - } else if (required) { - throw new errors.BadRequestError({message: `${key} is required`}); - } -} - -function validateBoolean(object: any, key: string, {required = true} = {}): boolean|undefined { - if (typeof object !== 'object' || object === null) { - throw new errors.BadRequestError({message: `${key} must be an object`}); - } - if (object[key] !== undefined) { - if (typeof object[key] !== 'boolean') { - throw new errors.BadRequestError({message: `${key} must be a boolean`}); - } - return object[key]; - } else if (required) { - throw new errors.BadRequestError({message: `${key} is required`}); - } -} - -function validateURL(object: any, key: string, {required = true, nullable = false} = {}): URL|undefined|null { - const string = validateString(object, key, {required, nullable}); - if (string === null) { - return null; - } - if (string !== undefined) { - try { - return new URL(string); - } catch (e) { - throw new errors.BadRequestError({message: `${key} must be a valid URL`}); - } - } -} - -function validateInteger(object: any, key: string, {required = true, nullable = false} = {}): number|undefined|null { - if (typeof object !== 'object' || object === null) { - throw new errors.BadRequestError({message: `${key} must be an object`}); - } - - if (nullable && object[key] === null) { - return null; - } - - if (object[key] !== undefined && object[key] !== null) { - if (typeof object[key] === 'string') { - // Try to cast to a number - const parsed = parseInt(object[key]); - if (isNaN(parsed) || !isFinite(parsed)) { - throw new errors.BadRequestError({message: `${key} must be a number`}); - } - return parsed; - } - - if (typeof object[key] !== 'number') { - throw new errors.BadRequestError({message: `${key} must be a number`}); - } - return object[key]; - } else if (required) { - throw new errors.BadRequestError({message: `${key} is required`}); - } -} - export class RecommendationController { service: RecommendationService; @@ -93,115 +18,130 @@ export class RecommendationController { this.service = deps.service; } - #getFrameId(frame: Frame): string { - if (!frame.options) { - throw new errors.BadRequestError(); - } + async add(frame: Frame) { + const data = new UnsafeData(frame.data); + const recommendation = data.key('recommendations').index(0); + const plain: AddRecommendation = { + title: recommendation.key('title').string, + url: recommendation.key('url').url, - const id = frame.options.id; - if (!id) { - throw new errors.BadRequestError(); - } + // Optional fields + oneClickSubscribe: recommendation.optionalKey('one_click_subscribe')?.boolean ?? false, + reason: recommendation.optionalKey('reason')?.nullable.string ?? null, + excerpt: recommendation.optionalKey('excerpt')?.nullable.string ?? null, + featuredImage: recommendation.optionalKey('featured_image')?.nullable.url ?? null, + favicon: recommendation.optionalKey('favicon')?.nullable.url ?? null + }; - return id; + return this.#serialize( + [await this.service.addRecommendation(plain)] + ); } - #getFrameInclude(frame: Frame, allowedIncludes: RecommendationInclude[]): RecommendationInclude[] { - if (!frame.options || !frame.options.withRelated) { - return []; - } + async edit(frame: Frame) { + const options = new UnsafeData(frame.options); + const data = new UnsafeData(frame.data); + const recommendation = data.key('recommendations').index(0); - const includes = frame.options.withRelated; + const id = options.key('id').string; + const plain: Partial = { + title: recommendation.optionalKey('title')?.string, + url: recommendation.optionalKey('url')?.url, + oneClickSubscribe: recommendation.optionalKey('one_click_subscribe')?.boolean, + reason: recommendation.optionalKey('reason')?.nullable.string, + excerpt: recommendation.optionalKey('excerpt')?.nullable.string, + featuredImage: recommendation.optionalKey('featured_image')?.nullable.url, + favicon: recommendation.optionalKey('favicon')?.nullable.url + }; - // Check if all includes are allowed - const invalidIncludes = includes.filter((i: unknown) => { - if (typeof i !== 'string') { - return true; + return this.#serialize( + [await this.service.editRecommendation(id, plain)] + ); + } + + async destroy(frame: Frame) { + const options = new UnsafeData(frame.options); + const id = options.key('id').string; + await this.service.deleteRecommendation(id); + } + + async browse(frame: Frame) { + const options = new UnsafeData(frame.options); + + const page = options.optionalKey('page')?.integer ?? 1; + const limit = options.optionalKey('limit')?.integer ?? 5; + const include = options.optionalKey('withRelated')?.array.map(item => item.enum(['count.clicks', 'count.subscribers'])) ?? []; + + const order = [ + { + field: 'createdAt' as const, + direction: 'desc' as const } - return !allowedIncludes.includes(i as RecommendationInclude); + ]; + + const count = await this.service.countRecommendations({}); + const recommendations = (await this.service.listRecommendations({page, limit, order, include})); + + return this.#serialize( + recommendations, + { + pagination: this.#serializePagination({page, limit, count}) + } + ); + } + + async trackClicked(frame: Frame) { + const member = this.#optionalAuthMember(frame); + const options = new UnsafeData(frame.options); + const id = options.key('id').string; + + await this.service.trackClicked({ + id, + memberId: member?.id }); + } + async trackSubscribed(frame: Frame) { + const member = this.#authMember(frame); + const options = new UnsafeData(frame.options); + const id = options.key('id').string; - if (invalidIncludes.length) { - throw new errors.BadRequestError({ - message: `Invalid include: ${invalidIncludes.join(',')}` - }); - } - return includes as RecommendationInclude[]; + await this.service.trackSubscribed({ + id, + memberId: member.id + }); } - #getFramePage(frame: Frame): number { - const page = validateInteger(frame.options, 'page', {required: false, nullable: true}) ?? 1; - if (page < 1) { - throw new errors.BadRequestError({message: 'page must be greater or equal to 1'}); - } - - return page; - } - - #getFrameLimit(frame: Frame, defaultLimit = 15): number { - const limit = validateInteger(frame.options, 'limit', {required: false, nullable: true}) ?? defaultLimit; - if (limit < 1) { - throw new errors.BadRequestError({message: 'limit must be greater or equal to 1'}); - } - return limit; - } - - #getFrameMemberId(frame: Frame): string { - if (!frame.options?.context?.member?.id) { + #authMember(frame: Frame): {id: string} { + const options = new UnsafeData(frame.options); + const memberId = options.key('context').optionalKey('member')?.nullable.key('id').string; + if (!memberId) { // This is an internal server error because authentication should happen outside this service. throw new errors.UnauthorizedError({ message: 'Member not found' }); } - return frame.options.context.member.id; - } - - #getFrameRecommendation(frame: Frame): AddRecommendation { - if (!frame.data || !frame.data.recommendations || !frame.data.recommendations[0]) { - throw new errors.BadRequestError(); - } - - const recommendation = frame.data.recommendations[0]; - - const cleanedRecommendation: AddRecommendation = { - title: validateString(recommendation, 'title') ?? '', - url: validateURL(recommendation, 'url')!, - - // Optional fields - oneClickSubscribe: validateBoolean(recommendation, 'one_click_subscribe', {required: false}) ?? false, - reason: validateString(recommendation, 'reason', {required: false, nullable: true}) ?? null, - excerpt: validateString(recommendation, 'excerpt', {required: false, nullable: true}) ?? null, - featuredImage: validateURL(recommendation, 'featured_image', {required: false, nullable: true}) ?? null, - favicon: validateURL(recommendation, 'favicon', {required: false, nullable: true}) ?? null - }; - - // Create a new recommendation - return cleanedRecommendation; - } - - #getFrameRecommendationEdit(frame: Frame): Partial { - if (!frame.data || !frame.data.recommendations || !frame.data.recommendations[0]) { - throw new errors.BadRequestError(); - } - - const recommendation = frame.data.recommendations[0]; - const cleanedRecommendation: EditRecommendation = { - title: validateString(recommendation, 'title', {required: false}) ?? undefined, - url: validateURL(recommendation, 'url', {required: false}) ?? undefined, - oneClickSubscribe: validateBoolean(recommendation, 'one_click_subscribe', {required: false}), - reason: validateString(recommendation, 'reason', {required: false, nullable: true}), - excerpt: validateString(recommendation, 'excerpt', {required: false, nullable: true}), - featuredImage: validateURL(recommendation, 'featured_image', {required: false, nullable: true}), - favicon: validateURL(recommendation, 'favicon', {required: false, nullable: true}) - }; - - // Create a new recommendation - return cleanedRecommendation; - } - - #returnRecommendations(recommendations: EntityWithIncludes[], meta?: any) { return { - data: recommendations.map(({entity, includes}) => { + id: memberId + }; + } + + #optionalAuthMember(frame: Frame): {id: string}|null { + try { + const member = this.#authMember(frame); + return member; + } catch (e) { + if (e instanceof errors.UnauthorizedError) { + // This is fine, this is not required + } else { + throw e; + } + } + return null; + } + + #serialize(recommendations: RecommendationWithIncludes[], meta?: any) { + return { + data: recommendations.map((entity) => { const d = { id: entity.id, title: entity.title, @@ -216,7 +156,7 @@ export class RecommendationController { count: undefined as undefined|{clicks?: number, subscribers?: number} }; - for (const [key, value] of includes) { + for (const [key, value] of Object.entries(entity)) { if (key === 'count.clicks') { if (typeof value !== 'number') { continue; @@ -238,12 +178,6 @@ export class RecommendationController { }; continue; } - - // This should never happen (if you get a compile error: check if you added all includes above) - const n: never = key; - throw new errors.BadRequestError({ - message: `Unsupported include: ${n}` - }); } return d; @@ -252,7 +186,7 @@ export class RecommendationController { }; } - #buildPagination({page, limit, count}: {page: number, limit: number, count: number}) { + #serializePagination({page, limit, count}: {page: number, limit: number, count: number}) { const pages = Math.ceil(count / limit); return { @@ -264,77 +198,4 @@ export class RecommendationController { next: page < pages ? page + 1 : null }; } - - async addRecommendation(frame: Frame) { - const recommendation = this.#getFrameRecommendation(frame); - return this.#returnRecommendations( - [await this.service.addRecommendation(recommendation)] - ); - } - - async editRecommendation(frame: Frame) { - const id = this.#getFrameId(frame); - const recommendationEdit = this.#getFrameRecommendationEdit(frame); - - return this.#returnRecommendations( - [await this.service.editRecommendation(id, recommendationEdit)] - ); - } - - async deleteRecommendation(frame: Frame) { - const id = this.#getFrameId(frame); - await this.service.deleteRecommendation(id); - } - - async listRecommendations(frame: Frame) { - const page = this.#getFramePage(frame); - const limit = this.#getFrameLimit(frame, 5); - const include = this.#getFrameInclude(frame, ['count.clicks', 'count.subscribers']); - const order = [ - { - field: 'createdAt' as const, - direction: 'desc' as const - } - ]; - - const count = await this.service.countRecommendations({}); - const data = (await this.service.listRecommendations({page, limit, order, include})); - - return this.#returnRecommendations( - data, - { - pagination: this.#buildPagination({page, limit, count}) - } - ); - } - - async trackClicked(frame: Frame) { - // First get the ID of the recommendation that was clicked - const id = this.#getFrameId(frame); - // Check type of event - let memberId: string | undefined; - try { - memberId = this.#getFrameMemberId(frame); - } catch (e) { - if (e instanceof errors.UnauthorizedError) { - // This is fine, this is not required - } else { - throw e; - } - } - - await this.service.trackClicked({ - id, - memberId - }); - } - async trackSubscribed(frame: Frame) { - // First get the ID of the recommendation that was clicked - const id = this.#getFrameId(frame); - const memberId = this.#getFrameMemberId(frame); - await this.service.trackSubscribed({ - id, - memberId - }); - } } diff --git a/ghost/recommendations/src/RecommendationService.ts b/ghost/recommendations/src/RecommendationService.ts index f2004e695e..56903388cd 100644 --- a/ghost/recommendations/src/RecommendationService.ts +++ b/ghost/recommendations/src/RecommendationService.ts @@ -1,14 +1,26 @@ import {BookshelfRepository, OrderOption} from '@tryghost/bookshelf-repository'; -import {AddRecommendation, Recommendation} from './Recommendation'; +import {AddRecommendation, Recommendation, RecommendationPlain} from './Recommendation'; import {RecommendationRepository} from './RecommendationRepository'; import {WellknownService} from './WellknownService'; import errors from '@tryghost/errors'; import tpl from '@tryghost/tpl'; import {ClickEvent} from './ClickEvent'; import {SubscribeEvent} from './SubscribeEvent'; -import {EntityWithIncludes} from './EntityWithIncludes'; -export type RecommendationInclude = 'count.clicks'|'count.subscribers'; +export type RecommendationIncludeTypes = { + 'count.clicks': number, + 'count.subscribers': number +}; +export type RecommendationIncludeFields = keyof RecommendationIncludeTypes; + +/** + * All includes are optional, but if they are explicitly loaded, they will not be optional in the result. + * + * E.g. RecommendationWithIncludes['count.clicks'|'count.subscribers']. + * + * When using methods like listRecommendations with the include option, the result will automatically return the correct relations + */ +export type RecommendationWithIncludes = RecommendationPlain & Partial & Record; type MentionSendingService = { sendAll(options: {url: URL, links: URL[]}): Promise @@ -49,7 +61,7 @@ export class RecommendationService { } async init() { - const recommendations = (await this.listRecommendations()).map(r => r.entity); + const recommendations = await this.#listRecommendations(); await this.updateWellknown(recommendations); } @@ -77,7 +89,7 @@ export class RecommendationService { }).catch(console.error); // eslint-disable-line no-console } - async addRecommendation(addRecommendation: AddRecommendation) { + async addRecommendation(addRecommendation: AddRecommendation): Promise { const recommendation = Recommendation.create(addRecommendation); // If a recommendation with this URL already exists, throw an error @@ -90,16 +102,16 @@ export class RecommendationService { await this.repository.save(recommendation); - const recommendations = (await this.listRecommendations()).map(r => r.entity); + const recommendations = await this.#listRecommendations(); await this.updateWellknown(recommendations); await this.updateRecommendationsEnabledSetting(recommendations); // Only send an update for the mentioned URL this.sendMentionToRecommendation(recommendation); - return EntityWithIncludes.create(recommendation); + return recommendation.plain; } - async editRecommendation(id: string, recommendationEdit: Partial) { + async editRecommendation(id: string, recommendationEdit: Partial): Promise { // Check if it exists const existing = await this.repository.getById(id); if (!existing) { @@ -111,11 +123,11 @@ export class RecommendationService { existing.edit(recommendationEdit); await this.repository.save(existing); - const recommendations = (await this.listRecommendations()).map(r => r.entity); + const recommendations = await this.#listRecommendations(); await this.updateWellknown(recommendations); this.sendMentionToRecommendation(existing); - return EntityWithIncludes.create(existing); + return existing.plain; } async deleteRecommendation(id: string) { @@ -129,7 +141,7 @@ export class RecommendationService { existing.delete(); await this.repository.save(existing); - const recommendations = (await this.listRecommendations()).map(r => r.entity); + const recommendations = await this.#listRecommendations(); await this.updateWellknown(recommendations); await this.updateRecommendationsEnabledSetting(recommendations); @@ -137,61 +149,73 @@ export class RecommendationService { this.sendMentionToRecommendation(existing); } - async listRecommendations({page, limit, filter, order, include}: { page: number; limit: number | 'all', filter?: string, order?: OrderOption, include?: RecommendationInclude[] } = {page: 1, limit: 'all'}): Promise[]> { + async #listRecommendations({page, limit, filter, order}: { page: number; limit: number | 'all', filter?: string, order?: OrderOption} = {page: 1, limit: 'all'}): Promise { let list: Recommendation[]; if (limit === 'all') { list = await this.repository.getAll({filter, order}); } else { + if (page < 1) { + throw new errors.BadRequestError({message: 'page must be greater or equal to 1'}); + } + if (limit < 1) { + throw new errors.BadRequestError({message: 'limit must be greater or equal to 1'}); + } list = await this.repository.getPage({page, limit, filter, order}); } - - // Transform to includes - const entities = list.map(entity => EntityWithIncludes.create(entity)); - await this.loadRelations(entities, include); - return entities; + return list; } - async loadRelations(list: EntityWithIncludes[], include?: RecommendationInclude[]) { + /** + * Same as #listRecommendations, but with includes and returns a plain object for external use + */ + async listRecommendations({page, limit, filter, order, include}: { page: number; limit: number | 'all', filter?: string, order?: OrderOption, include?: IncludeFields[] } = {page: 1, limit: 'all', include: []}): Promise[]> { + const list = await this.#listRecommendations({page, limit, filter, order}); + return await this.loadRelations(list, include); + } + + async loadRelations(list: Recommendation[], include?: IncludeFields[]): Promise[]> { + const plainList: RecommendationWithIncludes[] = list.map(e => e.plain); + if (!include || !include.length) { - return; + return plainList as RecommendationWithIncludes[]; } if (list.length === 0) { // Avoid doing queries with broken filters - return; + return plainList as RecommendationWithIncludes[]; } for (const relation of include) { switch (relation) { case 'count.clicks': - const clickCounts = await this.clickEventRepository.getGroupedCount({groupBy: 'recommendationId', filter: `recommendationId:[${list.map(entity => entity.entity.id).join(',')}]`}); + const clickCounts = await this.clickEventRepository.getGroupedCount({groupBy: 'recommendationId', filter: `recommendationId:[${list.map(entity => entity.id).join(',')}]`}); // Set all to zero by default - for (const entity of list) { - entity.setInclude(relation, 0); + for (const entity of plainList) { + entity[relation] = 0; } for (const r of clickCounts) { - const entity = list.find(e => e.entity.id === r.recommendationId); + const entity = plainList.find(e => e.id === r.recommendationId); if (entity) { - entity.setInclude(relation, r.count); + entity[relation] = r.count; } } break; case 'count.subscribers': - const subscribersCounts = await this.subscribeEventRepository.getGroupedCount({groupBy: 'recommendationId', filter: `recommendationId:[${list.map(entity => entity.entity.id).join(',')}]`}); + const subscribersCounts = await this.subscribeEventRepository.getGroupedCount({groupBy: 'recommendationId', filter: `recommendationId:[${list.map(entity => entity.id).join(',')}]`}); // Set all to zero by default - for (const entity of list) { - entity.setInclude(relation, 0); + for (const entity of plainList) { + entity[relation] = 0; } for (const r of subscribersCounts) { - const entity = list.find(e => e.entity.id === r.recommendationId); + const entity = plainList.find(e => e.id === r.recommendationId); if (entity) { - entity.setInclude(relation, r.count); + entity[relation] = r.count; } } @@ -202,6 +226,8 @@ export class RecommendationService { console.error(`Unknown relation ${r}`); // eslint-disable-line no-console } } + + return plainList as RecommendationWithIncludes[]; } async countRecommendations({filter}: { filter?: string }) { diff --git a/ghost/recommendations/src/UnsafeData.ts b/ghost/recommendations/src/UnsafeData.ts new file mode 100644 index 0000000000..d3ebb81765 --- /dev/null +++ b/ghost/recommendations/src/UnsafeData.ts @@ -0,0 +1,206 @@ +import errors from '@tryghost/errors'; + +type UnsafeDataContext = { + field?: string[] +} + +function serializeField(field: string[]) { + if (field.length === 0) { + return 'data'; + } + return field.join('.'); +} + +type NullData = { + readonly string: null, + readonly boolean: null, + readonly number: null, + readonly integer: null, + readonly url: null + enum(): null + key(key: string): NullData + optionalKey(key: string): NullData + readonly array: NullData + index(index: number): NullData +} + +/** + * NOTE: should be moved to a separate package in case this pattern is found to be useful + */ +export class UnsafeData { + protected data: unknown; + protected context: UnsafeDataContext; + + constructor(data: unknown, context: UnsafeDataContext = {}) { + this.data = data; + this.context = context; + } + + protected get field() { + return serializeField(this.context.field ?? []); + } + + protected addKeyToField(key: string) { + return this.context.field ? [...this.context.field, key] : [key]; + } + + protected fieldWithKey(key: string) { + return serializeField(this.addKeyToField(key)); + } + + /** + * Returns undefined if the key is not present on the object. Note that this doesn't check for null. + */ + optionalKey(key: string): UnsafeData|undefined { + if (typeof this.data !== 'object' || this.data === null) { + throw new errors.ValidationError({message: `${this.fieldWithKey(key)} must be an object`}); + } + + if (!Object.prototype.hasOwnProperty.call(this.data, key)) { + return undefined; + } + + return new UnsafeData((this.data as Record)[key], { + field: this.addKeyToField(key) + }); + } + + key(key: string): UnsafeData { + if (typeof this.data !== 'object' || this.data === null) { + throw new errors.ValidationError({message: `${this.fieldWithKey(key)} must be an object`}); + } + + if (!Object.prototype.hasOwnProperty.call(this.data, key)) { + throw new errors.ValidationError({message: `${this.fieldWithKey(key)} is required`}); + } + + return new UnsafeData((this.data as Record)[key], { + field: this.addKeyToField(key) + }); + } + + /** + * Use this to get a nullable value: + * ``` + * const url: string|null = data.key('url').nullable.string + * ``` + */ + get nullable(): UnsafeData|NullData { + if (this.data === null) { + const d: NullData = { + get string() { + return null; + }, + get boolean() { + return null; + }, + get number() { + return null; + }, + get integer() { + return null; + }, + get url() { + return null; + }, + enum() { + return null; + }, + key() { + return d; + }, + optionalKey() { + return d; + }, + get array() { + return d; + }, + index() { + return d; + } + }; + return d; + } + return this; + } + + get string(): string { + if (typeof this.data !== 'string') { + throw new errors.ValidationError({message: `${this.field} must be a string`}); + } + return this.data; + } + + get boolean(): boolean { + if (typeof this.data !== 'boolean') { + throw new errors.ValidationError({message: `${this.field} must be a boolean`}); + } + return this.data; + } + + get number(): number { + if (typeof this.data === 'string') { + return new UnsafeData(parseFloat(this.data), this.context).number; + } + + if (typeof this.data !== 'number') { + throw new errors.ValidationError({message: `${this.field} must be a number, got ${typeof this.data}`}); + } + if (Number.isNaN(this.data) || !Number.isFinite(this.data)) { + throw new errors.ValidationError({message: `${this.field} must be a finite number`}); + } + return this.data; + } + + get integer(): number { + if (typeof this.data === 'string') { + return new UnsafeData(parseInt(this.data), this.context).integer; + } + + const number = this.number; + if (!Number.isSafeInteger(number)) { + throw new errors.ValidationError({message: `${this.field} must be an integer`}); + } + return number; + } + + get url(): URL { + if (this.data instanceof URL) { + return this.data; + } + + const string = this.string; + try { + const url = new URL(string); + + if (!['http:', 'https:'].includes(url.protocol)) { + throw new errors.ValidationError({message: `${this.field} must be a valid URL`}); + } + return url; + } catch (e) { + throw new errors.ValidationError({message: `${this.field} must be a valid URL`}); + } + } + + enum(allowedValues: T[]): T { + if (!allowedValues.includes(this.data as T)) { + throw new errors.ValidationError({message: `${this.field} must be one of ${allowedValues.join(',')}`}); + } + return this.data as T; + } + + get array(): UnsafeData[] { + if (!Array.isArray(this.data)) { + throw new errors.ValidationError({message: `${this.field} must be an array`}); + } + return this.data.map((d, i) => new UnsafeData(d, {field: this.addKeyToField(`${i}`)})); + } + + index(index: number) { + const arr = this.array; + if (index < 0 || index >= arr.length) { + throw new errors.ValidationError({message: `${this.field} must be an array of length ${index + 1}`}); + } + return arr[index]; + } +};