Merge pull request #801 from hedgedoc/feature/history

This commit is contained in:
David Mehren 2021-02-05 21:30:52 +01:00 committed by GitHub
commit 763f67d5fb
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 667 additions and 261 deletions

View file

@ -134,11 +134,20 @@ entity "media_upload" {
*createdAt : date
}
entity "history_entry" {
*noteId : uuid <<FK note>>
*userId : uuid <<FK user>>
--
*pinStatus: boolean
*updatedAt: date
}
user "1" -- "0..*" note: owner
user "1" -u- "1..*" identity
user "1" - "1..*" auth_token: authTokens
user "1" -l- "1..*" session
user "1" - "0..*" media_upload
user "1" - "0..*" history_entry
user "0..*" -- "0..*" note
user "1" - "0..*" authorship
@ -149,6 +158,7 @@ revision "0..*" - "0..*" authorship
media_upload "0..*" -- "1" note
note "1" - "1..*" revision
note "1" - "0..*" history_entry
note "0..*" -l- "0..*" tag
note "0..*" -- "0..*" group

View file

@ -18,7 +18,9 @@ import { AuthService } from '../../../auth/auth.service';
import { TimestampMillis } from '../../../utils/timestamp';
import { AuthTokenDto } from '../../../auth/auth-token.dto';
import { AuthTokenWithSecretDto } from '../../../auth/auth-token-with-secret.dto';
import { ApiTags } from '@nestjs/swagger';
@ApiTags('tokens')
@Controller('tokens')
export class TokensController {
constructor(

View file

@ -19,6 +19,7 @@ import { Identity } from '../../../users/identity.entity';
import { User } from '../../../users/user.entity';
import { UsersModule } from '../../../users/users.module';
import { MeController } from './me.controller';
import { HistoryEntry } from '../../../history/history-entry.entity';
describe('Me Controller', () => {
let controller: MeController;
@ -44,6 +45,8 @@ describe('Me Controller', () => {
.useValue({})
.overrideProvider(getRepositoryToken(Tag))
.useValue({})
.overrideProvider(getRepositoryToken(HistoryEntry))
.useValue({})
.compile();
controller = module.get<MeController>(MeController);

View file

@ -17,16 +17,18 @@ import {
Request,
} from '@nestjs/common';
import { HistoryEntryUpdateDto } from '../../../history/history-entry-update.dto';
import { HistoryEntryDto } from '../../../history/history-entry.dto';
import { HistoryService } from '../../../history/history.service';
import { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { NoteMetadataDto } from '../../../notes/note-metadata.dto';
import { NotesService } from '../../../notes/notes.service';
import { UserInfoDto } from '../../../users/user-info.dto';
import { UsersService } from '../../../users/users.service';
import { TokenAuthGuard } from '../../../auth/token-auth.guard';
import { ApiSecurity } from '@nestjs/swagger';
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
import { HistoryEntryDto } from '../../../history/history-entry.dto';
import { UserInfoDto } from '../../../users/user-info.dto';
import { NotInDBError } from '../../../errors/errors';
@ApiTags('me')
@ApiSecurity('token')
@Controller('me')
export class MeController {
@ -49,19 +51,37 @@ export class MeController {
@UseGuards(TokenAuthGuard)
@Get('history')
getUserHistory(@Request() req): HistoryEntryDto[] {
return this.historyService.getUserHistory(req.user.userName);
async getUserHistory(@Request() req): Promise<HistoryEntryDto[]> {
const foundEntries = await this.historyService.getEntriesByUser(req.user);
return Promise.all(
foundEntries.map(
async (entry) => await this.historyService.toHistoryEntryDto(entry),
),
);
}
@UseGuards(TokenAuthGuard)
@Put('history/:note')
updateHistoryEntry(
async updateHistoryEntry(
@Request() req,
@Param('note') note: string,
@Body() entryUpdateDto: HistoryEntryUpdateDto,
): HistoryEntryDto {
): Promise<HistoryEntryDto> {
// ToDo: Check if user is allowed to pin this history entry
return this.historyService.updateHistoryEntry(note, entryUpdateDto);
try {
return this.historyService.toHistoryEntryDto(
await this.historyService.updateHistoryEntry(
note,
req.user,
entryUpdateDto,
),
);
} catch (e) {
if (e instanceof NotInDBError) {
throw new NotFoundException(e.message);
}
throw e;
}
}
@UseGuards(TokenAuthGuard)
@ -70,9 +90,12 @@ export class MeController {
deleteHistoryEntry(@Request() req, @Param('note') note: string) {
// ToDo: Check if user is allowed to delete note
try {
return this.historyService.deleteHistoryEntry(note);
return this.historyService.deleteHistoryEntry(note, req.user);
} catch (e) {
throw new NotFoundException(e.message);
if (e instanceof NotInDBError) {
throw new NotFoundException(e.message);
}
throw e;
}
}

View file

@ -28,9 +28,10 @@ import { ConsoleLoggerService } from '../../../logger/console-logger.service';
import { MediaService } from '../../../media/media.service';
import { MulterFile } from '../../../media/multer-file.interface';
import { TokenAuthGuard } from '../../../auth/token-auth.guard';
import { ApiSecurity } from '@nestjs/swagger';
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
import { MediaUploadUrlDto } from '../../../media/media-upload-url.dto';
@ApiTags('media')
@ApiSecurity('token')
@Controller('media')
export class MediaController {

View file

@ -7,9 +7,10 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { MonitoringService } from '../../../monitoring/monitoring.service';
import { TokenAuthGuard } from '../../../auth/token-auth.guard';
import { ApiSecurity } from '@nestjs/swagger';
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
import { ServerStatusDto } from '../../../monitoring/server-status.dto';
@ApiTags('monitoring')
@ApiSecurity('token')
@Controller('monitoring')
export class MonitoringController {

View file

@ -19,6 +19,8 @@ import { Identity } from '../../../users/identity.entity';
import { User } from '../../../users/user.entity';
import { UsersModule } from '../../../users/users.module';
import { NotesController } from './notes.controller';
import { HistoryModule } from '../../../history/history.module';
import { HistoryEntry } from '../../../history/history-entry.entity';
describe('Notes Controller', () => {
let controller: NotesController;
@ -37,7 +39,7 @@ describe('Notes Controller', () => {
useValue: {},
},
],
imports: [RevisionsModule, UsersModule, LoggerModule],
imports: [RevisionsModule, UsersModule, LoggerModule, HistoryModule],
})
.overrideProvider(getRepositoryToken(Note))
.useValue({})
@ -57,6 +59,8 @@ describe('Notes Controller', () => {
.useValue({})
.overrideProvider(getRepositoryToken(Tag))
.useValue({})
.overrideProvider(getRepositoryToken(HistoryEntry))
.useValue({})
.compile();
controller = module.get<NotesController>(NotesController);

View file

@ -27,12 +27,14 @@ import { NotesService } from '../../../notes/notes.service';
import { RevisionsService } from '../../../revisions/revisions.service';
import { MarkdownBody } from '../../utils/markdownbody-decorator';
import { TokenAuthGuard } from '../../../auth/token-auth.guard';
import { ApiSecurity } from '@nestjs/swagger';
import { ApiSecurity, ApiTags } from '@nestjs/swagger';
import { HistoryService } from '../../../history/history.service';
import { NoteDto } from '../../../notes/note.dto';
import { NoteMetadataDto } from '../../../notes/note-metadata.dto';
import { RevisionMetadataDto } from '../../../revisions/revision-metadata.dto';
import { RevisionDto } from '../../../revisions/revision.dto';
@ApiTags('notes')
@ApiSecurity('token')
@Controller('notes')
export class NotesController {
@ -40,6 +42,7 @@ export class NotesController {
private readonly logger: ConsoleLoggerService,
private noteService: NotesService,
private revisionsService: RevisionsService,
private historyService: HistoryService,
) {
this.logger.setContext(NotesController.name);
}
@ -57,6 +60,22 @@ export class NotesController {
);
}
@UseGuards(TokenAuthGuard)
@Get(':noteIdOrAlias')
async getNote(@Request() req, @Param('noteIdOrAlias') noteIdOrAlias: string) {
// ToDo: check if user is allowed to view this note
try {
const note = await this.noteService.getNoteByIdOrAlias(noteIdOrAlias);
await this.historyService.createOrUpdateHistoryEntry(note, req.user);
return this.noteService.toNoteDto(note);
} catch (e) {
if (e instanceof NotInDBError) {
throw new NotFoundException(e.message);
}
throw e;
}
}
@UseGuards(TokenAuthGuard)
@Post(':noteAlias')
async createNamedNote(
@ -71,25 +90,6 @@ export class NotesController {
);
}
@UseGuards(TokenAuthGuard)
@Get(':noteIdOrAlias')
async getNote(
@Request() req,
@Param('noteIdOrAlias') noteIdOrAlias: string,
): Promise<NoteDto> {
// ToDo: check if user is allowed to view this note
try {
return this.noteService.toNoteDto(
await this.noteService.getNoteByIdOrAlias(noteIdOrAlias),
);
} catch (e) {
if (e instanceof NotInDBError) {
throw new NotFoundException(e.message);
}
throw e;
}
}
@UseGuards(TokenAuthGuard)
@Delete(':noteIdOrAlias')
async deleteNote(

View file

@ -28,6 +28,7 @@ describe('AuthService', () => {
id: '1',
identities: [],
ownedNotes: [],
historyEntries: [],
updatedAt: new Date(),
userName: 'Testy',
};

View file

@ -16,7 +16,13 @@ export class MockAuthGuard {
async canActivate(context: ExecutionContext) {
const req = context.switchToHttp().getRequest();
if (!this.user) {
this.user = await this.usersService.createUser('hardcoded', 'Testy');
// this assures that we can create the user 'hardcoded', if we need them before any calls are made or
// create them on the fly when the first call to the api is made
try {
this.user = await this.usersService.getUserByUsername('hardcoded');
} catch (e) {
this.user = await this.usersService.createUser('hardcoded', 'Testy');
}
}
req.user = this.user;
return true;

View file

@ -4,15 +4,33 @@
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { IsBoolean, ValidateNested } from 'class-validator';
import { NoteMetadataDto } from '../notes/note-metadata.dto';
import { IsArray, IsBoolean, IsDate, IsString } from 'class-validator';
export class HistoryEntryDto {
/**
* Metadata of this note
* ID or Alias of the note
*/
@ValidateNested()
metadata: NoteMetadataDto;
@IsString()
identifier: string;
/**
* Title of the note
* Does not contain any markup but might be empty
* @example "Shopping List"
*/
@IsString()
title: string;
/**
* Datestring of the last time this note was updated
* @example "2020-12-01 12:23:34"
*/
@IsDate()
lastVisited: Date;
@IsArray()
@IsString({ each: true })
tags: string[];
/**
* True if this note is pinned

View file

@ -0,0 +1,42 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { Column, Entity, ManyToOne, UpdateDateColumn } from 'typeorm';
import { User } from '../users/user.entity';
import { Note } from '../notes/note.entity';
@Entity()
export class HistoryEntry {
@ManyToOne((_) => User, (user) => user.historyEntries, {
onDelete: 'CASCADE',
primary: true,
})
user: User;
@ManyToOne((_) => Note, (note) => note.historyEntries, {
onDelete: 'CASCADE',
primary: true,
})
note: Note;
@Column()
pinStatus: boolean;
@UpdateDateColumn()
updatedAt: Date;
// The optional note parameter is necessary for the createNote method in the NotesService,
// as we create the note then and don't need to add it to the HistoryEntry.
public static create(user: User, note?: Note): HistoryEntry {
const newHistoryEntry = new HistoryEntry();
newHistoryEntry.user = user;
if (note) {
newHistoryEntry.note = note;
}
newHistoryEntry.pinStatus = false;
return newHistoryEntry;
}
}

View file

@ -7,10 +7,19 @@
import { Module } from '@nestjs/common';
import { LoggerModule } from '../logger/logger.module';
import { HistoryService } from './history.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { HistoryEntry } from './history-entry.entity';
import { UsersModule } from '../users/users.module';
import { NotesModule } from '../notes/notes.module';
@Module({
providers: [HistoryService],
exports: [HistoryService],
imports: [LoggerModule],
imports: [
LoggerModule,
TypeOrmModule.forFeature([HistoryEntry]),
UsersModule,
NotesModule,
],
})
export class HistoryModule {}

View file

@ -7,20 +7,267 @@
import { Test, TestingModule } from '@nestjs/testing';
import { LoggerModule } from '../logger/logger.module';
import { HistoryService } from './history.service';
import { UsersModule } from '../users/users.module';
import { NotesModule } from '../notes/notes.module';
import { getRepositoryToken } from '@nestjs/typeorm';
import { Identity } from '../users/identity.entity';
import { User } from '../users/user.entity';
import { AuthorColor } from '../notes/author-color.entity';
import { Authorship } from '../revisions/authorship.entity';
import { HistoryEntry } from './history-entry.entity';
import { Note } from '../notes/note.entity';
import { Tag } from '../notes/tag.entity';
import { AuthToken } from '../auth/auth-token.entity';
import { Revision } from '../revisions/revision.entity';
import { Repository } from 'typeorm';
import { NotInDBError } from '../errors/errors';
describe('HistoryService', () => {
let service: HistoryService;
let historyRepo: Repository<HistoryEntry>;
let noteRepo: Repository<Note>;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [HistoryService],
imports: [LoggerModule],
}).compile();
providers: [
HistoryService,
{
provide: getRepositoryToken(HistoryEntry),
useClass: Repository,
},
],
imports: [LoggerModule, UsersModule, NotesModule],
})
.overrideProvider(getRepositoryToken(User))
.useValue({})
.overrideProvider(getRepositoryToken(AuthToken))
.useValue({})
.overrideProvider(getRepositoryToken(Identity))
.useValue({})
.overrideProvider(getRepositoryToken(Authorship))
.useValue({})
.overrideProvider(getRepositoryToken(AuthorColor))
.useValue({})
.overrideProvider(getRepositoryToken(Revision))
.useValue({})
.overrideProvider(getRepositoryToken(Note))
.useClass(Repository)
.overrideProvider(getRepositoryToken(Tag))
.useValue({})
.compile();
service = module.get<HistoryService>(HistoryService);
historyRepo = module.get<Repository<HistoryEntry>>(
getRepositoryToken(HistoryEntry),
);
noteRepo = module.get<Repository<Note>>(getRepositoryToken(Note));
});
it('should be defined', () => {
expect(service).toBeDefined();
});
describe('getEntriesByUser', () => {
describe('works', () => {
it('with an empty list', async () => {
jest.spyOn(historyRepo, 'find').mockResolvedValueOnce([]);
expect(await service.getEntriesByUser({} as User)).toEqual([]);
});
it('with an one element list', async () => {
const historyEntry = new HistoryEntry();
jest.spyOn(historyRepo, 'find').mockResolvedValueOnce([historyEntry]);
expect(await service.getEntriesByUser({} as User)).toEqual([
historyEntry,
]);
});
it('with an multiple element list', async () => {
const historyEntry = new HistoryEntry();
const historyEntry2 = new HistoryEntry();
jest
.spyOn(historyRepo, 'find')
.mockResolvedValueOnce([historyEntry, historyEntry2]);
expect(await service.getEntriesByUser({} as User)).toEqual([
historyEntry,
historyEntry2,
]);
});
});
});
describe('createOrUpdateHistoryEntry', () => {
describe('works', () => {
it('without an preexisting entry', async () => {
const user = new User();
const alias = 'alias';
jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(undefined);
jest
.spyOn(historyRepo, 'save')
.mockImplementation(
async (entry: HistoryEntry): Promise<HistoryEntry> => entry,
);
const createHistoryEntry = await service.createOrUpdateHistoryEntry(
Note.create(user, alias),
user,
);
expect(createHistoryEntry.note.alias).toEqual(alias);
expect(createHistoryEntry.note.owner).toEqual(user);
expect(createHistoryEntry.user).toEqual(user);
expect(createHistoryEntry.pinStatus).toEqual(false);
});
it('with an preexisting entry', async () => {
const user = new User();
const alias = 'alias';
const historyEntry = HistoryEntry.create(
user,
Note.create(user, alias),
);
jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(historyEntry);
jest
.spyOn(historyRepo, 'save')
.mockImplementation(
async (entry: HistoryEntry): Promise<HistoryEntry> => entry,
);
const createHistoryEntry = await service.createOrUpdateHistoryEntry(
Note.create(user, alias),
user,
);
expect(createHistoryEntry.note.alias).toEqual(alias);
expect(createHistoryEntry.note.owner).toEqual(user);
expect(createHistoryEntry.user).toEqual(user);
expect(createHistoryEntry.pinStatus).toEqual(false);
expect(createHistoryEntry.updatedAt.getTime()).toBeGreaterThanOrEqual(
historyEntry.updatedAt.getTime(),
);
});
});
});
describe('updateHistoryEntry', () => {
describe('works', () => {
it('with an entry', async () => {
const user = new User();
const alias = 'alias';
const note = Note.create(user, alias);
const historyEntry = HistoryEntry.create(user, note);
jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(historyEntry);
jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(note);
jest
.spyOn(historyRepo, 'save')
.mockImplementation(
async (entry: HistoryEntry): Promise<HistoryEntry> => entry,
);
const updatedHistoryEntry = await service.updateHistoryEntry(
alias,
user,
{
pinStatus: true,
},
);
expect(updatedHistoryEntry.note.alias).toEqual(alias);
expect(updatedHistoryEntry.note.owner).toEqual(user);
expect(updatedHistoryEntry.user).toEqual(user);
expect(updatedHistoryEntry.pinStatus).toEqual(true);
});
it('without an entry', async () => {
const user = new User();
const alias = 'alias';
const note = Note.create(user, alias);
jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(undefined);
jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(note);
try {
await service.updateHistoryEntry(alias, user, {
pinStatus: true,
});
} catch (e) {
expect(e).toBeInstanceOf(NotInDBError);
}
});
});
});
describe('deleteHistoryEntry', () => {
describe('works', () => {
it('with an entry', async () => {
const user = new User();
const alias = 'alias';
const note = Note.create(user, alias);
const historyEntry = HistoryEntry.create(user, note);
jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(historyEntry);
jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(note);
jest.spyOn(historyRepo, 'remove').mockImplementation(
async (entry: HistoryEntry): Promise<HistoryEntry> => {
expect(entry).toEqual(historyEntry);
return entry;
},
);
await service.deleteHistoryEntry(alias, user);
});
it('without an entry', async () => {
const user = new User();
const alias = 'alias';
const note = Note.create(user, alias);
jest.spyOn(historyRepo, 'findOne').mockResolvedValueOnce(undefined);
jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(note);
try {
await service.deleteHistoryEntry(alias, user);
} catch (e) {
expect(e).toBeInstanceOf(NotInDBError);
}
});
});
});
describe('toHistoryEntryDto', () => {
describe('works', () => {
it('with aliased note', async () => {
const user = new User();
const alias = 'alias';
const title = 'title';
const tags = ['tag1', 'tag2'];
const note = Note.create(user, alias);
note.title = title;
note.tags = tags.map((tag) => {
const newTag = new Tag();
newTag.name = tag;
return newTag;
});
const historyEntry = HistoryEntry.create(user, note);
historyEntry.pinStatus = true;
jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(note);
const historyEntryDto = await service.toHistoryEntryDto(historyEntry);
expect(historyEntryDto.pinStatus).toEqual(true);
expect(historyEntryDto.identifier).toEqual(alias);
expect(historyEntryDto.tags).toEqual(tags);
expect(historyEntryDto.title).toEqual(title);
});
it('with regular note', async () => {
const user = new User();
const title = 'title';
const id = 'id';
const tags = ['tag1', 'tag2'];
const note = Note.create(user);
note.title = title;
note.id = id;
note.tags = tags.map((tag) => {
const newTag = new Tag();
newTag.name = tag;
return newTag;
});
const historyEntry = HistoryEntry.create(user, note);
historyEntry.pinStatus = true;
jest.spyOn(noteRepo, 'findOne').mockResolvedValueOnce(note);
const historyEntryDto = await service.toHistoryEntryDto(historyEntry);
expect(historyEntryDto.pinStatus).toEqual(true);
expect(historyEntryDto.identifier).toEqual(id);
expect(historyEntryDto.tags).toEqual(tags);
expect(historyEntryDto.title).toEqual(title);
});
});
});
});

View file

@ -8,90 +8,98 @@ import { Injectable } from '@nestjs/common';
import { ConsoleLoggerService } from '../logger/console-logger.service';
import { HistoryEntryUpdateDto } from './history-entry-update.dto';
import { HistoryEntryDto } from './history-entry.dto';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { HistoryEntry } from './history-entry.entity';
import { UsersService } from '../users/users.service';
import { NotesService } from '../notes/notes.service';
import { User } from '../users/user.entity';
import { Note } from '../notes/note.entity';
import { NotInDBError } from '../errors/errors';
@Injectable()
export class HistoryService {
constructor(private readonly logger: ConsoleLoggerService) {
constructor(
private readonly logger: ConsoleLoggerService,
@InjectRepository(HistoryEntry)
private historyEntryRepository: Repository<HistoryEntry>,
private usersService: UsersService,
private notesService: NotesService,
) {
this.logger.setContext(HistoryService.name);
}
getUserHistory(username: string): HistoryEntryDto[] {
//TODO: Use the database
this.logger.warn('Using hardcoded data!');
return [
{
metadata: {
alias: null,
createTime: new Date(),
description: 'Very descriptive text.',
editedBy: [],
id: 'foobar-barfoo',
permissions: {
owner: {
displayName: 'foo',
userName: 'fooUser',
email: 'foo@example.com',
photo: '',
},
sharedToUsers: [],
sharedToGroups: [],
},
tags: [],
title: 'Title!',
updateTime: new Date(),
updateUser: {
displayName: 'foo',
userName: 'fooUser',
email: 'foo@example.com',
photo: '',
},
viewCount: 42,
},
pinStatus: false,
},
];
async getEntriesByUser(user: User): Promise<HistoryEntry[]> {
return await this.historyEntryRepository.find({
where: { user: user },
relations: ['note'],
});
}
updateHistoryEntry(
noteId: string,
updateDto: HistoryEntryUpdateDto,
): HistoryEntryDto {
//TODO: Use the database
this.logger.warn('Using hardcoded data!');
return {
metadata: {
alias: null,
createTime: new Date(),
description: 'Very descriptive text.',
editedBy: [],
id: 'foobar-barfoo',
permissions: {
owner: {
displayName: 'foo',
userName: 'fooUser',
email: 'foo@example.com',
photo: '',
},
sharedToUsers: [],
sharedToGroups: [],
},
tags: [],
title: 'Title!',
updateTime: new Date(),
updateUser: {
displayName: 'foo',
userName: 'fooUser',
email: 'foo@example.com',
photo: '',
},
viewCount: 42,
private async getEntryByNoteIdOrAlias(
noteIdOrAlias: string,
user: User,
): Promise<HistoryEntry> {
const note = await this.notesService.getNoteByIdOrAlias(noteIdOrAlias);
return await this.getEntryByNote(note, user);
}
private async getEntryByNote(note: Note, user: User): Promise<HistoryEntry> {
return await this.historyEntryRepository.findOne({
where: {
note: note,
user: user,
},
pinStatus: updateDto.pinStatus,
relations: ['note', 'user'],
});
}
async createOrUpdateHistoryEntry(
note: Note,
user: User,
): Promise<HistoryEntry> {
let entry = await this.getEntryByNote(note, user);
if (!entry) {
entry = HistoryEntry.create(user, note);
} else {
entry.updatedAt = new Date();
}
return await this.historyEntryRepository.save(entry);
}
async updateHistoryEntry(
noteIdOrAlias: string,
user: User,
updateDto: HistoryEntryUpdateDto,
): Promise<HistoryEntry> {
const entry = await this.getEntryByNoteIdOrAlias(noteIdOrAlias, user);
if (!entry) {
throw new NotInDBError(
`User '${user.userName}' has no HistoryEntry for Note with id '${noteIdOrAlias}'`,
);
}
entry.pinStatus = updateDto.pinStatus;
return this.historyEntryRepository.save(entry);
}
async deleteHistoryEntry(noteIdOrAlias: string, user: User): Promise<void> {
const entry = await this.getEntryByNoteIdOrAlias(noteIdOrAlias, user);
if (!entry) {
throw new NotInDBError(
`User '${user.userName}' has no HistoryEntry for Note with id '${noteIdOrAlias}'`,
);
}
await this.historyEntryRepository.remove(entry);
return;
}
async toHistoryEntryDto(entry: HistoryEntry): Promise<HistoryEntryDto> {
return {
identifier: entry.note.alias ? entry.note.alias : entry.note.id,
lastVisited: entry.updatedAt,
tags: this.notesService.toTagList(entry.note),
title: entry.note.title,
pinStatus: entry.pinStatus,
};
}
deleteHistoryEntry(note: string) {
//TODO: Use the database and actually do stuff
throw new Error('Not implemented');
}
}

View file

@ -20,6 +20,7 @@ import { Revision } from '../revisions/revision.entity';
import { User } from '../users/user.entity';
import { AuthorColor } from './author-color.entity';
import { Tag } from './tag.entity';
import { HistoryEntry } from '../history/history-entry.entity';
@Entity()
export class Note {
@ -53,6 +54,8 @@ export class Note {
revisions: Promise<Revision[]>;
@OneToMany((_) => AuthorColor, (authorColor) => authorColor.note)
authorColors: AuthorColor[];
@OneToMany((_) => HistoryEntry, (historyEntry) => historyEntry.user)
historyEntries: HistoryEntry[];
@Column({
nullable: true,

View file

@ -21,6 +21,7 @@ import {
import { NoteDto } from './note.dto';
import { Note } from './note.entity';
import { Tag } from './tag.entity';
import { HistoryEntry } from '../history/history-entry.entity';
@Injectable()
export class NotesService {
@ -46,6 +47,7 @@ export class NotesService {
description: 'Very descriptive text.',
userPermissions: [],
groupPermissions: [],
historyEntries: [],
tags: [],
revisions: Promise.resolve([]),
authorColors: [],
@ -69,6 +71,7 @@ export class NotesService {
newNote.alias = alias;
}
if (owner) {
newNote.historyEntries = [HistoryEntry.create(owner)];
newNote.owner = owner;
}
return this.noteRepository.save(newNote);
@ -153,12 +156,14 @@ export class NotesService {
id: '1',
identities: [],
ownedNotes: [],
historyEntries: [],
updatedAt: new Date(),
userName: 'Testy',
},
description: 'Very descriptive text.',
userPermissions: [],
groupPermissions: [],
historyEntries: [],
tags: [],
revisions: Promise.resolve([]),
authorColors: [],
@ -172,6 +177,10 @@ export class NotesService {
return this.getCurrentContent(note);
}
toTagList(note: Note): string[] {
return note.tags.map((tag) => tag.name);
}
async toNotePermissionsDto(note: Note): Promise<NotePermissionsDto> {
return {
owner: this.usersService.toUserDto(note.owner),
@ -199,7 +208,7 @@ export class NotesService {
),
// TODO: Extract into method
permissions: await this.toNotePermissionsDto(note),
tags: note.tags.map((tag) => tag.name),
tags: this.toTagList(note),
updateTime: (await this.getLatestRevision(note)).createdAt,
// TODO: Get actual updateUser
updateUser: {

View file

@ -14,6 +14,7 @@ import { Column, OneToMany } from 'typeorm';
import { Note } from '../notes/note.entity';
import { AuthToken } from '../auth/auth-token.entity';
import { Identity } from './identity.entity';
import { HistoryEntry } from '../history/history-entry.entity';
@Entity()
export class User {
@ -51,8 +52,8 @@ export class User {
@OneToMany((_) => Identity, (identity) => identity.user)
identities: Identity[];
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
@OneToMany((_) => HistoryEntry, (historyEntry) => historyEntry.user)
historyEntries: HistoryEntry[];
public static create(
userName: string,

View file

@ -0,0 +1,158 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { UserInfoDto } from '../../src/users/user-info.dto';
import { HistoryService } from '../../src/history/history.service';
import { NotesService } from '../../src/notes/notes.service';
import { HistoryEntryUpdateDto } from '../../src/history/history-entry-update.dto';
import { HistoryEntryDto } from '../../src/history/history-entry.dto';
import { HistoryEntry } from '../../src/history/history-entry.entity';
import { UsersService } from '../../src/users/users.service';
import { TokenAuthGuard } from '../../src/auth/token-auth.guard';
import { MockAuthGuard } from '../../src/auth/mock-auth.guard';
import { TypeOrmModule } from '@nestjs/typeorm';
import { PublicApiModule } from '../../src/api/public/public-api.module';
import { NotesModule } from '../../src/notes/notes.module';
import { PermissionsModule } from '../../src/permissions/permissions.module';
import { GroupsModule } from '../../src/groups/groups.module';
import { LoggerModule } from '../../src/logger/logger.module';
import { AuthModule } from '../../src/auth/auth.module';
import { UsersModule } from '../../src/users/users.module';
import { HistoryModule } from '../../src/history/history.module';
import { ConfigModule } from '@nestjs/config';
import mediaConfigMock from '../../src/config/media.config.mock';
import { User } from '../../src/users/user.entity';
// TODO Tests have to be reworked using UserService functions
describe('Notes', () => {
let app: INestApplication;
let historyService: HistoryService;
let notesService: NotesService;
let user: User;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [
ConfigModule.forRoot({
isGlobal: true,
load: [mediaConfigMock],
}),
PublicApiModule,
NotesModule,
PermissionsModule,
GroupsModule,
TypeOrmModule.forRoot({
type: 'sqlite',
database: './hedgedoc-e2e-me.sqlite',
autoLoadEntities: true,
synchronize: true,
dropSchema: true,
}),
LoggerModule,
AuthModule,
UsersModule,
HistoryModule,
],
})
.overrideGuard(TokenAuthGuard)
.useClass(MockAuthGuard)
.compile();
app = moduleRef.createNestApplication();
notesService = moduleRef.get(NotesService);
historyService = moduleRef.get(HistoryService);
const userService = moduleRef.get(UsersService);
user = await userService.createUser('hardcoded', 'Testy');
await app.init();
});
it.skip(`GET /me`, async () => {
// TODO Get user from beforeAll
const userInfo = new UserInfoDto();
const response = await request(app.getHttpServer())
.post('/me')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.content).toEqual(userInfo);
});
it(`GET /me/history`, async () => {
const noteName = 'testGetNoteHistory1';
const note = await notesService.createNote('', noteName);
const createdHistoryEntry = await historyService.createOrUpdateHistoryEntry(
note,
user,
);
const response = await request(app.getHttpServer())
.get('/me/history')
.expect('Content-Type', /json/)
.expect(200);
const history = <HistoryEntryDto[]>response.body;
for (const historyEntry of history) {
if ((<HistoryEntryDto>historyEntry).identifier === 'testGetHistory') {
expect(historyEntry).toEqual(createdHistoryEntry);
}
}
});
it(`PUT /me/history/{note}`, async () => {
const noteName = 'testGetNoteHistory2';
const note = await notesService.createNote('', noteName);
await historyService.createOrUpdateHistoryEntry(note, user);
const historyEntryUpdateDto = new HistoryEntryUpdateDto();
historyEntryUpdateDto.pinStatus = true;
const response = await request(app.getHttpServer())
.put('/me/history/' + noteName)
.send(historyEntryUpdateDto)
.expect(200);
const history = await historyService.getEntriesByUser(user);
let historyEntry: HistoryEntryDto = response.body;
expect(historyEntry.pinStatus).toEqual(true);
historyEntry = null;
for (const e of history) {
if (e.note.alias === noteName) {
historyEntry = await historyService.toHistoryEntryDto(e);
}
}
expect(historyEntry.pinStatus).toEqual(true);
});
it(`DELETE /me/history/{note}`, async () => {
const noteName = 'testGetNoteHistory3';
const note = await notesService.createNote('', noteName);
await historyService.createOrUpdateHistoryEntry(note, user);
const response = await request(app.getHttpServer())
.delete(`/me/history/${noteName}`)
.expect(204);
expect(response.body).toEqual({});
const history = await historyService.getEntriesByUser(user);
let historyEntry: HistoryEntry = null;
for (const e of history) {
if ((<HistoryEntry>e).note.alias === noteName) {
historyEntry = e;
}
}
return expect(historyEntry).toBeNull();
});
it.skip(`GET /me/notes/`, async () => {
// TODO use function from HistoryService to add an History Entry
await notesService.createNote('This is a test note.', 'test7');
// usersService.getALLNotesOwnedByUser() TODO Implement function
const response = await request(app.getHttpServer())
.get('/me/notes/')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.revisions).toHaveLength(1);
});
afterAll(async () => {
await app.close();
});
});

View file

@ -1,140 +0,0 @@
/*
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { INestApplication } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import * as request from 'supertest';
import { AppModule } from '../../src/app.module';
import { UserInfoDto } from '../../src/users/user-info.dto';
import { HistoryService } from '../../src/history/history.service';
import { NotesService } from '../../src/notes/notes.service';
import { HistoryEntryUpdateDto } from '../../src/history/history-entry-update.dto';
import { HistoryEntryDto } from '../../src/history/history-entry.dto';
// TODO Tests have to be reworked using UserService functions
describe('Notes', () => {
let app: INestApplication;
//let usersService: UsersService;
let historyService: HistoryService;
let notesService: NotesService;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [AppModule],
}).compile();
// TODO Create User and generateAPI Token or other Auth
app = moduleRef.createNestApplication();
//usersService = moduleRef.get(UsersService);
await app.init();
});
it.skip(`GET /me`, async () => {
// TODO Get user from beforeAll
const userInfo = new UserInfoDto();
const response = await request(app.getHttpServer())
.post('/me')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.content).toEqual(userInfo);
});
it.skip(`GET /me/history`, async () => {
// TODO user has to be chosen
/* TODO Note maybe not added to history by createNote,
use function from HistoryService instead
*/
await notesService.createNote('', 'testGetHistory');
const response = await request(app.getHttpServer())
.get('/me/history')
.expect('Content-Type', /json/)
.expect(200);
let historyEntry: HistoryEntryDto;
for (const e of <any[]>response.body.content) {
if ((<HistoryEntryDto>e).metadata.alias === 'testGetHistory') {
historyEntry = e;
}
}
expect(historyEntry).toEqual(history);
});
it.skip(`GET /me/history/{note}`, async () => {
const noteName = 'testGetNoteHistory';
/* TODO Note maybe not added to history by createNote,
use function from HistoryService instead
*/
await notesService.createNote('', noteName);
const response = await request(app.getHttpServer())
.get('/me/history/' + noteName)
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.metadata?.id).toBeDefined();
return expect(response.body.metadata.alias).toEqual(noteName);
});
it.skip(`DELETE /me/history/{note}`, async () => {
const noteName = 'testDeleteNoteHistory';
/* TODO Note maybe not added to history by createNote,
use function from HistoryService instead
*/
await notesService.createNote('This is a test note.', noteName);
const response = await request(app.getHttpServer())
.delete('/me/history/test3')
.expect(204);
expect(response.body.content).toBeNull();
const history = historyService.getUserHistory('testuser');
let historyEntry: HistoryEntryDto = null;
for (const e of history) {
if (e.metadata.alias === noteName) {
historyEntry = e;
}
}
return expect(historyEntry).toBeNull();
});
it.skip(`PUT /me/history/{note}`, async () => {
const noteName = 'testPutNoteHistory';
// TODO use function from HistoryService to add an History Entry
await notesService.createNote('', noteName);
const historyEntryUpdateDto = new HistoryEntryUpdateDto();
historyEntryUpdateDto.pinStatus = true;
const response = await request(app.getHttpServer())
.put('/me/history/' + noteName)
.send(historyEntryUpdateDto)
.expect(200);
// TODO parameter is not used for now
const history = historyService.getUserHistory('testuser');
let historyEntry: HistoryEntryDto;
for (const e of <any[]>response.body.content) {
if ((<HistoryEntryDto>e).metadata.alias === noteName) {
historyEntry = e;
}
}
expect(historyEntry.pinStatus).toEqual(true);
historyEntry = null;
for (const e of history) {
if (e.metadata.alias === noteName) {
historyEntry = e;
}
}
expect(historyEntry.pinStatus).toEqual(true);
});
it.skip(`GET /me/notes/`, async () => {
// TODO use function from HistoryService to add an History Entry
await notesService.createNote('This is a test note.', 'test7');
// usersService.getALLNotesOwnedByUser() TODO Implement function
const response = await request(app.getHttpServer())
.get('/me/notes/')
.expect('Content-Type', /json/)
.expect(200);
expect(response.body.revisions).toHaveLength(1);
});
afterAll(async () => {
await app.close();
});
});