Merge branch 'main' into webhook-author

This commit is contained in:
Miriam Gonçalves 2023-10-03 17:10:59 +01:00 committed by GitHub
commit 230b4e1b79
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 1223 additions and 27 deletions

View File

@ -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;
},

View File

@ -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') {

View File

@ -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">

View File

@ -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,

View File

@ -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

View File

@ -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

View File

@ -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()
};

View 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');
});
});
});

View 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);
});
});
});

View 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'
);
});
});