Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BC-5642 - internal links in LinkElements on boards #4549

Merged
merged 18 commits into from
Nov 20, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ApiProperty, ApiPropertyOptional, getSchemaPath } from '@nestjs/swagger
import { ContentElementType } from '@shared/domain';
import { InputFormat } from '@shared/domain/types';
import { Type } from 'class-transformer';
import { IsDate, IsEnum, IsMongoId, IsOptional, IsString, IsUrl, ValidateNested } from 'class-validator';
import { IsDate, IsEnum, IsMongoId, IsOptional, IsString, ValidateNested } from 'class-validator';

export abstract class ElementContentBody {
@IsEnum(ContentElementType)
Expand Down Expand Up @@ -34,7 +34,7 @@ export class FileElementContentBody extends ElementContentBody {
}

export class LinkContentBody {
@IsUrl()
@IsString()
@ApiProperty({})
url!: string;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,15 @@ import { Test, TestingModule } from '@nestjs/testing';
import { TestApiClient, UserAndAccountTestFactory } from '@shared/testing';
import { MetaTagExtractorService } from '../../service';

const URL = 'https://test.de';
const URL = 'https://best-example.de/my-article';

const mockedResponse = {
url: URL,
title: 'The greatest Test-Page',
description: 'with great description',
};

describe(`get data (api)`, () => {
describe(`get meta tags (api)`, () => {
let app: INestApplication;
let em: EntityManager;
let testApiClient: TestApiClient;
Expand All @@ -24,7 +24,7 @@ describe(`get data (api)`, () => {
})
.overrideProvider(MetaTagExtractorService)
.useValue({
fetchMetaData: () => mockedResponse,
getMetaData: () => mockedResponse,
})
.compile();

Expand Down Expand Up @@ -63,7 +63,7 @@ describe(`get data (api)`, () => {

const response = await loggedInClient.post(undefined, { url: URL });

expect(response?.body).toEqual(mockedResponse);
expect(response?.body).toEqual(expect.objectContaining(mockedResponse));
});
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@ describe(MetaTagExtractorResponse.name, () => {
title: 'Testbild',
description: 'Here we describe what this page is about.',
imageUrl: 'https://www.abc.de/test.png',
type: 'unknown',
parentTitle: 'Math',
parentType: 'course',
};

const errorResponse = new MetaTagExtractorResponse(properties);
expect(errorResponse.url).toEqual(properties.url);
expect(errorResponse.title).toEqual(properties.title);
expect(errorResponse.description).toEqual(properties.description);
expect(errorResponse.imageUrl).toEqual(properties.imageUrl);
const response = new MetaTagExtractorResponse(properties);
expect(response.url).toEqual(properties.url);
expect(response.title).toEqual(properties.title);
expect(response.description).toEqual(properties.description);
expect(response.imageUrl).toEqual(properties.imageUrl);
expect(response.type).toEqual(properties.type);
expect(response.parentTitle).toEqual(properties.parentTitle);
expect(response.parentType).toEqual(properties.parentType);
});
});
});
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
import { ApiProperty } from '@nestjs/swagger';
import { DecodeHtmlEntities } from '@shared/controller';
import { IsString, IsUrl } from 'class-validator';
import { MetaDataEntityType } from '../../types';

export class MetaTagExtractorResponse {
constructor({ url, title, description, imageUrl }: MetaTagExtractorResponse) {
constructor({ url, title, description, imageUrl, type, parentTitle, parentType }: MetaTagExtractorResponse) {
this.url = url;
this.title = title;
this.description = description;
this.imageUrl = imageUrl;
this.type = type;
this.parentTitle = parentTitle;
this.parentType = parentType;
}

@ApiProperty()
Expand All @@ -25,4 +29,16 @@ export class MetaTagExtractorResponse {
@ApiProperty()
@IsString()
imageUrl?: string;

@ApiProperty()
@IsString()
type: MetaDataEntityType;

@ApiProperty()
@DecodeHtmlEntities()
parentTitle?: string;

@ApiProperty()
@IsString()
parentType?: MetaDataEntityType;
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Authenticate, CurrentUser, ICurrentUser } from '@modules/authentication';
import { Body, Controller, InternalServerErrorException, Post, UnauthorizedException } from '@nestjs/common';
import { ApiOperation, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ICurrentUser } from '@src/modules/authentication';
import { Authenticate, CurrentUser } from '@src/modules/authentication/decorator/auth.decorator';
import { MetaTagExtractorUc } from '../uc';
import { MetaTagExtractorResponse } from './dto';
import { GetMetaTagDataBody } from './post-link-url.body.params';
Expand All @@ -17,11 +16,11 @@ export class MetaTagExtractorController {
@ApiResponse({ status: 401, type: UnauthorizedException })
@ApiResponse({ status: 500, type: InternalServerErrorException })
@Post('')
async getData(
async getMetaTags(
@CurrentUser() currentUser: ICurrentUser,
@Body() bodyParams: GetMetaTagDataBody
): Promise<MetaTagExtractorResponse> {
const result = await this.metaTagExtractorUc.fetchMetaData(currentUser.userId, bodyParams.url);
const result = await this.metaTagExtractorUc.getMetaData(currentUser.userId, bodyParams.url);
const imageUrl = result.image?.url;
const response = new MetaTagExtractorResponse({ ...result, imageUrl });
return response;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsUrl } from 'class-validator';
import { IsString } from 'class-validator';

export class GetMetaTagDataBody {
@IsUrl()
@IsString()
@ApiProperty({
required: true,
nullable: false,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { MetaData } from '../types';

export interface UrlHandler {
doesUrlMatch(url: string): boolean;
getMetaData(url: string): Promise<MetaData | undefined>;
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { AuthorizationModule } from '@modules/authorization';
import { forwardRef, Module } from '@nestjs/common';
import { LoggerModule } from '@src/core/logger';
import { AuthorizationModule } from '@src/modules/authorization';
import { MetaTagExtractorController } from './controller';
import { MetaTagExtractorModule } from './meta-tag-extractor.module';
import { MetaTagExtractorUc } from './uc';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,20 +5,37 @@ import { ConsoleWriterModule } from '@infra/console';
import { createConfigModuleOptions } from '@src/config';
import { LoggerModule } from '@src/core/logger';
import { AuthenticationModule } from '../authentication/authentication.module';
import { BoardModule } from '../board';
import { LearnroomModule } from '../learnroom';
import { LessonModule } from '../lesson';
import { TaskModule } from '../task';
import { UserModule } from '../user';
import metaTagExtractorConfig from './meta-tag-extractor.config';
import { MetaTagExtractorService } from './service';
import { MetaTagInternalUrlService } from './service/meta-tag-internal-url.service';
import { BoardUrlHandler, CourseUrlHandler, LessonUrlHandler, TaskUrlHandler } from './service/url-handler';

@Module({
imports: [
AuthenticationModule,
BoardModule,
ConsoleWriterModule,
HttpModule,
LearnroomModule,
LessonModule,
LoggerModule,
TaskModule,
UserModule,
ConfigModule.forRoot(createConfigModuleOptions(metaTagExtractorConfig)),
],
providers: [MetaTagExtractorService],
providers: [
MetaTagExtractorService,
MetaTagInternalUrlService,
TaskUrlHandler,
LessonUrlHandler,
CourseUrlHandler,
BoardUrlHandler,
],
exports: [MetaTagExtractorService],
})
export class MetaTagExtractorModule {}
Original file line number Diff line number Diff line change
@@ -1,35 +1,52 @@
import { createMock, DeepMocked } from '@golevelup/ts-jest';
import { Configuration } from '@hpi-schul-cloud/commons/lib';
import { Test, TestingModule } from '@nestjs/testing';
import { setupEntities } from '@shared/testing';
import ogs from 'open-graph-scraper';
import { ImageObject } from 'open-graph-scraper/dist/lib/types';
import { MetaTagExtractorService } from './meta-tag-extractor.service';
import { MetaTagInternalUrlService } from './meta-tag-internal-url.service';

let ogsResponseMock = {};
let ogsRejectMock: Error | undefined;

jest.mock('open-graph-scraper', () => () => {
if (ogsRejectMock) {
return Promise.reject(ogsRejectMock);
}
jest.mock('open-graph-scraper', () => {
return {
__esModule: true,
default: jest.fn(),
};
});

return Promise.resolve({
const mockOgsResolve = (result: Record<any, any>) => {
const mockedOgs = ogs as jest.Mock;
mockedOgs.mockResolvedValueOnce({
error: false,
html: '',
response: {},
result: ogsResponseMock,
result,
});
});
};

const mockOgsReject = (error: Error) => {
const mockedOgs = ogs as jest.Mock;
mockedOgs.mockRejectedValueOnce(error);
};

describe(MetaTagExtractorService.name, () => {
let module: TestingModule;
let metaTagInternalUrlService: DeepMocked<MetaTagInternalUrlService>;
let service: MetaTagExtractorService;

beforeAll(async () => {
module = await Test.createTestingModule({
providers: [MetaTagExtractorService],
providers: [
MetaTagExtractorService,
{
provide: MetaTagInternalUrlService,
useValue: createMock<MetaTagInternalUrlService>(),
},
],
}).compile();

metaTagInternalUrlService = module.get(MetaTagInternalUrlService);
service = module.get(MetaTagExtractorService);

await setupEntities();
});

Expand All @@ -38,8 +55,8 @@ describe(MetaTagExtractorService.name, () => {
});

beforeEach(() => {
ogsResponseMock = {};
ogsRejectMock = undefined;
Configuration.set('SC_DOMAIN', 'localhost');
metaTagInternalUrlService.tryInternalLinkMetaTags.mockResolvedValue(undefined);
});

afterEach(() => {
Expand All @@ -48,26 +65,28 @@ describe(MetaTagExtractorService.name, () => {

describe('create', () => {
describe('when url points to webpage', () => {
it('should thrown an error if url is an empty string', async () => {
const url = '';

await expect(service.getMetaData(url)).rejects.toThrow();
});

it('should return also the original url', async () => {
const ogTitle = 'My Title';
const url = 'https://de.wikipedia.org';
mockOgsResolve({ url, ogTitle });

const result = await service.fetchMetaData(url);
const result = await service.getMetaData(url);

expect(result).toEqual(expect.objectContaining({ url }));
});

it('should thrown an error if url is an empty string', async () => {
const url = '';

await expect(service.fetchMetaData(url)).rejects.toThrow();
});

it('should return ogTitle as title', async () => {
const ogTitle = 'My Title';
const url = 'https://de.wikipedia.org';
ogsResponseMock = { ogTitle };
mockOgsResolve({ ogTitle });

const result = await service.fetchMetaData(url);
const result = await service.getMetaData(url);

expect(result).toEqual(expect.objectContaining({ title: ogTitle }));
});
Expand All @@ -91,9 +110,9 @@ describe(MetaTagExtractorService.name, () => {
},
];
const url = 'https://de.wikipedia.org';
ogsResponseMock = { ogImage };
mockOgsResolve({ url, ogImage });

const result = await service.fetchMetaData(url);
const result = await service.getMetaData(url);

expect(result).toEqual(expect.objectContaining({ image: ogImage[1] }));
});
Expand All @@ -102,19 +121,21 @@ describe(MetaTagExtractorService.name, () => {
describe('when url points to a file', () => {
it('should return filename as title', async () => {
const url = 'https://de.wikipedia.org/abc.jpg';
ogsRejectMock = new Error('no open graph data included... probably not a webpage');

const result = await service.fetchMetaData(url);
mockOgsReject(new Error('no open graph data included... probably not a webpage'));

const result = await service.getMetaData(url);
expect(result).toEqual(expect.objectContaining({ title: 'abc.jpg' }));
});
});

describe('when url is invalid', () => {
it('should return url as it is', async () => {
const url = 'not-a-real-domain';
ogsRejectMock = new Error('no open graph data included... probably not a webpage');

const result = await service.fetchMetaData(url);
mockOgsReject(new Error('no open graph data included... probably not a webpage'));

const result = await service.getMetaData(url);
expect(result).toEqual(expect.objectContaining({ url, title: '', description: '' }));
});
});
Expand Down
Loading
Loading