From 9e55af1247da0563d9d09a3cd6b953c087ff43ba Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Wed, 3 Mar 2021 15:23:45 +0100 Subject: [PATCH 1/3] HistoryService: Add deleteHistory method This method deletes all history entries of a user. Signed-off-by: Philip Molares --- src/history/history.service.spec.ts | 46 +++++++++++++++++++++++++++++ src/history/history.service.ts | 14 ++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/history/history.service.spec.ts b/src/history/history.service.spec.ts index 97d129c02..46a984800 100644 --- a/src/history/history.service.spec.ts +++ b/src/history/history.service.spec.ts @@ -241,6 +241,52 @@ describe('HistoryService', () => { }); describe('deleteHistoryEntry', () => { + describe('works', () => { + const user = {} as User; + const alias = 'alias'; + const note = Note.create(user, alias); + const historyEntry = HistoryEntry.create(user, note); + it('with an entry', async () => { + jest.spyOn(historyRepo, 'find').mockResolvedValueOnce([historyEntry]); + jest.spyOn(historyRepo, 'remove').mockImplementationOnce( + async (entry: HistoryEntry): Promise => { + expect(entry).toEqual(historyEntry); + return entry; + }, + ); + await service.deleteHistory(user); + }); + it('with multiple entries', async () => { + const alias2 = 'alias2'; + const note2 = Note.create(user, alias2); + const historyEntry2 = HistoryEntry.create(user, note2); + jest + .spyOn(historyRepo, 'find') + .mockResolvedValueOnce([historyEntry, historyEntry2]); + jest + .spyOn(historyRepo, 'remove') + .mockImplementationOnce( + async (entry: HistoryEntry): Promise => { + expect(entry).toEqual(historyEntry); + return entry; + }, + ) + .mockImplementationOnce( + async (entry: HistoryEntry): Promise => { + expect(entry).toEqual(historyEntry2); + return entry; + }, + ); + await service.deleteHistory(user); + }); + it('without an entry', async () => { + jest.spyOn(historyRepo, 'find').mockResolvedValueOnce([]); + await service.deleteHistory(user); + }); + }); + }); + + describe('deleteHistory', () => { describe('works', () => { it('with an entry', async () => { const user = {} as User; diff --git a/src/history/history.service.ts b/src/history/history.service.ts index 04f70eadc..d2793ac04 100644 --- a/src/history/history.service.ts +++ b/src/history/history.service.ts @@ -38,7 +38,7 @@ export class HistoryService { async getEntriesByUser(user: User): Promise { return await this.historyEntryRepository.find({ where: { user: user }, - relations: ['note'], + relations: ['note', 'user'], }); } @@ -136,6 +136,18 @@ export class HistoryService { return; } + /** + * @async + * Delete all history entries of a specific user + * @param {User} user - the user that the entry belongs to + */ + async deleteHistory(user: User): Promise { + const entries: HistoryEntry[] = await this.getEntriesByUser(user); + for (const entry of entries) { + await this.historyEntryRepository.remove(entry); + } + } + /** * Build HistoryEntryDto from a history entry. * @param {HistoryEntry} entry - the history entry to use From 7f399735f6b4516becad55af7f25ee472e4b794e Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Wed, 3 Mar 2021 15:25:11 +0100 Subject: [PATCH 2/3] PrivateAPI: Add history controller Signed-off-by: Philip Molares --- .../me/history/history.controller.spec.ts | 78 +++++++++++ .../private/me/history/history.controller.ts | 127 ++++++++++++++++++ src/api/private/private-api.module.ts | 7 +- src/history/history-entry-creation.dto.ts | 15 +++ 4 files changed, 225 insertions(+), 2 deletions(-) create mode 100644 src/api/private/me/history/history.controller.spec.ts create mode 100644 src/api/private/me/history/history.controller.ts create mode 100644 src/history/history-entry-creation.dto.ts diff --git a/src/api/private/me/history/history.controller.spec.ts b/src/api/private/me/history/history.controller.spec.ts new file mode 100644 index 000000000..f52891cae --- /dev/null +++ b/src/api/private/me/history/history.controller.spec.ts @@ -0,0 +1,78 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { Test, TestingModule } from '@nestjs/testing'; +import { HistoryController } from './history.controller'; +import { LoggerModule } from '../../../../logger/logger.module'; +import { UsersModule } from '../../../../users/users.module'; +import { HistoryModule } from '../../../../history/history.module'; +import { NotesModule } from '../../../../notes/notes.module'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { User } from '../../../../users/user.entity'; +import { Note } from '../../../../notes/note.entity'; +import { AuthToken } from '../../../../auth/auth-token.entity'; +import { Identity } from '../../../../users/identity.entity'; +import { AuthorColor } from '../../../../notes/author-color.entity'; +import { Authorship } from '../../../../revisions/authorship.entity'; +import { Revision } from '../../../../revisions/revision.entity'; +import { Tag } from '../../../../notes/tag.entity'; +import { HistoryEntry } from '../../../../history/history-entry.entity'; +import { NoteGroupPermission } from '../../../../permissions/note-group-permission.entity'; +import { NoteUserPermission } from '../../../../permissions/note-user-permission.entity'; +import { Group } from '../../../../groups/group.entity'; +import { ConfigModule } from '@nestjs/config'; +import appConfigMock from '../../../../config/app.config.mock'; + +describe('HistoryController', () => { + let controller: HistoryController; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [HistoryController], + imports: [ + UsersModule, + HistoryModule, + NotesModule, + LoggerModule, + ConfigModule.forRoot({ + isGlobal: true, + load: [appConfigMock], + }), + ], + }) + .overrideProvider(getRepositoryToken(User)) + .useValue({}) + .overrideProvider(getRepositoryToken(Note)) + .useValue({}) + .overrideProvider(getRepositoryToken(AuthToken)) + .useValue({}) + .overrideProvider(getRepositoryToken(Identity)) + .useValue({}) + .overrideProvider(getRepositoryToken(AuthorColor)) + .useValue({}) + .overrideProvider(getRepositoryToken(Authorship)) + .useValue({}) + .overrideProvider(getRepositoryToken(Revision)) + .useValue({}) + .overrideProvider(getRepositoryToken(Tag)) + .useValue({}) + .overrideProvider(getRepositoryToken(HistoryEntry)) + .useValue({}) + .overrideProvider(getRepositoryToken(NoteGroupPermission)) + .useValue({}) + .overrideProvider(getRepositoryToken(NoteUserPermission)) + .useValue({}) + .overrideProvider(getRepositoryToken(Group)) + .useValue({}) + .compile(); + + controller = module.get(HistoryController); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); +}); diff --git a/src/api/private/me/history/history.controller.ts b/src/api/private/me/history/history.controller.ts new file mode 100644 index 000000000..85203b78b --- /dev/null +++ b/src/api/private/me/history/history.controller.ts @@ -0,0 +1,127 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { + Body, + Controller, + Delete, + Get, + NotFoundException, + Param, + Post, + Put, +} from '@nestjs/common'; +import { ApiTags } from '@nestjs/swagger'; +import { UsersService } from '../../../../users/users.service'; +import { NotesService } from '../../../../notes/notes.service'; +import { HistoryEntryDto } from '../../../../history/history-entry.dto'; +import { NotInDBError } from '../../../../errors/errors'; +import { HistoryEntryCreationDto } from '../../../../history/history-entry-creation.dto'; +import { HistoryEntryUpdateDto } from '../../../../history/history-entry-update.dto'; +import { ConsoleLoggerService } from '../../../../logger/console-logger.service'; +import { HistoryService } from '../../../../history/history.service'; + +@ApiTags('history') +@Controller('/me/history') +export class HistoryController { + constructor( + private readonly logger: ConsoleLoggerService, + private historyService: HistoryService, + private userService: UsersService, + private noteService: NotesService, + ) { + this.logger.setContext(HistoryController.name); + } + + @Get() + async getHistory(): Promise { + // ToDo: use actual user here + try { + const user = await this.userService.getUserByUsername('hardcoded'); + const foundEntries = await this.historyService.getEntriesByUser(user); + return foundEntries.map((entry) => + this.historyService.toHistoryEntryDto(entry), + ); + } catch (e) { + if (e instanceof NotInDBError) { + throw new NotFoundException(e.message); + } + throw e; + } + } + + @Post() + async setHistory( + @Body('history') history: HistoryEntryCreationDto[], + ): Promise { + try { + // ToDo: use actual user here + const user = await this.userService.getUserByUsername('hardcoded'); + await this.historyService.deleteHistory(user); + for (const historyEntry of history) { + const note = await this.noteService.getNoteByIdOrAlias( + historyEntry.note, + ); + await this.historyService.createOrUpdateHistoryEntry(note, user); + } + } catch (e) { + if (e instanceof NotInDBError) { + throw new NotFoundException(e.message); + } + throw e; + } + } + + @Delete() + async deleteHistory(): Promise { + try { + // ToDo: use actual user here + const user = await this.userService.getUserByUsername('hardcoded'); + await this.historyService.deleteHistory(user); + } catch (e) { + if (e instanceof NotInDBError) { + throw new NotFoundException(e.message); + } + throw e; + } + } + + @Put(':note') + async updateHistoryEntry( + @Param('note') noteId: string, + @Body() entryUpdateDto: HistoryEntryUpdateDto, + ): Promise { + try { + // ToDo: use actual user here + const user = await this.userService.getUserByUsername('hardcoded'); + const newEntry = await this.historyService.updateHistoryEntry( + noteId, + user, + entryUpdateDto, + ); + return this.historyService.toHistoryEntryDto(newEntry); + } catch (e) { + if (e instanceof NotInDBError) { + throw new NotFoundException(e.message); + } + throw e; + } + } + + @Delete(':note') + async deleteHistoryEntry(@Param('note') noteId: string): Promise { + try { + // ToDo: use actual user here + const user = await this.userService.getUserByUsername('hardcoded'); + await this.historyService.deleteHistoryEntry(noteId, user); + } catch (e) { + if (e instanceof NotInDBError) { + throw new NotFoundException(e.message); + } + throw e; + } + } +} diff --git a/src/api/private/private-api.module.ts b/src/api/private/private-api.module.ts index 74dc95fe8..3d6d8b4a2 100644 --- a/src/api/private/private-api.module.ts +++ b/src/api/private/private-api.module.ts @@ -9,9 +9,12 @@ import { TokensController } from './tokens/tokens.controller'; import { LoggerModule } from '../../logger/logger.module'; import { UsersModule } from '../../users/users.module'; import { AuthModule } from '../../auth/auth.module'; +import { HistoryController } from './me/history/history.controller'; +import { HistoryModule } from '../../history/history.module'; +import { NotesModule } from '../../notes/notes.module'; @Module({ - imports: [LoggerModule, UsersModule, AuthModule], - controllers: [TokensController], + imports: [LoggerModule, UsersModule, AuthModule, HistoryModule, NotesModule], + controllers: [TokensController, HistoryController], }) export class PrivateApiModule {} diff --git a/src/history/history-entry-creation.dto.ts b/src/history/history-entry-creation.dto.ts new file mode 100644 index 000000000..aa565ca1d --- /dev/null +++ b/src/history/history-entry-creation.dto.ts @@ -0,0 +1,15 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { IsString } from 'class-validator'; + +export class HistoryEntryCreationDto { + /** + * ID or Alias of the note + */ + @IsString() + note: string; +} From 25126eb03fcdf4d2966d9d745a78c57a617114da Mon Sep 17 00:00:00 2001 From: Philip Molares Date: Thu, 4 Mar 2021 10:20:58 +0100 Subject: [PATCH 3/3] PrivateE2E: Add history test Test the /me/history route in the private API. Signed-off-by: Philip Molares --- test/private-api/history.e2e-spec.ts | 150 +++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 test/private-api/history.e2e-spec.ts diff --git a/test/private-api/history.e2e-spec.ts b/test/private-api/history.e2e-spec.ts new file mode 100644 index 000000000..20fbf7878 --- /dev/null +++ b/test/private-api/history.e2e-spec.ts @@ -0,0 +1,150 @@ +/* + * SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file) + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/* eslint-disable +@typescript-eslint/no-unsafe-assignment, +@typescript-eslint/no-unsafe-member-access +*/ + +import { INestApplication } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; +import { Test } from '@nestjs/testing'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import * as request from 'supertest'; +import mediaConfigMock from '../../src/config/media.config.mock'; +import appConfigMock from '../../src/config/app.config.mock'; +import { GroupsModule } from '../../src/groups/groups.module'; +import { LoggerModule } from '../../src/logger/logger.module'; +import { NotesModule } from '../../src/notes/notes.module'; +import { NotesService } from '../../src/notes/notes.service'; +import { PermissionsModule } from '../../src/permissions/permissions.module'; +import { AuthModule } from '../../src/auth/auth.module'; +import { UsersService } from '../../src/users/users.service'; +import { User } from '../../src/users/user.entity'; +import { UsersModule } from '../../src/users/users.module'; +import { PrivateApiModule } from '../../src/api/private/private-api.module'; +import { HistoryService } from '../../src/history/history.service'; +import { Note } from '../../src/notes/note.entity'; +import { HistoryEntryCreationDto } from '../../src/history/history-entry-creation.dto'; + +describe('History', () => { + let app: INestApplication; + let historyService: HistoryService; + let user: User; + let note: Note; + let note2: Note; + let content: string; + + beforeAll(async () => { + const moduleRef = await Test.createTestingModule({ + imports: [ + ConfigModule.forRoot({ + isGlobal: true, + load: [appConfigMock, mediaConfigMock], + }), + PrivateApiModule, + NotesModule, + PermissionsModule, + GroupsModule, + TypeOrmModule.forRoot({ + type: 'sqlite', + database: './hedgedoc-e2e-private-history.sqlite', + autoLoadEntities: true, + synchronize: true, + dropSchema: true, + }), + LoggerModule, + AuthModule, + UsersModule, + ], + }).compile(); + + app = moduleRef.createNestApplication(); + await app.init(); + content = 'This is a test note.'; + historyService = moduleRef.get(HistoryService); + const userService = moduleRef.get(UsersService); + user = await userService.createUser('hardcoded', 'Testy'); + const notesService = moduleRef.get(NotesService); + note = await notesService.createNote(content, null, user); + note2 = await notesService.createNote(content, 'note2', user); + }); + + it('GET /me/history', async () => { + const emptyResponse = await request(app.getHttpServer()) + .get('/me/history') + .expect('Content-Type', /json/) + .expect(200); + expect(emptyResponse.body.length).toEqual(0); + const entry = await historyService.createOrUpdateHistoryEntry(note, user); + const entryDto = historyService.toHistoryEntryDto(entry); + const response = await request(app.getHttpServer()) + .get('/me/history') + .expect('Content-Type', /json/) + .expect(200); + expect(response.body.length).toEqual(1); + expect(response.body[0].identifier).toEqual(entryDto.identifier); + expect(response.body[0].title).toEqual(entryDto.title); + expect(response.body[0].tags).toEqual(entryDto.tags); + expect(response.body[0].pinStatus).toEqual(entryDto.pinStatus); + expect(response.body[0].lastVisited).toEqual( + entryDto.lastVisited.toISOString(), + ); + }); + + it('POST /me/history', async () => { + const postEntryDto = new HistoryEntryCreationDto(); + postEntryDto.note = note2.alias; + await request(app.getHttpServer()) + .post('/me/history') + .set('Content-Type', 'application/json') + .send(JSON.stringify({ history: [postEntryDto] })) + .expect(201); + const userEntries = await historyService.getEntriesByUser(user); + expect(userEntries.length).toEqual(1); + expect(userEntries[0].note.alias).toEqual(note2.alias); + }); + + it('DELETE /me/history', async () => { + expect((await historyService.getEntriesByUser(user)).length).toEqual(1); + await request(app.getHttpServer()).delete('/me/history').expect(200); + expect((await historyService.getEntriesByUser(user)).length).toEqual(0); + }); + + it('PUT /me/history/:note', async () => { + const entry = await historyService.createOrUpdateHistoryEntry(note2, user); + expect(entry.pinStatus).toBeFalsy(); + await request(app.getHttpServer()) + .put(`/me/history/${entry.note.alias}`) + .send({ pinStatus: true }) + .expect(200); + const userEntries = await historyService.getEntriesByUser(user); + expect(userEntries.length).toEqual(1); + expect(userEntries[0].pinStatus).toBeTruthy(); + await historyService.deleteHistoryEntry(note2.alias, user); + }); + + it('DELETE /me/history/:note', async () => { + const entry = await historyService.createOrUpdateHistoryEntry(note2, user); + const entry2 = await historyService.createOrUpdateHistoryEntry(note, user); + const entryDto = historyService.toHistoryEntryDto(entry2); + await request(app.getHttpServer()) + .delete(`/me/history/${entry.note.alias}`) + .expect(200); + const userEntries = await historyService.getEntriesByUser(user); + expect(userEntries.length).toEqual(1); + const userEntryDto = historyService.toHistoryEntryDto(userEntries[0]); + expect(userEntryDto.identifier).toEqual(entryDto.identifier); + expect(userEntryDto.title).toEqual(entryDto.title); + expect(userEntryDto.tags).toEqual(entryDto.tags); + expect(userEntryDto.pinStatus).toEqual(entryDto.pinStatus); + expect(userEntryDto.lastVisited).toEqual(entryDto.lastVisited); + }); + + afterAll(async () => { + await app.close(); + }); +});