Merge branch 'main' into webhook-author
This commit is contained in:
commit
230b4e1b79
@ -1,3 +1,4 @@
|
||||
import {resolve} from "path";
|
||||
import type { StorybookConfig } from "@storybook/react-vite";
|
||||
const config: StorybookConfig = {
|
||||
stories: ["../src/**/*.mdx", "../src/**/*.stories.@(js|jsx|ts|tsx)"],
|
||||
@ -20,6 +21,8 @@ const config: StorybookConfig = {
|
||||
async viteFinal(config, options) {
|
||||
config.resolve.alias = {
|
||||
crypto: require.resolve('rollup-plugin-node-builtins'),
|
||||
// @TODO: Remove this when @tryghost/nql is updated
|
||||
mingo: resolve(__dirname, '../../../node_modules/mingo/dist/mingo.js')
|
||||
}
|
||||
return config;
|
||||
},
|
||||
|
@ -60,6 +60,7 @@ const AddRecommendationModalConfirm: React.FC<AddRecommendationModalProps> = ({r
|
||||
label: 'Back',
|
||||
icon: 'arrow-left',
|
||||
iconColorClass: 'text-black dark:text-white',
|
||||
link: true,
|
||||
size: 'sm' as const,
|
||||
onClick: () => {
|
||||
if (saveState === 'saving') {
|
||||
|
@ -26,9 +26,9 @@ const RecommendationReasonForm: React.FC<Props<EditOrAddRecommendation | Recomme
|
||||
>
|
||||
<div>
|
||||
<Heading className='mb-2 block text-2xs font-semibold uppercase tracking-wider' grey={true} level={6}>Preview</Heading>
|
||||
<div className="flex items-center justify-center overflow-hidden rounded-sm border border-grey-200 bg-grey-50 px-4">
|
||||
<div className="w-full bg-white py-3 shadow">
|
||||
<div className="border-y border-grey-200 py-1">
|
||||
<div className="-mx-8 flex items-center justify-center overflow-hidden border border-grey-100 bg-grey-50 px-7 py-4">
|
||||
<div className="w-full rounded bg-white py-3 shadow">
|
||||
<div className="">
|
||||
<a className='flex items-center justify-between bg-white p-3' href={formState.url} rel="noopener noreferrer" target="_blank">
|
||||
<div className='flex flex-col gap-[2px]'>
|
||||
<div className="flex items-start gap-2">
|
||||
|
@ -141,9 +141,9 @@ export class Recommendation {
|
||||
title: data.title,
|
||||
reason: data.reason,
|
||||
excerpt: data.excerpt,
|
||||
featuredImage: new UnsafeData(data.featuredImage).nullable.url,
|
||||
favicon: new UnsafeData(data.favicon).nullable.url,
|
||||
url: new UnsafeData(data.url).url,
|
||||
featuredImage: new UnsafeData(data.featuredImage, {field: ['featuredImage']}).nullable.url,
|
||||
favicon: new UnsafeData(data.favicon, {field: ['favicon']}).nullable.url,
|
||||
url: new UnsafeData(data.url, {field: ['url']}).url,
|
||||
oneClickSubscribe: data.oneClickSubscribe,
|
||||
createdAt: data.createdAt ?? new Date(),
|
||||
updatedAt: data.updatedAt ?? null,
|
||||
|
@ -9,7 +9,6 @@ type Frame = {
|
||||
data: unknown,
|
||||
options: unknown,
|
||||
user: unknown,
|
||||
member: unknown,
|
||||
};
|
||||
|
||||
const RecommendationIncludesMap = {
|
||||
@ -101,10 +100,10 @@ export class RecommendationController {
|
||||
|
||||
const parts = str.split(',');
|
||||
const order: OrderOption<Recommendation> = [];
|
||||
for (const part of parts) {
|
||||
for (const [index, part] of parts.entries()) {
|
||||
const trimmed = part.trim();
|
||||
const fieldData = new UnsafeData(trimmed.split(' ')[0].trim());
|
||||
const directionData = new UnsafeData(trimmed.split(' ')[1]?.trim() ?? 'asc');
|
||||
const fieldData = new UnsafeData(trimmed.split(' ')[0].trim(), {field: ['order', index.toString(), 'field']});
|
||||
const directionData = new UnsafeData(trimmed.split(' ')[1]?.trim() ?? 'desc', {field: ['order', index.toString(), 'direction']});
|
||||
|
||||
const validatedField = fieldData.enum(
|
||||
Object.keys(RecommendationOrderMap) as (keyof typeof RecommendationOrderMap)[]
|
||||
@ -119,15 +118,6 @@ export class RecommendationController {
|
||||
});
|
||||
}
|
||||
|
||||
if (order.length === 0) {
|
||||
// Default order
|
||||
return [
|
||||
{
|
||||
field: 'createdAt' as const,
|
||||
direction: 'desc' as const
|
||||
}
|
||||
];
|
||||
}
|
||||
return order;
|
||||
}
|
||||
|
||||
@ -218,8 +208,8 @@ export class RecommendationController {
|
||||
favicon: entity.favicon?.toString() ?? null,
|
||||
url: entity.url.toString(),
|
||||
one_click_subscribe: entity.oneClickSubscribe,
|
||||
created_at: entity.createdAt,
|
||||
updated_at: entity.updatedAt,
|
||||
created_at: entity.createdAt.toISOString(),
|
||||
updated_at: entity.updatedAt?.toISOString() ?? null,
|
||||
count: entity.clickCount !== undefined || entity.subscriberCount !== undefined ? {
|
||||
clicks: entity.clickCount,
|
||||
subscribers: entity.subscriberCount
|
||||
|
@ -1,5 +1,6 @@
|
||||
import {BookshelfRepository, IncludeOption, OrderOption} from '@tryghost/bookshelf-repository';
|
||||
import {IncludeOption, OrderOption} from '@tryghost/bookshelf-repository';
|
||||
import errors from '@tryghost/errors';
|
||||
import {InMemoryRepository} from '@tryghost/in-memory-repository';
|
||||
import logging from '@tryghost/logging';
|
||||
import tpl from '@tryghost/tpl';
|
||||
import {ClickEvent} from './ClickEvent';
|
||||
@ -23,8 +24,8 @@ const messages = {
|
||||
|
||||
export class RecommendationService {
|
||||
repository: RecommendationRepository;
|
||||
clickEventRepository: BookshelfRepository<string, ClickEvent>;
|
||||
subscribeEventRepository: BookshelfRepository<string, SubscribeEvent>;
|
||||
clickEventRepository: InMemoryRepository<string, ClickEvent>;
|
||||
subscribeEventRepository: InMemoryRepository<string, SubscribeEvent>;
|
||||
|
||||
wellknownService: WellknownService;
|
||||
mentionSendingService: MentionSendingService;
|
||||
@ -32,8 +33,8 @@ export class RecommendationService {
|
||||
|
||||
constructor(deps: {
|
||||
repository: RecommendationRepository,
|
||||
clickEventRepository: BookshelfRepository<string, ClickEvent>,
|
||||
subscribeEventRepository: BookshelfRepository<string, SubscribeEvent>,
|
||||
clickEventRepository: InMemoryRepository<string, ClickEvent>,
|
||||
subscribeEventRepository: InMemoryRepository<string, SubscribeEvent>,
|
||||
wellknownService: WellknownService,
|
||||
mentionSendingService: MentionSendingService,
|
||||
recommendationEnablerService: RecommendationEnablerService
|
||||
|
@ -25,7 +25,6 @@ export class WellknownService {
|
||||
#formatRecommendation(recommendation: Recommendation) {
|
||||
return {
|
||||
url: recommendation.url,
|
||||
reason: recommendation.reason,
|
||||
updated_at: (recommendation.updatedAt ?? recommendation.createdAt).toISOString(),
|
||||
created_at: (recommendation.createdAt).toISOString()
|
||||
};
|
||||
|
673
ghost/recommendations/test/RecommendationController.test.ts
Normal file
673
ghost/recommendations/test/RecommendationController.test.ts
Normal file
@ -0,0 +1,673 @@
|
||||
import assert from 'assert/strict';
|
||||
import {RecommendationController, RecommendationService} from '../src';
|
||||
import sinon, {SinonSpy} from 'sinon';
|
||||
|
||||
describe('RecommendationController', function () {
|
||||
let service: Partial<RecommendationService>;
|
||||
let controller: RecommendationController;
|
||||
|
||||
beforeEach(function () {
|
||||
service = {};
|
||||
controller = new RecommendationController({service: service as RecommendationService});
|
||||
});
|
||||
|
||||
describe('read', function () {
|
||||
it('should return a recommendation', async function () {
|
||||
service.readRecommendation = async (id) => {
|
||||
return {
|
||||
id,
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: new URL('https://example.com/image.png'),
|
||||
favicon: new URL('https://example.com/favicon.ico'),
|
||||
url: new URL('https://example.com'),
|
||||
oneClickSubscribe: false,
|
||||
createdAt: new Date('2020-01-01T00:00:00.000Z'),
|
||||
updatedAt: null
|
||||
};
|
||||
};
|
||||
|
||||
const result = await controller.read({
|
||||
data: {},
|
||||
options: {
|
||||
id: '1'
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
data: [{
|
||||
id: '1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featured_image: 'https://example.com/image.png',
|
||||
favicon: 'https://example.com/favicon.ico',
|
||||
url: 'https://example.com/',
|
||||
one_click_subscribe: false,
|
||||
created_at: '2020-01-01T00:00:00.000Z',
|
||||
updated_at: null,
|
||||
count: undefined
|
||||
}],
|
||||
meta: undefined
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('add', function () {
|
||||
it('should add a recommendation', async function () {
|
||||
service.addRecommendation = async (plain) => {
|
||||
return {
|
||||
id: '1',
|
||||
title: plain.title,
|
||||
reason: plain.reason,
|
||||
excerpt: plain.excerpt,
|
||||
featuredImage: plain.featuredImage ? new URL(plain.featuredImage.toString()) : null,
|
||||
favicon: plain.favicon ? new URL(plain.favicon.toString()) : null,
|
||||
url: new URL(plain.url.toString()),
|
||||
oneClickSubscribe: plain.oneClickSubscribe,
|
||||
createdAt: new Date('2020-01-01T00:00:00.000Z'),
|
||||
updatedAt: null
|
||||
};
|
||||
};
|
||||
|
||||
const result = await controller.add({
|
||||
data: {
|
||||
recommendations: [
|
||||
{
|
||||
title: 'Test',
|
||||
reason: 'My reason',
|
||||
excerpt: 'My excerpt',
|
||||
featured_image: 'https://example.com/image.png',
|
||||
favicon: 'https://example.com/favicon.ico',
|
||||
url: 'https://example.com/',
|
||||
one_click_subscribe: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {},
|
||||
user: {}
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
data: [{
|
||||
id: '1',
|
||||
title: 'Test',
|
||||
reason: 'My reason',
|
||||
excerpt: 'My excerpt',
|
||||
featured_image: 'https://example.com/image.png',
|
||||
favicon: 'https://example.com/favicon.ico',
|
||||
url: 'https://example.com/',
|
||||
one_click_subscribe: true,
|
||||
created_at: '2020-01-01T00:00:00.000Z',
|
||||
updated_at: null,
|
||||
count: undefined
|
||||
}],
|
||||
meta: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('works with all optional fields missing', async function () {
|
||||
service.addRecommendation = async (plain) => {
|
||||
return {
|
||||
id: '1',
|
||||
title: plain.title,
|
||||
reason: plain.reason,
|
||||
excerpt: plain.excerpt,
|
||||
featuredImage: plain.featuredImage ? new URL(plain.featuredImage.toString()) : null,
|
||||
favicon: plain.favicon ? new URL(plain.favicon.toString()) : null,
|
||||
url: new URL(plain.url.toString()),
|
||||
oneClickSubscribe: plain.oneClickSubscribe,
|
||||
createdAt: new Date('2020-01-01T00:00:00.000Z'),
|
||||
updatedAt: null
|
||||
};
|
||||
};
|
||||
|
||||
const result = await controller.add({
|
||||
data: {
|
||||
recommendations: [
|
||||
{
|
||||
title: 'Test',
|
||||
url: 'https://example.com/'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {},
|
||||
user: {}
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
data: [{
|
||||
id: '1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featured_image: null,
|
||||
favicon: null,
|
||||
url: 'https://example.com/',
|
||||
one_click_subscribe: false,
|
||||
created_at: '2020-01-01T00:00:00.000Z',
|
||||
updated_at: null,
|
||||
count: undefined
|
||||
}],
|
||||
meta: undefined
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('edit', function () {
|
||||
it('should edit a recommendation', async function () {
|
||||
service.editRecommendation = async (id, edit) => {
|
||||
return {
|
||||
id: '1',
|
||||
title: edit.title || 'Test',
|
||||
reason: edit.reason || null,
|
||||
excerpt: edit.excerpt || null,
|
||||
featuredImage: edit.featuredImage ? new URL(edit.featuredImage.toString()) : null,
|
||||
favicon: edit.favicon ? new URL(edit.favicon.toString()) : null,
|
||||
url: edit.url ? new URL(edit.url.toString()) : new URL('https://example.com'),
|
||||
oneClickSubscribe: edit.oneClickSubscribe || false,
|
||||
createdAt: new Date('2020-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2020-01-01T00:00:00.000Z')
|
||||
};
|
||||
};
|
||||
|
||||
const result = await controller.edit({
|
||||
data: {
|
||||
recommendations: [
|
||||
{
|
||||
title: 'Test'
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
id: '1'
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
data: [{
|
||||
id: '1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featured_image: null,
|
||||
favicon: null,
|
||||
url: 'https://example.com/',
|
||||
one_click_subscribe: false,
|
||||
created_at: '2020-01-01T00:00:00.000Z',
|
||||
updated_at: '2020-01-01T00:00:00.000Z',
|
||||
count: undefined
|
||||
}],
|
||||
meta: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('works with all others keys', async function () {
|
||||
service.editRecommendation = async (id, edit) => {
|
||||
return {
|
||||
id: '1',
|
||||
title: edit.title || 'Test',
|
||||
reason: edit.reason || null,
|
||||
excerpt: edit.excerpt || null,
|
||||
featuredImage: edit.featuredImage ? new URL(edit.featuredImage.toString()) : null,
|
||||
favicon: edit.favicon ? new URL(edit.favicon.toString()) : null,
|
||||
url: edit.url ? new URL(edit.url.toString()) : new URL('https://example.com'),
|
||||
oneClickSubscribe: edit.oneClickSubscribe || false,
|
||||
createdAt: new Date('2020-01-01T00:00:00.000Z'),
|
||||
updatedAt: new Date('2020-01-01T00:00:00.000Z')
|
||||
};
|
||||
};
|
||||
|
||||
const result = await controller.edit({
|
||||
data: {
|
||||
recommendations: [
|
||||
{
|
||||
// All execpt title
|
||||
reason: 'My reason',
|
||||
excerpt: 'My excerpt',
|
||||
featured_image: 'https://example.com/image.png',
|
||||
favicon: 'https://example.com/favicon.ico',
|
||||
url: 'https://example.com/',
|
||||
one_click_subscribe: true
|
||||
}
|
||||
]
|
||||
},
|
||||
options: {
|
||||
id: '1'
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
data: [{
|
||||
id: '1',
|
||||
title: 'Test',
|
||||
reason: 'My reason',
|
||||
excerpt: 'My excerpt',
|
||||
featured_image: 'https://example.com/image.png',
|
||||
favicon: 'https://example.com/favicon.ico',
|
||||
url: 'https://example.com/',
|
||||
one_click_subscribe: true,
|
||||
created_at: '2020-01-01T00:00:00.000Z',
|
||||
updated_at: '2020-01-01T00:00:00.000Z',
|
||||
count: undefined
|
||||
}],
|
||||
meta: undefined
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('destroy', function () {
|
||||
it('should delete a recommendation', async function () {
|
||||
service.deleteRecommendation = async () => {
|
||||
return;
|
||||
};
|
||||
|
||||
const result = await controller.destroy({
|
||||
data: {},
|
||||
options: {
|
||||
id: '1'
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
|
||||
assert.deepEqual(result, undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('browse', function () {
|
||||
beforeEach(function () {
|
||||
service.listRecommendations = async () => {
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: new URL('https://example.com/image.png'),
|
||||
favicon: new URL('https://example.com/favicon.ico'),
|
||||
url: new URL('https://example.com'),
|
||||
oneClickSubscribe: false,
|
||||
createdAt: new Date('2020-01-01T00:00:00.000Z'),
|
||||
updatedAt: null
|
||||
}
|
||||
];
|
||||
};
|
||||
service.countRecommendations = async () => {
|
||||
return 1;
|
||||
};
|
||||
});
|
||||
|
||||
it('default options', async function () {
|
||||
const result = await controller.browse({
|
||||
data: {},
|
||||
options: {},
|
||||
user: {}
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
data: [{
|
||||
id: '1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featured_image: 'https://example.com/image.png',
|
||||
favicon: 'https://example.com/favicon.ico',
|
||||
url: 'https://example.com/',
|
||||
one_click_subscribe: false,
|
||||
created_at: '2020-01-01T00:00:00.000Z',
|
||||
updated_at: null,
|
||||
count: undefined
|
||||
}],
|
||||
meta: {
|
||||
pagination: {
|
||||
page: 1,
|
||||
limit: 5,
|
||||
pages: 1,
|
||||
total: 1,
|
||||
next: null,
|
||||
prev: null
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('all options', async function () {
|
||||
service.listRecommendations = async () => {
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: new URL('https://example.com/image.png'),
|
||||
favicon: new URL('https://example.com/favicon.ico'),
|
||||
url: new URL('https://example.com'),
|
||||
oneClickSubscribe: false,
|
||||
createdAt: new Date('2020-01-01T00:00:00.000Z'),
|
||||
updatedAt: null
|
||||
}
|
||||
];
|
||||
};
|
||||
service.countRecommendations = async () => {
|
||||
return 11;
|
||||
};
|
||||
const result = await controller.browse({
|
||||
data: {},
|
||||
options: {
|
||||
page: 2,
|
||||
limit: 5,
|
||||
filter: 'id:2'
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
|
||||
assert.deepEqual(result, {
|
||||
data: [{
|
||||
id: '1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featured_image: 'https://example.com/image.png',
|
||||
favicon: 'https://example.com/favicon.ico',
|
||||
url: 'https://example.com/',
|
||||
one_click_subscribe: false,
|
||||
created_at: '2020-01-01T00:00:00.000Z',
|
||||
updated_at: null,
|
||||
count: undefined
|
||||
}],
|
||||
meta: {
|
||||
pagination: {
|
||||
page: 2,
|
||||
limit: 5,
|
||||
pages: 3,
|
||||
total: 11,
|
||||
next: 3,
|
||||
prev: 1
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('order', function () {
|
||||
let listSpy: SinonSpy;
|
||||
|
||||
beforeEach(function () {
|
||||
listSpy = sinon.spy(service, 'listRecommendations');
|
||||
});
|
||||
|
||||
it('orders by createdAt by default', async function () {
|
||||
await controller.browse({
|
||||
data: {},
|
||||
options: {
|
||||
order: ''
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
assert(listSpy.calledOnce);
|
||||
const args = listSpy.getCall(0).args[0];
|
||||
assert.deepEqual(args.order, [
|
||||
{
|
||||
field: 'createdAt',
|
||||
direction: 'desc'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('order by custom field', async function () {
|
||||
await controller.browse({
|
||||
data: {},
|
||||
options: {
|
||||
order: 'created_at'
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
assert(listSpy.calledOnce);
|
||||
const args = listSpy.getCall(0).args[0];
|
||||
assert.deepEqual(args.order, [
|
||||
{
|
||||
field: 'createdAt',
|
||||
direction: 'desc'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('order by multiple custom field', async function () {
|
||||
await controller.browse({
|
||||
data: {},
|
||||
options: {
|
||||
order: 'created_at, count.clicks'
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
assert(listSpy.calledOnce);
|
||||
const args = listSpy.getCall(0).args[0];
|
||||
assert.deepEqual(args.order, [
|
||||
{
|
||||
field: 'createdAt',
|
||||
direction: 'desc'
|
||||
},
|
||||
{
|
||||
field: 'clickCount',
|
||||
direction: 'desc'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('order by multiple custom field with directions', async function () {
|
||||
await controller.browse({
|
||||
data: {},
|
||||
options: {
|
||||
order: 'created_at asc, count.clicks desc'
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
assert(listSpy.calledOnce);
|
||||
const args = listSpy.getCall(0).args[0];
|
||||
assert.deepEqual(args.order, [
|
||||
{
|
||||
field: 'createdAt',
|
||||
direction: 'asc'
|
||||
},
|
||||
{
|
||||
field: 'clickCount',
|
||||
direction: 'desc'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('cannot order by invalid fields', async function () {
|
||||
await assert.rejects(
|
||||
controller.browse({
|
||||
data: {},
|
||||
options: {
|
||||
order: 'invalid desc'
|
||||
},
|
||||
user: {}
|
||||
}),
|
||||
{
|
||||
message: 'order.0.field must be one of count.clicks, count.subscribers, created_at'
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('cannot order by invalid direction', async function () {
|
||||
await assert.rejects(
|
||||
controller.browse({
|
||||
data: {},
|
||||
options: {
|
||||
order: 'created_at down'
|
||||
},
|
||||
user: {}
|
||||
}),
|
||||
{
|
||||
message: 'order.0.direction must be one of asc, desc'
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('include', function () {
|
||||
let listSpy: SinonSpy;
|
||||
let rec = {
|
||||
id: '1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: new URL('https://example.com/image.png'),
|
||||
favicon: new URL('https://example.com/favicon.ico'),
|
||||
url: new URL('https://example.com'),
|
||||
oneClickSubscribe: false,
|
||||
createdAt: new Date('2020-01-01T00:00:00.000Z'),
|
||||
updatedAt: null,
|
||||
clickCount: 5,
|
||||
subscriberCount: 10
|
||||
};
|
||||
|
||||
beforeEach(function () {
|
||||
service.listRecommendations = async () => {
|
||||
return [
|
||||
rec
|
||||
];
|
||||
};
|
||||
listSpy = sinon.spy(service, 'listRecommendations');
|
||||
});
|
||||
|
||||
it('can include clicks and subscribes', async function () {
|
||||
await controller.browse({
|
||||
data: {},
|
||||
options: {
|
||||
withRelated: ['count.clicks', 'count.subscribers']
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
assert(listSpy.calledOnce);
|
||||
const args = listSpy.getCall(0).args[0];
|
||||
assert.deepEqual(args.include, ['clickCount', 'subscriberCount']);
|
||||
});
|
||||
|
||||
it('throws for invalid include', async function () {
|
||||
await assert.rejects(
|
||||
controller.browse({
|
||||
data: {},
|
||||
options: {
|
||||
withRelated: ['invalid']
|
||||
},
|
||||
user: {}
|
||||
}),
|
||||
{
|
||||
message: 'withRelated.0 must be one of count.clicks, count.subscribers'
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackClicked', function () {
|
||||
it('should track a click', async function () {
|
||||
service.trackClicked = async ({id, memberId}) => {
|
||||
assert.equal(id, '1');
|
||||
assert.equal(memberId, undefined);
|
||||
return;
|
||||
};
|
||||
|
||||
const result = await controller.trackClicked({
|
||||
data: {},
|
||||
options: {
|
||||
id: '1',
|
||||
context: {}
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
|
||||
assert.deepEqual(result, undefined);
|
||||
});
|
||||
|
||||
it('authenticated', async function () {
|
||||
service.trackClicked = async ({id, memberId}) => {
|
||||
assert.equal(id, '1');
|
||||
assert.equal(memberId, '1');
|
||||
return;
|
||||
};
|
||||
|
||||
const result = await controller.trackClicked({
|
||||
data: {},
|
||||
options: {
|
||||
id: '1',
|
||||
context: {
|
||||
member: {
|
||||
id: '1'
|
||||
}
|
||||
}
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
|
||||
assert.deepEqual(result, undefined);
|
||||
});
|
||||
|
||||
it('throws if invalid member context', async function () {
|
||||
await assert.rejects(async () => {
|
||||
await controller.trackClicked({
|
||||
data: {},
|
||||
options: {
|
||||
id: '1',
|
||||
context: {
|
||||
member: {
|
||||
missingId: 'example'
|
||||
}
|
||||
}
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
}, {
|
||||
message: 'context.member.id is required'
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackSubscribed', function () {
|
||||
it('works if authenticated', async function () {
|
||||
service.trackSubscribed = async () => {
|
||||
return;
|
||||
};
|
||||
|
||||
const result = await controller.trackSubscribed({
|
||||
data: {},
|
||||
options: {
|
||||
id: '1',
|
||||
context: {
|
||||
member: {
|
||||
id: '1'
|
||||
}
|
||||
}
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
|
||||
assert.deepEqual(result, undefined);
|
||||
});
|
||||
|
||||
it('throws if not authenticated', async function () {
|
||||
service.trackSubscribed = async () => {
|
||||
return;
|
||||
};
|
||||
|
||||
await assert.rejects(async () => {
|
||||
await controller.trackSubscribed({
|
||||
data: {},
|
||||
options: {
|
||||
id: '1',
|
||||
context: {}
|
||||
},
|
||||
user: {}
|
||||
});
|
||||
}, {
|
||||
message: 'Member not found'
|
||||
}, 'trackSubscribed should throw if not authenticated');
|
||||
});
|
||||
});
|
||||
});
|
451
ghost/recommendations/test/RecommendationService.test.ts
Normal file
451
ghost/recommendations/test/RecommendationService.test.ts
Normal file
@ -0,0 +1,451 @@
|
||||
import assert from 'assert/strict';
|
||||
import {ClickEvent, InMemoryRecommendationRepository, Recommendation, RecommendationService, SubscribeEvent, WellknownService} from '../src';
|
||||
import {InMemoryRepository} from '@tryghost/in-memory-repository';
|
||||
import sinon from 'sinon';
|
||||
|
||||
class InMemoryClickEventRepository<T extends ClickEvent|SubscribeEvent> extends InMemoryRepository<string, T> {
|
||||
toPrimitive(entity: T): object {
|
||||
return entity;
|
||||
}
|
||||
}
|
||||
|
||||
describe('RecommendationService', function () {
|
||||
let service: RecommendationService;
|
||||
let enabled = false;
|
||||
|
||||
beforeEach(function () {
|
||||
enabled = false;
|
||||
service = new RecommendationService({
|
||||
repository: new InMemoryRecommendationRepository(),
|
||||
clickEventRepository: new InMemoryClickEventRepository<ClickEvent>(),
|
||||
subscribeEventRepository: new InMemoryClickEventRepository<SubscribeEvent>(),
|
||||
wellknownService: {
|
||||
getPath() {
|
||||
return '';
|
||||
},
|
||||
getURL() {
|
||||
return new URL('http://localhost/.well-known/recommendations.json');
|
||||
},
|
||||
set() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
} as unknown as WellknownService,
|
||||
mentionSendingService: {
|
||||
sendAll() {
|
||||
return Promise.resolve();
|
||||
}
|
||||
},
|
||||
recommendationEnablerService: {
|
||||
getSetting() {
|
||||
return enabled.toString();
|
||||
},
|
||||
setSetting(e) {
|
||||
enabled = e === 'true';
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('init', function () {
|
||||
it('should update wellknown', async function () {
|
||||
const updateWellknown = sinon.stub(service.wellknownService, 'set').resolves();
|
||||
await service.init();
|
||||
assert(updateWellknown.calledOnce);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRecommendationsEnabledSetting', function () {
|
||||
it('should set to true if more than one', async function () {
|
||||
enabled = false;
|
||||
await service.updateRecommendationsEnabledSetting([
|
||||
Recommendation.create({
|
||||
url: 'http://localhost/1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false
|
||||
})
|
||||
]);
|
||||
assert(enabled);
|
||||
});
|
||||
|
||||
it('should keep enabled true if already enabled', async function () {
|
||||
enabled = true;
|
||||
await service.updateRecommendationsEnabledSetting([
|
||||
Recommendation.create({
|
||||
url: 'http://localhost/1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false
|
||||
})
|
||||
]);
|
||||
assert(enabled);
|
||||
});
|
||||
|
||||
it('should set to false if none', async function () {
|
||||
enabled = false;
|
||||
await service.updateRecommendationsEnabledSetting([]);
|
||||
assert.equal(enabled, false);
|
||||
});
|
||||
|
||||
it('should set to false if none if currently enabled', async function () {
|
||||
enabled = true;
|
||||
await service.updateRecommendationsEnabledSetting([]);
|
||||
assert.equal(enabled, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readRecommendation', function () {
|
||||
it('throws if not found', async function () {
|
||||
await assert.rejects(() => service.readRecommendation('1'), {
|
||||
name: 'NotFoundError',
|
||||
message: 'Recommendation with id 1 not found'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns plain if found', async function () {
|
||||
const recommendation = Recommendation.create({
|
||||
id: '2',
|
||||
url: 'http://localhost/1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false
|
||||
});
|
||||
await service.repository.save(recommendation);
|
||||
|
||||
const response = await service.readRecommendation('2');
|
||||
assert.deepEqual(response, recommendation.plain);
|
||||
|
||||
// Check not instance of Recommendation
|
||||
assert.equal(response instanceof Recommendation, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('addRecommendation', function () {
|
||||
it('throws if already exists', async function () {
|
||||
const recommendation = Recommendation.create({
|
||||
id: '2',
|
||||
url: 'http://localhost/1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false
|
||||
});
|
||||
await service.repository.save(recommendation);
|
||||
|
||||
await assert.rejects(() => service.addRecommendation({
|
||||
url: 'http://localhost/1',
|
||||
title: 'Test 2',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false
|
||||
}), {
|
||||
name: 'ValidationError',
|
||||
message: 'A recommendation with this URL already exists.'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns plain if sucessful', async function () {
|
||||
const response = await service.addRecommendation({
|
||||
url: 'http://localhost/1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false
|
||||
});
|
||||
assert.deepEqual(response, {
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false,
|
||||
clickCount: undefined,
|
||||
subscriberCount: undefined,
|
||||
updatedAt: null,
|
||||
|
||||
// Ignored
|
||||
url: response.url,
|
||||
id: response.id,
|
||||
createdAt: response.createdAt
|
||||
});
|
||||
|
||||
assert(response.id);
|
||||
assert(response.url);
|
||||
assert(response.createdAt);
|
||||
|
||||
assert(response.url instanceof URL);
|
||||
assert(response.createdAt instanceof Date);
|
||||
});
|
||||
|
||||
it('does not throw if sendMentionToRecommendation throws', async function () {
|
||||
const recommendation = Recommendation.create({
|
||||
id: '2',
|
||||
url: 'http://localhost/1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false
|
||||
});
|
||||
|
||||
const updateRecommendationsEnabledSetting = sinon.stub(service.mentionSendingService, 'sendAll').rejects(new Error('Test'));
|
||||
await service.repository.save(recommendation);
|
||||
|
||||
await assert.doesNotReject(() => service.addRecommendation({
|
||||
url: 'http://localhost/2',
|
||||
title: 'Test 2',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false
|
||||
}));
|
||||
|
||||
assert(updateRecommendationsEnabledSetting.calledOnce);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editRecommendation', function () {
|
||||
it('throws if not found', async function () {
|
||||
await assert.rejects(() => service.editRecommendation('1', {
|
||||
title: 'Test 2'
|
||||
}), {
|
||||
name: 'NotFoundError',
|
||||
message: 'Recommendation with id 1 not found'
|
||||
});
|
||||
});
|
||||
|
||||
it('returns plain if sucessful', async function () {
|
||||
const recommendation = Recommendation.create({
|
||||
id: '2',
|
||||
url: 'http://localhost/1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false
|
||||
});
|
||||
await service.repository.save(recommendation);
|
||||
|
||||
const response = await service.editRecommendation('2', {
|
||||
title: 'Test 2'
|
||||
});
|
||||
assert.deepEqual(response, {
|
||||
title: 'Test 2',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false,
|
||||
clickCount: undefined,
|
||||
subscriberCount: undefined,
|
||||
|
||||
// Ignored
|
||||
url: response.url,
|
||||
id: response.id,
|
||||
createdAt: response.createdAt,
|
||||
updatedAt: response.updatedAt
|
||||
});
|
||||
|
||||
assert(response.id);
|
||||
assert(response.url);
|
||||
assert(response.createdAt);
|
||||
assert(response.updatedAt);
|
||||
|
||||
assert(response.url instanceof URL);
|
||||
assert(response.createdAt instanceof Date);
|
||||
assert(response.updatedAt instanceof Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteRecommendation', function () {
|
||||
it('throws if not found', async function () {
|
||||
await assert.rejects(() => service.deleteRecommendation('1'), {
|
||||
name: 'NotFoundError',
|
||||
message: 'Recommendation with id 1 not found'
|
||||
});
|
||||
});
|
||||
|
||||
it('deletes if found', async function () {
|
||||
const recommendation = Recommendation.create({
|
||||
id: '2',
|
||||
url: 'http://localhost/1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false
|
||||
});
|
||||
await service.repository.save(recommendation);
|
||||
|
||||
assert.equal(await service.repository.getCount({}), 1);
|
||||
await service.deleteRecommendation('2');
|
||||
assert.equal(await service.repository.getCount({}), 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listRecommendations', function () {
|
||||
it('returns plain if sucessful', async function () {
|
||||
const recommendation = Recommendation.create({
|
||||
id: '2',
|
||||
url: 'http://localhost/1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false
|
||||
});
|
||||
await service.repository.save(recommendation);
|
||||
|
||||
const response = await service.listRecommendations();
|
||||
assert.equal(response.length, 1);
|
||||
assert.equal(response[0] instanceof Recommendation, false);
|
||||
});
|
||||
|
||||
it('returns pages', async function () {
|
||||
const recommendation = Recommendation.create({
|
||||
id: '2',
|
||||
url: 'http://localhost/1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false
|
||||
});
|
||||
await service.repository.save(recommendation);
|
||||
|
||||
const recommendation2 = Recommendation.create({
|
||||
id: '3',
|
||||
url: 'http://localhost/2',
|
||||
title: 'Test 2',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false
|
||||
});
|
||||
await service.repository.save(recommendation2);
|
||||
|
||||
const response = await service.listRecommendations({
|
||||
limit: 1,
|
||||
order: [
|
||||
{
|
||||
field: 'id',
|
||||
direction: 'desc'
|
||||
}
|
||||
]
|
||||
});
|
||||
assert.equal(response.length, 1);
|
||||
assert.equal(response[0].id, '3');
|
||||
assert.equal(response[0] instanceof Recommendation, false);
|
||||
});
|
||||
|
||||
it('uses a default limit and page', async function () {
|
||||
const recommendation = Recommendation.create({
|
||||
id: '2',
|
||||
url: 'http://localhost/1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false
|
||||
});
|
||||
await service.repository.save(recommendation);
|
||||
|
||||
const recommendation2 = Recommendation.create({
|
||||
id: '3',
|
||||
url: 'http://localhost/2',
|
||||
title: 'Test 2',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false
|
||||
});
|
||||
await service.repository.save(recommendation2);
|
||||
|
||||
const response = await service.listRecommendations({});
|
||||
assert.equal(response.length, 2);
|
||||
assert.equal(response[0] instanceof Recommendation, false);
|
||||
assert.equal(response[1] instanceof Recommendation, false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('countRecommendations', function () {
|
||||
it('returns count', async function () {
|
||||
const recommendation = Recommendation.create({
|
||||
id: '2',
|
||||
url: 'http://localhost/1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false
|
||||
});
|
||||
await service.repository.save(recommendation);
|
||||
|
||||
assert.equal(await service.countRecommendations({}), 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackClicked', function () {
|
||||
it('adds click event', async function () {
|
||||
await service.trackClicked({id: '1'});
|
||||
assert.equal(await service.clickEventRepository.getCount({}), 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('trackSubscribed', function () {
|
||||
it('adds subscribe event', async function () {
|
||||
await service.trackSubscribed({id: '1', memberId: '1'});
|
||||
assert.equal(await service.subscribeEventRepository.getCount({}), 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('readRecommendationByUrl', function () {
|
||||
it('returns if found', async function () {
|
||||
const recommendation = Recommendation.create({
|
||||
id: '2',
|
||||
url: 'http://localhost/1',
|
||||
title: 'Test',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
oneClickSubscribe: false
|
||||
});
|
||||
await service.repository.save(recommendation);
|
||||
|
||||
const response = await service.readRecommendationByUrl(new URL('http://localhost/1'));
|
||||
assert.deepEqual(response, recommendation.plain);
|
||||
});
|
||||
|
||||
it('returns null if not found', async function () {
|
||||
const response = await service.readRecommendationByUrl(new URL('http://localhost/1'));
|
||||
assert.equal(response, null);
|
||||
});
|
||||
});
|
||||
});
|
78
ghost/recommendations/test/WellknownService.test.ts
Normal file
78
ghost/recommendations/test/WellknownService.test.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import assert from 'assert/strict';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import {Recommendation} from '../src/Recommendation';
|
||||
import {WellknownService} from '../src/WellknownService';
|
||||
|
||||
const dir = path.join(__dirname, 'data');
|
||||
|
||||
async function getContent() {
|
||||
const content = await fs.readFile(path.join(dir, '.well-known', 'recommendations.json'), 'utf8');
|
||||
return JSON.parse(content);
|
||||
}
|
||||
|
||||
describe('WellknownService', function () {
|
||||
const service = new WellknownService({
|
||||
urlUtils: {
|
||||
relativeToAbsolute(url: string) {
|
||||
return 'https://example.com' + url;
|
||||
}
|
||||
},
|
||||
dir
|
||||
});
|
||||
|
||||
afterEach(async function () {
|
||||
// Remove folder
|
||||
await fs.rm(dir, {recursive: true, force: true});
|
||||
});
|
||||
|
||||
it('Can save recommendations', async function () {
|
||||
const recommendations = [
|
||||
Recommendation.create({
|
||||
title: 'My Blog',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
url: 'https://example.com/blog',
|
||||
oneClickSubscribe: false,
|
||||
createdAt: new Date('2021-01-01T00:00:00Z'),
|
||||
updatedAt: new Date('2021-02-01T00:00:00Z')
|
||||
}),
|
||||
Recommendation.create({
|
||||
title: 'My Other Blog',
|
||||
reason: null,
|
||||
excerpt: null,
|
||||
featuredImage: null,
|
||||
favicon: null,
|
||||
url: 'https://example.com/blog2',
|
||||
oneClickSubscribe: false,
|
||||
createdAt: new Date('2021-01-01T00:00:00Z'),
|
||||
updatedAt: null
|
||||
})
|
||||
];
|
||||
|
||||
await service.set(recommendations);
|
||||
|
||||
// Check that the file exists
|
||||
assert.deepEqual(await getContent(), [
|
||||
{
|
||||
url: 'https://example.com/blog',
|
||||
created_at: '2021-01-01T00:00:00.000Z',
|
||||
updated_at: '2021-02-01T00:00:00.000Z'
|
||||
},
|
||||
{
|
||||
url: 'https://example.com/blog2',
|
||||
created_at: '2021-01-01T00:00:00.000Z',
|
||||
updated_at: '2021-01-01T00:00:00.000Z'
|
||||
}
|
||||
]);
|
||||
});
|
||||
|
||||
it('Can get URL', async function () {
|
||||
assert.equal(
|
||||
(await service.getURL()).toString(),
|
||||
'https://example.com/.well-known/recommendations.json'
|
||||
);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue
Block a user