mirror of
https://github.com/hedgedoc/hedgedoc.git
synced 2025-06-04 08:49:59 -04:00
fix(repository): Move backend code into subdirectory
Signed-off-by: Tilman Vatteroth <git@tilmanvatteroth.de>
This commit is contained in:
parent
86584e705f
commit
bf30cbcf48
272 changed files with 87 additions and 67 deletions
45
backend/src/history/history-entry-import.dto.ts
Normal file
45
backend/src/history/history-entry-import.dto.ts
Normal file
|
@ -0,0 +1,45 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDate,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
// This needs to be here because of weird import-behaviour during tests
|
||||
import 'reflect-metadata';
|
||||
|
||||
import { BaseDto } from '../utils/base.dto.';
|
||||
|
||||
export class HistoryEntryImportDto extends BaseDto {
|
||||
/**
|
||||
* ID or Alias of the note
|
||||
*/
|
||||
@IsString()
|
||||
note: string;
|
||||
/**
|
||||
* True if the note should be pinned
|
||||
* @example true
|
||||
*/
|
||||
@IsBoolean()
|
||||
pinStatus: boolean;
|
||||
/**
|
||||
* Datestring of the last time this note was updated
|
||||
* @example "2020-12-01 12:23:34"
|
||||
*/
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
lastVisitedAt: Date;
|
||||
}
|
||||
|
||||
export class HistoryEntryImportListDto extends BaseDto {
|
||||
@ValidateNested({ each: true })
|
||||
@IsArray()
|
||||
@Type(() => HistoryEntryImportDto)
|
||||
history: HistoryEntryImportDto[];
|
||||
}
|
18
backend/src/history/history-entry-update.dto.ts
Normal file
18
backend/src/history/history-entry-update.dto.ts
Normal file
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
import { BaseDto } from '../utils/base.dto.';
|
||||
|
||||
export class HistoryEntryUpdateDto extends BaseDto {
|
||||
/**
|
||||
* True if the note should be pinned
|
||||
*/
|
||||
@IsBoolean()
|
||||
@ApiProperty()
|
||||
pinStatus: boolean;
|
||||
}
|
50
backend/src/history/history-entry.dto.ts
Normal file
50
backend/src/history/history-entry.dto.ts
Normal file
|
@ -0,0 +1,50 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsArray, IsBoolean, IsDate, IsString } from 'class-validator';
|
||||
|
||||
import { BaseDto } from '../utils/base.dto.';
|
||||
|
||||
export class HistoryEntryDto extends BaseDto {
|
||||
/**
|
||||
* ID or Alias of the note
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
identifier: string;
|
||||
|
||||
/**
|
||||
* Title of the note
|
||||
* Does not contain any markup but might be empty
|
||||
* @example "Shopping List"
|
||||
*/
|
||||
@IsString()
|
||||
@ApiProperty()
|
||||
title: string;
|
||||
|
||||
/**
|
||||
* Datestring of the last time this note was updated
|
||||
* @example "2020-12-01 12:23:34"
|
||||
*/
|
||||
@IsDate()
|
||||
@Type(() => Date)
|
||||
@ApiProperty()
|
||||
lastVisitedAt: Date;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
@ApiProperty()
|
||||
tags: string[];
|
||||
|
||||
/**
|
||||
* True if this note is pinned
|
||||
* @example false
|
||||
*/
|
||||
@IsBoolean()
|
||||
@ApiProperty()
|
||||
pinStatus: boolean;
|
||||
}
|
59
backend/src/history/history-entry.entity.ts
Normal file
59
backend/src/history/history-entry.entity.ts
Normal file
|
@ -0,0 +1,59 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
|
||||
import { Note } from '../notes/note.entity';
|
||||
import { User } from '../users/user.entity';
|
||||
|
||||
@Entity()
|
||||
@Index(['note', 'user'], { unique: true })
|
||||
export class HistoryEntry {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@ManyToOne((_) => User, (user) => user.historyEntries, {
|
||||
onDelete: 'CASCADE',
|
||||
orphanedRowAction: 'delete', // This ensures the row of the history entry is deleted when no user references it anymore
|
||||
})
|
||||
user: Promise<User>;
|
||||
|
||||
@ManyToOne((_) => Note, (note) => note.historyEntries, {
|
||||
onDelete: 'CASCADE',
|
||||
orphanedRowAction: 'delete', // This ensures the row of the history entry is deleted when no note references it anymore
|
||||
})
|
||||
note: Promise<Note>;
|
||||
|
||||
@Column()
|
||||
pinStatus: boolean;
|
||||
|
||||
@UpdateDateColumn()
|
||||
updatedAt: Date;
|
||||
|
||||
/**
|
||||
* Create a history entry
|
||||
* @param user the user the history entry is associated with
|
||||
* @param note the note the history entry is associated with
|
||||
* @param [pinStatus=false] if the history entry should be pinned
|
||||
*/
|
||||
public static create(
|
||||
user: User,
|
||||
note: Note,
|
||||
pinStatus = false,
|
||||
): Omit<HistoryEntry, 'updatedAt'> {
|
||||
const newHistoryEntry = new HistoryEntry();
|
||||
newHistoryEntry.user = Promise.resolve(user);
|
||||
newHistoryEntry.note = Promise.resolve(note);
|
||||
newHistoryEntry.pinStatus = pinStatus;
|
||||
return newHistoryEntry;
|
||||
}
|
||||
}
|
27
backend/src/history/history.module.ts
Normal file
27
backend/src/history/history.module.ts
Normal file
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
import { NotesModule } from '../notes/notes.module';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { HistoryEntry } from './history-entry.entity';
|
||||
import { HistoryService } from './history.service';
|
||||
|
||||
@Module({
|
||||
providers: [HistoryService],
|
||||
exports: [HistoryService],
|
||||
imports: [
|
||||
LoggerModule,
|
||||
TypeOrmModule.forFeature([HistoryEntry]),
|
||||
UsersModule,
|
||||
NotesModule,
|
||||
ConfigModule,
|
||||
],
|
||||
})
|
||||
export class HistoryModule {}
|
442
backend/src/history/history.service.spec.ts
Normal file
442
backend/src/history/history.service.spec.ts
Normal file
|
@ -0,0 +1,442 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getDataSourceToken, getRepositoryToken } from '@nestjs/typeorm';
|
||||
import assert from 'assert';
|
||||
import { Mock } from 'ts-mockery';
|
||||
import { DataSource, EntityManager, Repository } from 'typeorm';
|
||||
|
||||
import { AuthToken } from '../auth/auth-token.entity';
|
||||
import { Author } from '../authors/author.entity';
|
||||
import appConfigMock from '../config/mock/app.config.mock';
|
||||
import authConfigMock from '../config/mock/auth.config.mock';
|
||||
import databaseConfigMock from '../config/mock/database.config.mock';
|
||||
import noteConfigMock from '../config/mock/note.config.mock';
|
||||
import { NotInDBError } from '../errors/errors';
|
||||
import { eventModuleConfig } from '../events';
|
||||
import { Group } from '../groups/group.entity';
|
||||
import { Identity } from '../identity/identity.entity';
|
||||
import { LoggerModule } from '../logger/logger.module';
|
||||
import { Alias } from '../notes/alias.entity';
|
||||
import { Note } from '../notes/note.entity';
|
||||
import { NotesModule } from '../notes/notes.module';
|
||||
import { Tag } from '../notes/tag.entity';
|
||||
import { NoteGroupPermission } from '../permissions/note-group-permission.entity';
|
||||
import { NoteUserPermission } from '../permissions/note-user-permission.entity';
|
||||
import { Edit } from '../revisions/edit.entity';
|
||||
import { Revision } from '../revisions/revision.entity';
|
||||
import { Session } from '../users/session.entity';
|
||||
import { User } from '../users/user.entity';
|
||||
import { UsersModule } from '../users/users.module';
|
||||
import { mockSelectQueryBuilderInRepo } from '../utils/test-utils/mockSelectQueryBuilder';
|
||||
import { HistoryEntryImportDto } from './history-entry-import.dto';
|
||||
import { HistoryEntry } from './history-entry.entity';
|
||||
import { HistoryService } from './history.service';
|
||||
|
||||
describe('HistoryService', () => {
|
||||
let service: HistoryService;
|
||||
let historyRepo: Repository<HistoryEntry>;
|
||||
let noteRepo: Repository<Note>;
|
||||
let mockedTransaction: jest.Mock<
|
||||
Promise<void>,
|
||||
[(entityManager: EntityManager) => Promise<void>]
|
||||
>;
|
||||
|
||||
class CreateQueryBuilderClass {
|
||||
leftJoinAndSelect: () => CreateQueryBuilderClass;
|
||||
where: () => CreateQueryBuilderClass;
|
||||
orWhere: () => CreateQueryBuilderClass;
|
||||
setParameter: () => CreateQueryBuilderClass;
|
||||
getOne: () => HistoryEntry;
|
||||
getMany: () => HistoryEntry[];
|
||||
}
|
||||
|
||||
let createQueryBuilderFunc: CreateQueryBuilderClass;
|
||||
|
||||
beforeEach(async () => {
|
||||
noteRepo = new Repository<Note>(
|
||||
'',
|
||||
new EntityManager(
|
||||
new DataSource({
|
||||
type: 'sqlite',
|
||||
database: ':memory:',
|
||||
}),
|
||||
),
|
||||
undefined,
|
||||
);
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
HistoryService,
|
||||
{
|
||||
provide: getDataSourceToken(),
|
||||
useFactory: () => {
|
||||
mockedTransaction = jest.fn();
|
||||
return Mock.of<DataSource>({
|
||||
transaction: mockedTransaction,
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(HistoryEntry),
|
||||
useClass: Repository,
|
||||
},
|
||||
{
|
||||
provide: getRepositoryToken(Note),
|
||||
useValue: noteRepo,
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
LoggerModule,
|
||||
UsersModule,
|
||||
NotesModule,
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
load: [
|
||||
appConfigMock,
|
||||
databaseConfigMock,
|
||||
authConfigMock,
|
||||
noteConfigMock,
|
||||
],
|
||||
}),
|
||||
EventEmitterModule.forRoot(eventModuleConfig),
|
||||
],
|
||||
})
|
||||
.overrideProvider(getRepositoryToken(User))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(AuthToken))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Identity))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Edit))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Revision))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Note))
|
||||
.useValue(noteRepo)
|
||||
.overrideProvider(getRepositoryToken(Tag))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(NoteGroupPermission))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(NoteUserPermission))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Group))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Session))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Author))
|
||||
.useValue({})
|
||||
.overrideProvider(getRepositoryToken(Alias))
|
||||
.useClass(Repository)
|
||||
.compile();
|
||||
|
||||
service = module.get<HistoryService>(HistoryService);
|
||||
historyRepo = module.get<Repository<HistoryEntry>>(
|
||||
getRepositoryToken(HistoryEntry),
|
||||
);
|
||||
noteRepo = module.get<Repository<Note>>(getRepositoryToken(Note));
|
||||
const historyEntry = new HistoryEntry();
|
||||
const createQueryBuilder = {
|
||||
leftJoinAndSelect: () => createQueryBuilder,
|
||||
where: () => createQueryBuilder,
|
||||
orWhere: () => createQueryBuilder,
|
||||
setParameter: () => createQueryBuilder,
|
||||
getOne: () => historyEntry,
|
||||
getMany: () => [historyEntry],
|
||||
};
|
||||
createQueryBuilderFunc = createQueryBuilder as CreateQueryBuilderClass;
|
||||
jest
|
||||
.spyOn(historyRepo, 'createQueryBuilder')
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore
|
||||
.mockImplementation(() => createQueryBuilder);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
|
||||
describe('getEntriesByUser', () => {
|
||||
describe('works', () => {
|
||||
it('with an empty list', async () => {
|
||||
createQueryBuilderFunc.getMany = () => [];
|
||||
expect(await service.getEntriesByUser({} as User)).toEqual([]);
|
||||
});
|
||||
|
||||
it('with an one element list', async () => {
|
||||
const historyEntry = new HistoryEntry();
|
||||
createQueryBuilderFunc.getMany = () => [historyEntry];
|
||||
expect(await service.getEntriesByUser({} as User)).toEqual([
|
||||
historyEntry,
|
||||
]);
|
||||
});
|
||||
|
||||
it('with an multiple element list', async () => {
|
||||
const historyEntry = new HistoryEntry();
|
||||
const historyEntry2 = new HistoryEntry();
|
||||
createQueryBuilderFunc.getMany = () => [historyEntry, historyEntry2];
|
||||
expect(await service.getEntriesByUser({} as User)).toEqual([
|
||||
historyEntry,
|
||||
historyEntry2,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateHistoryEntryTimestamp', () => {
|
||||
describe('works', () => {
|
||||
const user = {} as User;
|
||||
const alias = 'alias';
|
||||
const historyEntry = HistoryEntry.create(
|
||||
user,
|
||||
Note.create(user, alias) as Note,
|
||||
) as HistoryEntry;
|
||||
it('without an preexisting entry', async () => {
|
||||
mockSelectQueryBuilderInRepo(historyRepo, null);
|
||||
jest
|
||||
.spyOn(historyRepo, 'save')
|
||||
.mockImplementation(
|
||||
async (entry): Promise<HistoryEntry> => entry as HistoryEntry,
|
||||
);
|
||||
const createHistoryEntry = await service.updateHistoryEntryTimestamp(
|
||||
Note.create(user, alias) as Note,
|
||||
user,
|
||||
);
|
||||
assert(createHistoryEntry != null);
|
||||
expect(await (await createHistoryEntry.note).aliases).toHaveLength(1);
|
||||
expect((await (await createHistoryEntry.note).aliases)[0].name).toEqual(
|
||||
alias,
|
||||
);
|
||||
expect(await (await createHistoryEntry.note).owner).toEqual(user);
|
||||
expect(await createHistoryEntry.user).toEqual(user);
|
||||
expect(createHistoryEntry.pinStatus).toEqual(false);
|
||||
});
|
||||
|
||||
it('with an preexisting entry', async () => {
|
||||
mockSelectQueryBuilderInRepo(historyRepo, historyEntry);
|
||||
jest
|
||||
.spyOn(historyRepo, 'save')
|
||||
.mockImplementation(
|
||||
async (entry): Promise<HistoryEntry> => entry as HistoryEntry,
|
||||
);
|
||||
const createHistoryEntry = await service.updateHistoryEntryTimestamp(
|
||||
Note.create(user, alias) as Note,
|
||||
user,
|
||||
);
|
||||
assert(createHistoryEntry != null);
|
||||
expect(await (await createHistoryEntry.note).aliases).toHaveLength(1);
|
||||
expect((await (await createHistoryEntry.note).aliases)[0].name).toEqual(
|
||||
alias,
|
||||
);
|
||||
expect(await (await createHistoryEntry.note).owner).toEqual(user);
|
||||
expect(await createHistoryEntry.user).toEqual(user);
|
||||
expect(createHistoryEntry.pinStatus).toEqual(false);
|
||||
expect(createHistoryEntry.updatedAt.getTime()).toBeGreaterThanOrEqual(
|
||||
historyEntry.updatedAt.getTime(),
|
||||
);
|
||||
});
|
||||
});
|
||||
it('returns null if user is null', async () => {
|
||||
const entry = await service.updateHistoryEntryTimestamp({} as Note, null);
|
||||
expect(entry).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateHistoryEntry', () => {
|
||||
const user = {} as User;
|
||||
const alias = 'alias';
|
||||
const note = Note.create(user, alias) as Note;
|
||||
beforeEach(() => {
|
||||
mockSelectQueryBuilderInRepo(noteRepo, note);
|
||||
});
|
||||
describe('works', () => {
|
||||
it('with an entry', async () => {
|
||||
const historyEntry = HistoryEntry.create(user, note) as HistoryEntry;
|
||||
mockSelectQueryBuilderInRepo(historyRepo, historyEntry);
|
||||
jest
|
||||
.spyOn(historyRepo, 'save')
|
||||
.mockImplementation(
|
||||
async (entry): Promise<HistoryEntry> => entry as HistoryEntry,
|
||||
);
|
||||
const updatedHistoryEntry = await service.updateHistoryEntry(
|
||||
note,
|
||||
user,
|
||||
{
|
||||
pinStatus: true,
|
||||
},
|
||||
);
|
||||
expect(await (await updatedHistoryEntry.note).aliases).toHaveLength(1);
|
||||
expect(
|
||||
(await (await updatedHistoryEntry.note).aliases)[0].name,
|
||||
).toEqual(alias);
|
||||
expect(await (await updatedHistoryEntry.note).owner).toEqual(user);
|
||||
expect(await updatedHistoryEntry.user).toEqual(user);
|
||||
expect(updatedHistoryEntry.pinStatus).toEqual(true);
|
||||
});
|
||||
|
||||
it('without an entry', async () => {
|
||||
mockSelectQueryBuilderInRepo(historyRepo, null);
|
||||
await expect(
|
||||
service.updateHistoryEntry(note, user, {
|
||||
pinStatus: true,
|
||||
}),
|
||||
).rejects.toThrow(NotInDBError);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteHistoryEntry', () => {
|
||||
describe('works', () => {
|
||||
const user = {} as User;
|
||||
const alias = 'alias';
|
||||
const note = Note.create(user, alias) as Note;
|
||||
const historyEntry = HistoryEntry.create(user, note) as HistoryEntry;
|
||||
it('with an entry', async () => {
|
||||
createQueryBuilderFunc.getMany = () => [historyEntry];
|
||||
jest
|
||||
.spyOn(historyRepo, 'remove')
|
||||
.mockImplementationOnce(
|
||||
async (entry: HistoryEntry): Promise<HistoryEntry> => {
|
||||
expect(entry).toEqual(historyEntry);
|
||||
return entry;
|
||||
},
|
||||
);
|
||||
await service.deleteHistory(user);
|
||||
});
|
||||
it('with multiple entries', async () => {
|
||||
const alias2 = 'alias2';
|
||||
const note2 = Note.create(user, alias2) as Note;
|
||||
const historyEntry2 = HistoryEntry.create(user, note2) as HistoryEntry;
|
||||
createQueryBuilderFunc.getMany = () => [historyEntry, historyEntry2];
|
||||
jest
|
||||
.spyOn(historyRepo, 'remove')
|
||||
.mockImplementationOnce(
|
||||
async (entry: HistoryEntry): Promise<HistoryEntry> => {
|
||||
expect(entry).toEqual(historyEntry);
|
||||
return entry;
|
||||
},
|
||||
)
|
||||
.mockImplementationOnce(
|
||||
async (entry: HistoryEntry): Promise<HistoryEntry> => {
|
||||
expect(entry).toEqual(historyEntry2);
|
||||
return entry;
|
||||
},
|
||||
);
|
||||
await service.deleteHistory(user);
|
||||
});
|
||||
it('without an entry', async () => {
|
||||
createQueryBuilderFunc.getMany = () => [];
|
||||
await service.deleteHistory(user);
|
||||
expect(true).toBeTruthy();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteHistory', () => {
|
||||
describe('works', () => {
|
||||
it('with an entry', async () => {
|
||||
const user = {} as User;
|
||||
const alias = 'alias';
|
||||
const note = Note.create(user, alias) as Note;
|
||||
const historyEntry = HistoryEntry.create(user, note) as HistoryEntry;
|
||||
mockSelectQueryBuilderInRepo(historyRepo, historyEntry);
|
||||
mockSelectQueryBuilderInRepo(noteRepo, note);
|
||||
jest
|
||||
.spyOn(historyRepo, 'remove')
|
||||
.mockImplementation(
|
||||
async (entry: HistoryEntry): Promise<HistoryEntry> => {
|
||||
expect(entry).toEqual(historyEntry);
|
||||
return entry;
|
||||
},
|
||||
);
|
||||
await service.deleteHistoryEntry(note, user);
|
||||
});
|
||||
});
|
||||
describe('fails', () => {
|
||||
const user = {} as User;
|
||||
const alias = 'alias';
|
||||
it('without an entry', async () => {
|
||||
const note = Note.create(user, alias) as Note;
|
||||
|
||||
mockSelectQueryBuilderInRepo(historyRepo, null);
|
||||
mockSelectQueryBuilderInRepo(noteRepo, note);
|
||||
await expect(service.deleteHistoryEntry(note, user)).rejects.toThrow(
|
||||
NotInDBError,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('setHistory', () => {
|
||||
it('works', async () => {
|
||||
const user = {} as User;
|
||||
const alias = 'alias';
|
||||
const note = Note.create(user, alias) as Note;
|
||||
const historyEntry = HistoryEntry.create(user, note);
|
||||
const historyEntryImport: HistoryEntryImportDto = {
|
||||
lastVisitedAt: new Date('2020-12-01 12:23:34'),
|
||||
note: alias,
|
||||
pinStatus: true,
|
||||
};
|
||||
const newlyCreatedHistoryEntry: HistoryEntry = {
|
||||
...historyEntry,
|
||||
pinStatus: historyEntryImport.pinStatus,
|
||||
updatedAt: historyEntryImport.lastVisitedAt,
|
||||
};
|
||||
|
||||
const createQueryBuilder = mockSelectQueryBuilderInRepo(noteRepo, note);
|
||||
const mockedManager = Mock.of<EntityManager>({
|
||||
find: jest.fn().mockResolvedValueOnce([historyEntry]),
|
||||
createQueryBuilder: () => createQueryBuilder,
|
||||
remove: jest.fn().mockImplementationOnce(async (_: HistoryEntry) => {
|
||||
// TODO: reimplement checks below
|
||||
//expect(await (await entry.note).aliases).toHaveLength(1);
|
||||
//expect((await (await entry.note).aliases)[0].name).toEqual(alias);
|
||||
//expect(entry.pinStatus).toEqual(false);
|
||||
}),
|
||||
save: jest.fn().mockImplementationOnce(async (entry: HistoryEntry) => {
|
||||
expect((await entry.note).aliases).toEqual(
|
||||
(await newlyCreatedHistoryEntry.note).aliases,
|
||||
);
|
||||
expect(entry.pinStatus).toEqual(newlyCreatedHistoryEntry.pinStatus);
|
||||
expect(entry.updatedAt).toEqual(newlyCreatedHistoryEntry.updatedAt);
|
||||
}),
|
||||
});
|
||||
mockedTransaction.mockImplementation((cb) => cb(mockedManager));
|
||||
await service.setHistory(user, [historyEntryImport]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('toHistoryEntryDto', () => {
|
||||
describe('works', () => {
|
||||
it('with aliased note', async () => {
|
||||
const user = {} as User;
|
||||
const alias = 'alias';
|
||||
const title = 'title';
|
||||
const tags = ['tag1', 'tag2'];
|
||||
const note = Note.create(user, alias) as Note;
|
||||
note.title = title;
|
||||
note.tags = Promise.resolve(
|
||||
tags.map((tag) => {
|
||||
const newTag = new Tag();
|
||||
newTag.name = tag;
|
||||
return newTag;
|
||||
}),
|
||||
);
|
||||
const historyEntry = HistoryEntry.create(user, note) as HistoryEntry;
|
||||
historyEntry.pinStatus = true;
|
||||
|
||||
mockSelectQueryBuilderInRepo(noteRepo, 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
188
backend/src/history/history.service.ts
Normal file
188
backend/src/history/history.service.ts
Normal file
|
@ -0,0 +1,188 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2022 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectConnection, InjectRepository } from '@nestjs/typeorm';
|
||||
import { Connection, Repository } from 'typeorm';
|
||||
|
||||
import { NotInDBError } from '../errors/errors';
|
||||
import { ConsoleLoggerService } from '../logger/console-logger.service';
|
||||
import { Note } from '../notes/note.entity';
|
||||
import { NotesService } from '../notes/notes.service';
|
||||
import { User } from '../users/user.entity';
|
||||
import { UsersService } from '../users/users.service';
|
||||
import { HistoryEntryImportDto } from './history-entry-import.dto';
|
||||
import { HistoryEntryUpdateDto } from './history-entry-update.dto';
|
||||
import { HistoryEntryDto } from './history-entry.dto';
|
||||
import { HistoryEntry } from './history-entry.entity';
|
||||
import { getIdentifier } from './utils';
|
||||
|
||||
@Injectable()
|
||||
export class HistoryService {
|
||||
constructor(
|
||||
private readonly logger: ConsoleLoggerService,
|
||||
@InjectConnection()
|
||||
private connection: Connection,
|
||||
@InjectRepository(HistoryEntry)
|
||||
private historyEntryRepository: Repository<HistoryEntry>,
|
||||
private usersService: UsersService,
|
||||
private notesService: NotesService,
|
||||
) {
|
||||
this.logger.setContext(HistoryService.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Get all entries of a user
|
||||
* @param {User} user - the user the entries should be from
|
||||
* @return {HistoryEntry[]} an array of history entries of the specified user
|
||||
*/
|
||||
async getEntriesByUser(user: User): Promise<HistoryEntry[]> {
|
||||
return await this.historyEntryRepository
|
||||
.createQueryBuilder('entry')
|
||||
.where('entry.userId = :userId', { userId: user.id })
|
||||
.getMany();
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Get a history entry by the user and note
|
||||
* @param {Note} note - the note that the history entry belongs to
|
||||
* @param {User} user - the user that the history entry belongs to
|
||||
* @return {HistoryEntry} the requested history entry
|
||||
*/
|
||||
async getEntryByNote(note: Note, user: User): Promise<HistoryEntry> {
|
||||
const entry = await this.historyEntryRepository
|
||||
.createQueryBuilder('entry')
|
||||
.where('entry.note = :note', { note: note.id })
|
||||
.andWhere('entry.user = :user', { user: user.id })
|
||||
.leftJoinAndSelect('entry.note', 'note')
|
||||
.leftJoinAndSelect('entry.user', 'user')
|
||||
.getOne();
|
||||
if (!entry) {
|
||||
throw new NotInDBError(
|
||||
`User '${user.username}' has no HistoryEntry for Note with id '${note.id}'`,
|
||||
);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Updates the updatedAt timestamp of a HistoryEntry.
|
||||
* If no history entry exists, it will be created.
|
||||
* @param {Note} note - the note that the history entry belongs to
|
||||
* @param {User | null} user - the user that the history entry belongs to
|
||||
* @return {HistoryEntry} the requested history entry
|
||||
*/
|
||||
async updateHistoryEntryTimestamp(
|
||||
note: Note,
|
||||
user: User | null,
|
||||
): Promise<HistoryEntry | null> {
|
||||
if (user == null) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const entry = await this.getEntryByNote(note, user);
|
||||
entry.updatedAt = new Date();
|
||||
return await this.historyEntryRepository.save(entry);
|
||||
} catch (e) {
|
||||
if (e instanceof NotInDBError) {
|
||||
const entry = HistoryEntry.create(user, note);
|
||||
return await this.historyEntryRepository.save(entry);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Update a history entry identified by the user and a note id or alias
|
||||
* @param {Note} note - the note that the history entry belongs to
|
||||
* @param {User} user - the user that the history entry belongs to
|
||||
* @param {HistoryEntryUpdateDto} updateDto - the change that should be applied to the history entry
|
||||
* @return {HistoryEntry} the requested history entry
|
||||
*/
|
||||
async updateHistoryEntry(
|
||||
note: Note,
|
||||
user: User,
|
||||
updateDto: HistoryEntryUpdateDto,
|
||||
): Promise<HistoryEntry> {
|
||||
const entry = await this.getEntryByNote(note, user);
|
||||
entry.pinStatus = updateDto.pinStatus;
|
||||
return await this.historyEntryRepository.save(entry);
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Delete the history entry identified by the user and a note id or alias
|
||||
* @param {Note} note - the note that the history entry belongs to
|
||||
* @param {User} user - the user that the history entry belongs to
|
||||
* @throws {NotInDBError} the specified history entry does not exist
|
||||
*/
|
||||
async deleteHistoryEntry(note: Note, user: User): Promise<void> {
|
||||
const entry = await this.getEntryByNote(note, user);
|
||||
await this.historyEntryRepository.remove(entry);
|
||||
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<void> {
|
||||
const entries: HistoryEntry[] = await this.getEntriesByUser(user);
|
||||
for (const entry of entries) {
|
||||
await this.historyEntryRepository.remove(entry);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @async
|
||||
* Replace the user history with the provided history
|
||||
* @param {User} user - the user that get's their history replaces
|
||||
* @param {HistoryEntryImportDto[]} history
|
||||
* @throws {ForbiddenIdError} one of the note ids or alias in the new history are forbidden
|
||||
*/
|
||||
async setHistory(
|
||||
user: User,
|
||||
history: HistoryEntryImportDto[],
|
||||
): Promise<void> {
|
||||
await this.connection.transaction(async (manager) => {
|
||||
const currentHistory = await manager
|
||||
.createQueryBuilder(HistoryEntry, 'entry')
|
||||
.where('entry.userId = :userId', { userId: user.id })
|
||||
.getMany();
|
||||
for (const entry of currentHistory) {
|
||||
await manager.remove<HistoryEntry>(entry);
|
||||
}
|
||||
for (const historyEntry of history) {
|
||||
const note = await this.notesService.getNoteByIdOrAlias(
|
||||
historyEntry.note,
|
||||
);
|
||||
const entry = HistoryEntry.create(user, note) as HistoryEntry;
|
||||
entry.pinStatus = historyEntry.pinStatus;
|
||||
entry.updatedAt = historyEntry.lastVisitedAt;
|
||||
await manager.save<HistoryEntry>(entry);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Build HistoryEntryDto from a history entry.
|
||||
* @param {HistoryEntry} entry - the history entry to use
|
||||
* @return {HistoryEntryDto} the built HistoryEntryDto
|
||||
*/
|
||||
async toHistoryEntryDto(entry: HistoryEntry): Promise<HistoryEntryDto> {
|
||||
return {
|
||||
identifier: await getIdentifier(entry),
|
||||
lastVisitedAt: entry.updatedAt,
|
||||
tags: await this.notesService.toTagList(await entry.note),
|
||||
title: (await entry.note).title ?? '',
|
||||
pinStatus: entry.pinStatus,
|
||||
};
|
||||
}
|
||||
}
|
36
backend/src/history/utils.spec.ts
Normal file
36
backend/src/history/utils.spec.ts
Normal file
|
@ -0,0 +1,36 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { Alias } from '../notes/alias.entity';
|
||||
import { Note } from '../notes/note.entity';
|
||||
import { User } from '../users/user.entity';
|
||||
import { HistoryEntry } from './history-entry.entity';
|
||||
import { getIdentifier } from './utils';
|
||||
|
||||
describe('getIdentifier', () => {
|
||||
const alias = 'alias';
|
||||
let note: Note;
|
||||
let entry: HistoryEntry;
|
||||
beforeEach(() => {
|
||||
const user = User.create('hardcoded', 'Testy') as User;
|
||||
note = Note.create(user, alias) as Note;
|
||||
entry = HistoryEntry.create(user, note) as HistoryEntry;
|
||||
});
|
||||
it('returns the publicId if there are no aliases', async () => {
|
||||
note.aliases = Promise.resolve(undefined as unknown as Alias[]);
|
||||
expect(await getIdentifier(entry)).toEqual(note.publicId);
|
||||
});
|
||||
it('returns the publicId, if the alias array is empty', async () => {
|
||||
note.aliases = Promise.resolve([]);
|
||||
expect(await getIdentifier(entry)).toEqual(note.publicId);
|
||||
});
|
||||
it('returns the publicId, if the only alias is not primary', async () => {
|
||||
(await note.aliases)[0].primary = false;
|
||||
expect(await getIdentifier(entry)).toEqual(note.publicId);
|
||||
});
|
||||
it('returns the primary alias, if one exists', async () => {
|
||||
expect(await getIdentifier(entry)).toEqual((await note.aliases)[0].name);
|
||||
});
|
||||
});
|
19
backend/src/history/utils.ts
Normal file
19
backend/src/history/utils.ts
Normal file
|
@ -0,0 +1,19 @@
|
|||
/*
|
||||
* SPDX-FileCopyrightText: 2021 The HedgeDoc developers (see AUTHORS file)
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
import { getPrimaryAlias } from '../notes/utils';
|
||||
import { HistoryEntry } from './history-entry.entity';
|
||||
|
||||
export async function getIdentifier(entry: HistoryEntry): Promise<string> {
|
||||
const aliases = await (await entry.note).aliases;
|
||||
if (!aliases || aliases.length === 0) {
|
||||
return (await entry.note).publicId;
|
||||
}
|
||||
const primaryAlias = await getPrimaryAlias(await entry.note);
|
||||
if (primaryAlias === undefined) {
|
||||
return (await entry.note).publicId;
|
||||
}
|
||||
return primaryAlias;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue