/* * 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 { getRepositoryToken } from '@nestjs/typeorm'; import { Mock } from 'ts-mockery'; import { 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 { Session } from '../sessions/session.entity'; import { User } from '../users/user.entity'; import { Edit } from './edit.entity'; import { EditService } from './edit.service'; import { Revision } from './revision.entity'; import { RevisionsService } from './revisions.service'; describe('RevisionsService', () => { let service: RevisionsService; let revisionRepo: Repository; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ RevisionsService, EditService, { provide: getRepositoryToken(Revision), useClass: Repository, }, ], imports: [ NotesModule, LoggerModule, ConfigModule.forRoot({ isGlobal: true, load: [ appConfigMock, databaseConfigMock, authConfigMock, noteConfigMock, ], }), EventEmitterModule.forRoot(eventModuleConfig), ], }) .overrideProvider(getRepositoryToken(Edit)) .useValue({}) .overrideProvider(getRepositoryToken(User)) .useValue({}) .overrideProvider(getRepositoryToken(AuthToken)) .useValue({}) .overrideProvider(getRepositoryToken(Identity)) .useValue({}) .overrideProvider(getRepositoryToken(Note)) .useValue({}) .overrideProvider(getRepositoryToken(Revision)) .useClass(Repository) .overrideProvider(getRepositoryToken(Tag)) .useValue({}) .overrideProvider(getRepositoryToken(NoteGroupPermission)) .useValue({}) .overrideProvider(getRepositoryToken(NoteUserPermission)) .useValue({}) .overrideProvider(getRepositoryToken(Group)) .useValue({}) .overrideProvider(getRepositoryToken(Alias)) .useValue({}) .overrideProvider(getRepositoryToken(Session)) .useValue({}) .overrideProvider(getRepositoryToken(Author)) .useValue({}) .compile(); service = module.get(RevisionsService); revisionRepo = module.get>( getRepositoryToken(Revision), ); }); it('should be defined', () => { expect(service).toBeDefined(); }); describe('getRevision', () => { it('returns a revision', async () => { const note = Mock.of({}); const revision = Mock.of({}); jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(revision); expect(await service.getRevision(note, 1)).toBe(revision); }); it('throws if the revision is not in the databse', async () => { jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(null); await expect(service.getRevision({} as Note, 1)).rejects.toThrow( NotInDBError, ); }); }); describe('purgeRevisions', () => { let revisions: Revision[]; let note: Note; beforeEach(() => { note = Mock.of({}); revisions = []; jest .spyOn(revisionRepo, 'remove') .mockImplementation( (deleteEntities: T): Promise => { const newRevisions = revisions.filter((item: Revision) => Array.isArray(deleteEntities) ? !deleteEntities.includes(item) : deleteEntities !== item, ); revisions = newRevisions; note.revisions = Promise.resolve(newRevisions); return Promise.resolve(deleteEntities); }, ); }); it('purges the revision history', async () => { const revision1 = Mock.of({ id: 1 }); const revision2 = Mock.of({ id: 2 }); const revision3 = Mock.of({ id: 3 }); revisions = [revision1, revision2, revision3]; note.revisions = Promise.resolve(revisions); jest.spyOn(revisionRepo, 'find').mockResolvedValueOnce(revisions); jest.spyOn(service, 'getLatestRevision').mockResolvedValueOnce(revision3); // expected to return all the purged revisions expect(await service.purgeRevisions(note)).toStrictEqual([ revision1, revision2, ]); // expected to have only the latest revision expect(revisions).toStrictEqual([revision3]); }); it('has no effect on revision history when a single revision is present', async () => { const revision1 = Mock.of({ id: 1 }); revisions = [revision1]; note.revisions = Promise.resolve(revisions); jest.spyOn(revisionRepo, 'find').mockResolvedValueOnce(revisions); jest.spyOn(service, 'getLatestRevision').mockResolvedValueOnce(revision1); // expected to return all the purged revisions expect(await service.purgeRevisions(note)).toHaveLength(0); // expected to have only the latest revision const updatedRevisions: Revision[] = [revision1]; expect(revisions).toEqual(updatedRevisions); }); }); describe('getRevisionUserInfo', () => { it('counts users correctly', async () => { const user = User.create('test', 'test') as User; const author = Author.create(123) as Author; author.user = Promise.resolve(user); const anonAuthor = Author.create(123) as Author; const anonAuthor2 = Author.create(123) as Author; const edits = [Edit.create(author, 12, 15) as Edit]; edits.push(Edit.create(author, 16, 18) as Edit); edits.push(Edit.create(author, 29, 20) as Edit); edits.push(Edit.create(anonAuthor, 29, 20) as Edit); edits.push(Edit.create(anonAuthor, 29, 20) as Edit); edits.push(Edit.create(anonAuthor2, 29, 20) as Edit); const revision = Mock.of({}); revision.edits = Promise.resolve(edits); const userInfo = await service.getRevisionUserInfo(revision); expect(userInfo.usernames.length).toEqual(1); expect(userInfo.anonymousUserCount).toEqual(2); }); }); describe('toRevisionMetadataDto', () => { it('converts a revision', async () => { const revision = Mock.of({ id: 3246, content: 'mockContent', length: 1854, createdAt: new Date('2020-05-20T09:58:00.000Z'), title: 'mockTitle', tags: Promise.resolve([Mock.of({ name: 'mockTag' })]), description: 'mockDescription', patch: 'mockPatch', edits: Promise.resolve([ Mock.of({ endPos: 93, startPos: 34, createdAt: new Date('2020-03-04T20:12:00.000Z'), updatedAt: new Date('2021-12-10T09:45:00.000Z'), author: Promise.resolve( Mock.of({ user: Promise.resolve( Mock.of({ username: 'mockusername', }), ), }), ), }), ]), }); expect(await service.toRevisionMetadataDto(revision)) .toMatchInlineSnapshot(` { "anonymousAuthorCount": 0, "authorUsernames": [ "mockusername", ], "createdAt": 2020-05-20T09:58:00.000Z, "description": "mockDescription", "id": 3246, "length": 1854, "tags": [ "mockTag", ], "title": "mockTitle", } `); }); }); describe('toRevisionDto', () => { it('converts a revision', async () => { const revision = Mock.of({ id: 3246, content: 'mockContent', length: 1854, createdAt: new Date('2020-05-20T09:58:00.000Z'), title: 'mockTitle', tags: Promise.resolve([Mock.of({ name: 'mockTag' })]), description: 'mockDescription', patch: 'mockPatch', edits: Promise.resolve([ Mock.of({ endPos: 93, startPos: 34, createdAt: new Date('2020-03-04T22:32:00.000Z'), updatedAt: new Date('2021-02-10T12:23:00.000Z'), author: Promise.resolve( Mock.of({ user: Promise.resolve( Mock.of({ username: 'mockusername', }), ), }), ), }), ]), }); expect(await service.toRevisionDto(revision)).toMatchInlineSnapshot(` { "anonymousAuthorCount": 0, "authorUsernames": [ "mockusername", ], "content": "mockContent", "createdAt": 2020-05-20T09:58:00.000Z, "description": "mockDescription", "edits": [ { "createdAt": 2020-03-04T22:32:00.000Z, "endPos": 93, "startPos": 34, "updatedAt": 2021-02-10T12:23:00.000Z, "username": "mockusername", }, ], "id": 3246, "length": 1854, "patch": "mockPatch", "tags": [ "mockTag", ], "title": "mockTitle", } `); }); }); describe('createRevision', () => { it('creates a new revision', async () => { const note = Mock.of({ publicId: 'test-note', id: 1 }); const oldContent = 'old content\n'; const newContent = '---\ntitle: new title\ndescription: new description\ntags: [ "tag1" ]\n---\nnew content\n'; const oldRevision = Mock.of({ content: oldContent, id: 1 }); jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(oldRevision); jest .spyOn(revisionRepo, 'save') .mockImplementation((revision) => Promise.resolve(revision as Revision), ); const createdRevision = await service.createRevision(note, newContent); expect(createdRevision).not.toBeUndefined(); expect(createdRevision?.content).toBe(newContent); await expect(createdRevision?.tags).resolves.toMatchInlineSnapshot(` [ Tag { "name": "tag1", }, ] `); expect(createdRevision?.title).toBe('new title'); expect(createdRevision?.description).toBe('new description'); await expect(createdRevision?.note).resolves.toBe(note); expect(createdRevision?.patch).toMatchInlineSnapshot(` "Index: test-note =================================================================== --- test-note +++ test-note @@ -1,1 +1,6 @@ -old content +--- +title: new title +description: new description +tags: [ "tag1" ] +--- +new content " `); }); it("won't create a revision if content is unchanged", async () => { const note = Mock.of({ id: 1 }); const oldContent = 'old content\n'; const oldRevision = Mock.of({ content: oldContent, id: 1 }); jest.spyOn(revisionRepo, 'findOne').mockResolvedValueOnce(oldRevision); const saveSpy = jest.spyOn(revisionRepo, 'save').mockImplementation(); const createdRevision = await service.createRevision(note, oldContent); expect(createdRevision).toBeUndefined(); expect(saveSpy).not.toHaveBeenCalled(); }); }); describe('createAndSaveRevision', () => { it('creates and saves a new revision', async () => { const newRevision = Mock.of(); const createRevisionSpy = jest .spyOn(service, 'createRevision') .mockResolvedValue(newRevision); const repoSaveSpy = jest .spyOn(revisionRepo, 'save') .mockResolvedValue(newRevision); const note = Mock.of({}); const newContent = 'MockContent'; const yjsState = [0, 1, 2, 3, 4, 5]; await service.createAndSaveRevision(note, newContent, yjsState); expect(createRevisionSpy).toHaveBeenCalledWith( note, newContent, yjsState, ); expect(repoSaveSpy).toHaveBeenCalledWith(newRevision); }); it("doesn't save if no revision has been created", async () => { const createRevisionSpy = jest .spyOn(service, 'createRevision') .mockResolvedValue(undefined); const repoSaveSpy = jest .spyOn(revisionRepo, 'save') .mockRejectedValue(new Error("shouldn't have been called")); const note = Mock.of({}); const newContent = 'MockContent'; const yjsState = [0, 1, 2, 3, 4, 5]; await service.createAndSaveRevision(note, newContent, yjsState); expect(createRevisionSpy).toHaveBeenCalledWith( note, newContent, yjsState, ); expect(repoSaveSpy).not.toHaveBeenCalled(); }); }); });