import { Kysely } from 'kysely'; import { AssetEditAction, MirrorAxis } from 'src/dtos/editing.dto'; import { AssetFaceCreateDto } from 'src/dtos/person.dto'; import { AccessRepository } from 'src/repositories/access.repository'; import { AssetEditRepository } from 'src/repositories/asset-edit.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; import { DatabaseRepository } from 'src/repositories/database.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { PersonRepository } from 'src/repositories/person.repository'; import { StorageRepository } from 'src/repositories/storage.repository'; import { DB } from 'src/schema'; import { PersonService } from 'src/services/person.service'; import { newMediumService } from 'test/medium.factory'; import { factory } from 'test/small.factory'; import { getKyselyDB } from 'test/utils'; let defaultDatabase: Kysely; const setup = (db?: Kysely) => { return newMediumService(PersonService, { database: db || defaultDatabase, real: [AccessRepository, DatabaseRepository, PersonRepository, AssetRepository, AssetEditRepository], mock: [LoggingRepository, StorageRepository], }); }; beforeAll(async () => { defaultDatabase = await getKyselyDB(); }); describe(PersonService.name, () => { describe('delete', () => { it('should throw an error when there is no access', async () => { const { sut } = setup(); const auth = factory.auth(); const personId = factory.uuid(); await expect(sut.delete(auth, personId)).rejects.toThrow('Not found or no person.delete access'); }); it('should delete the person', async () => { const { sut, ctx } = setup(); const personRepo = ctx.get(PersonRepository); const storageMock = ctx.getMock(StorageRepository); const { user } = await ctx.newUser(); const { person } = await ctx.newPerson({ ownerId: user.id }); const auth = factory.auth({ user }); storageMock.unlink.mockResolvedValue(); await expect(personRepo.getById(person.id)).resolves.toEqual(expect.objectContaining({ id: person.id })); await expect(sut.delete(auth, person.id)).resolves.toBeUndefined(); await expect(personRepo.getById(person.id)).resolves.toBeUndefined(); expect(storageMock.unlink).toHaveBeenCalledWith(person.thumbnailPath); }); }); describe('deleteAll', () => { it('should throw an error when there is no access', async () => { const { sut } = setup(); const auth = factory.auth(); const personId = factory.uuid(); await expect(sut.deleteAll(auth, { ids: [personId] })).rejects.toThrow('Not found or no person.delete access'); }); it('should delete the person', async () => { const { sut, ctx } = setup(); const storageMock = ctx.getMock(StorageRepository); const personRepo = ctx.get(PersonRepository); const { user } = await ctx.newUser(); const { person: person1 } = await ctx.newPerson({ ownerId: user.id }); const { person: person2 } = await ctx.newPerson({ ownerId: user.id }); const auth = factory.auth({ user }); storageMock.unlink.mockResolvedValue(); await expect(sut.deleteAll(auth, { ids: [person1.id, person2.id] })).resolves.toBeUndefined(); await expect(personRepo.getById(person1.id)).resolves.toBeUndefined(); await expect(personRepo.getById(person2.id)).resolves.toBeUndefined(); expect(storageMock.unlink).toHaveBeenCalledTimes(2); expect(storageMock.unlink).toHaveBeenCalledWith(person1.thumbnailPath); expect(storageMock.unlink).toHaveBeenCalledWith(person2.thumbnailPath); }); }); describe('createFace', () => { it('should store and retrieve the face as-is when there are no edits', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const { person } = await ctx.newPerson({ ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 200 }); await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 }); const auth = factory.auth({ user }); const dto: AssetFaceCreateDto = { imageWidth: 200, imageHeight: 200, x: 50, y: 50, width: 150, height: 150, personId: person.id, assetId: asset.id, }; await sut.createFace(auth, dto); // retrieve an asset's faces const faces = sut.getFacesById(auth, { id: asset.id }); await expect(faces).resolves.toHaveLength(1); await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: 50, boundingBoxY1: 50, boundingBoxX2: 200, boundingBoxY2: 200, }), ]), ); }); it('should properly transform the coordinates when the asset is edited (Crop)', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const { person } = await ctx.newPerson({ ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 200 }); await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 }); await ctx.newEdits(asset.id, { edits: [ { action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 150, height: 200, }, }, ], }); const auth = factory.auth({ user }); const dto: AssetFaceCreateDto = { imageWidth: 150, imageHeight: 200, x: 0, y: 0, width: 100, height: 100, personId: person.id, assetId: asset.id, }; await sut.createFace(auth, dto); // retrieve an asset's faces const faces = sut.getFacesById(auth, { id: asset.id }); await expect(faces).resolves.toHaveLength(1); await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: 0, boundingBoxY1: 0, boundingBoxX2: 100, boundingBoxY2: 100, }), ]), ); // remove edits and verify the stored coordinates map to the original image await ctx.newEdits(asset.id, { edits: [] }); const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id }); await expect(facesAfterRemovingEdits).resolves.toHaveLength(1); await expect(facesAfterRemovingEdits).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: 50, boundingBoxY1: 50, boundingBoxX2: 150, boundingBoxY2: 150, }), ]), ); }); it('should properly transform the coordinates when the asset is edited (Rotate 90)', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const { person } = await ctx.newPerson({ ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 200 }); await ctx.newExif({ assetId: asset.id, exifImageWidth: 200, exifImageHeight: 100 }); await ctx.newEdits(asset.id, { edits: [ { action: AssetEditAction.Rotate, parameters: { angle: 90, }, }, ], }); const auth = factory.auth({ user }); const dto: AssetFaceCreateDto = { imageWidth: 100, imageHeight: 200, x: 25, y: 50, width: 10, height: 10, personId: person.id, assetId: asset.id, }; await sut.createFace(auth, dto); const faces = sut.getFacesById(auth, { id: asset.id }); await expect(faces).resolves.toHaveLength(1); await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: expect.closeTo(25, 1), boundingBoxY1: expect.closeTo(50, 1), boundingBoxX2: expect.closeTo(35, 1), boundingBoxY2: expect.closeTo(60, 1), }), ]), ); // remove edits and verify the stored coordinates map to the original image await ctx.newEdits(asset.id, { edits: [] }); const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id }); await expect(facesAfterRemovingEdits).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: 50, boundingBoxY1: 65, boundingBoxX2: 60, boundingBoxY2: 75, }), ]), ); }); it('should properly transform the coordinates when the asset is edited (Mirror Horizontal)', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const { person } = await ctx.newPerson({ ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 100 }); await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 200 }); await ctx.newEdits(asset.id, { edits: [ { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal, }, }, ], }); const auth = factory.auth({ user }); const dto: AssetFaceCreateDto = { imageWidth: 200, imageHeight: 100, x: 50, y: 25, width: 100, height: 50, personId: person.id, assetId: asset.id, }; await sut.createFace(auth, dto); const faces = sut.getFacesById(auth, { id: asset.id }); await expect(faces).resolves.toHaveLength(1); await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: 50, boundingBoxY1: 25, boundingBoxX2: 150, boundingBoxY2: 75, }), ]), ); // remove edits and verify the stored coordinates map to the original image await ctx.newEdits(asset.id, { edits: [] }); const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id }); await expect(facesAfterRemovingEdits).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: 50, boundingBoxY1: 25, boundingBoxX2: 150, boundingBoxY2: 75, }), ]), ); }); it('should properly transform the coordinates when the asset is edited (Crop + Rotate)', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const { person } = await ctx.newPerson({ ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 150 }); await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 }); await ctx.newEdits(asset.id, { edits: [ { action: AssetEditAction.Crop, parameters: { x: 50, y: 0, width: 150, height: 200, }, }, { action: AssetEditAction.Rotate, parameters: { angle: 90, }, }, ], }); const auth = factory.auth({ user }); const dto: AssetFaceCreateDto = { imageWidth: 200, imageHeight: 150, x: 50, y: 25, width: 10, height: 20, personId: person.id, assetId: asset.id, }; await sut.createFace(auth, dto); const faces = sut.getFacesById(auth, { id: asset.id }); await expect(faces).resolves.toHaveLength(1); await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: expect.closeTo(50, 1), boundingBoxY1: expect.closeTo(25, 1), boundingBoxX2: expect.closeTo(60, 1), boundingBoxY2: expect.closeTo(45, 1), }), ]), ); // remove edits and verify the stored coordinates map to the original image await ctx.newEdits(asset.id, { edits: [] }); const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id }); await expect(facesAfterRemovingEdits).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: 75, boundingBoxY1: 140, boundingBoxX2: 95, boundingBoxY2: 150, }), ]), ); }); it('should properly transform the coordinates when the asset is edited (Crop + Mirror)', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const { person } = await ctx.newPerson({ ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 100 }); await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 200 }); await ctx.newEdits(asset.id, { edits: [ { action: AssetEditAction.Crop, parameters: { x: 50, y: 0, width: 150, height: 100, }, }, { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal, }, }, ], }); const auth = factory.auth({ user }); const dto: AssetFaceCreateDto = { imageWidth: 150, imageHeight: 100, x: 25, y: 25, width: 75, height: 50, personId: person.id, assetId: asset.id, }; await sut.createFace(auth, dto); const faces = sut.getFacesById(auth, { id: asset.id }); await expect(faces).resolves.toHaveLength(1); await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: 25, boundingBoxY1: 25, boundingBoxX2: 100, boundingBoxY2: 75, }), ]), ); // remove edits and verify the stored coordinates map to the original image await ctx.newEdits(asset.id, { edits: [] }); const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id }); await expect(facesAfterRemovingEdits).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: 100, boundingBoxY1: 25, boundingBoxX2: 175, boundingBoxY2: 75, }), ]), ); }); it('should properly transform the coordinates when the asset is edited (Rotate + Mirror)', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const { person } = await ctx.newPerson({ ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 200, height: 150 }); await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 150 }); await ctx.newEdits(asset.id, { edits: [ { action: AssetEditAction.Rotate, parameters: { angle: 90, }, }, { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal, }, }, ], }); const auth = factory.auth({ user }); const dto: AssetFaceCreateDto = { imageWidth: 200, imageHeight: 150, x: 50, y: 25, width: 15, height: 20, personId: person.id, assetId: asset.id, }; await sut.createFace(auth, dto); const faces = sut.getFacesById(auth, { id: asset.id }); await expect(faces).resolves.toHaveLength(1); await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: expect.closeTo(50, 1), boundingBoxY1: expect.closeTo(25, 1), boundingBoxX2: expect.closeTo(65, 1), boundingBoxY2: expect.closeTo(45, 1), }), ]), ); // remove edits and verify the stored coordinates map to the original image await ctx.newEdits(asset.id, { edits: [] }); const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id }); await expect(facesAfterRemovingEdits).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: 25, boundingBoxY1: 50, boundingBoxX2: 45, boundingBoxY2: 65, }), ]), ); }); it('should properly transform the coordinates when the asset is edited (Crop + Rotate + Mirror)', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const { person } = await ctx.newPerson({ ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 150, height: 100 }); await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 200 }); await ctx.newEdits(asset.id, { edits: [ { action: AssetEditAction.Crop, parameters: { x: 50, y: 25, width: 100, height: 150, }, }, { action: AssetEditAction.Rotate, parameters: { angle: 270, }, }, { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal, }, }, ], }); const auth = factory.auth({ user }); const dto: AssetFaceCreateDto = { imageWidth: 150, imageHeight: 150, x: 25, y: 50, width: 75, height: 50, personId: person.id, assetId: asset.id, }; await sut.createFace(auth, dto); const faces = sut.getFacesById(auth, { id: asset.id }); await expect(faces).resolves.toHaveLength(1); await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: expect.closeTo(25, 1), boundingBoxY1: expect.closeTo(50, 1), boundingBoxX2: expect.closeTo(100, 1), boundingBoxY2: expect.closeTo(100, 1), }), ]), ); // remove edits and verify the stored coordinates map to the original image await ctx.newEdits(asset.id, { edits: [] }); const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id }); await expect(facesAfterRemovingEdits).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: 50, boundingBoxY1: 75, boundingBoxX2: 100, boundingBoxY2: 150, }), ]), ); }); it('should properly transform the coordinates with multiple mirrors in sequence', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const { person } = await ctx.newPerson({ ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 100 }); await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 100 }); await ctx.newEdits(asset.id, { edits: [ { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal, }, }, { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical, }, }, ], }); const auth = factory.auth({ user }); const dto: AssetFaceCreateDto = { imageWidth: 100, imageHeight: 100, x: 10, y: 10, width: 80, height: 80, personId: person.id, assetId: asset.id, }; await sut.createFace(auth, dto); const faces = sut.getFacesById(auth, { id: asset.id }); await expect(faces).resolves.toHaveLength(1); await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: 10, boundingBoxY1: 10, boundingBoxX2: 90, boundingBoxY2: 90, }), ]), ); // remove edits and verify the stored coordinates map to the original image await ctx.newEdits(asset.id, { edits: [] }); const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id }); await expect(facesAfterRemovingEdits).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: 10, boundingBoxY1: 10, boundingBoxX2: 90, boundingBoxY2: 90, }), ]), ); }); it('should properly handle exif orientation when creating a face on an edited asset', async () => { const { sut, ctx } = setup(); const { user } = await ctx.newUser(); const { person } = await ctx.newPerson({ ownerId: user.id }); const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 100 }); await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 100, orientation: '6' }); await ctx.newEdits(asset.id, { edits: [ { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal, }, }, { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical, }, }, ], }); const auth = factory.auth({ user }); const dto: AssetFaceCreateDto = { imageWidth: 100, imageHeight: 100, x: 10, y: 10, width: 80, height: 80, personId: person.id, assetId: asset.id, }; await sut.createFace(auth, dto); const faces = sut.getFacesById(auth, { id: asset.id }); await expect(faces).resolves.toHaveLength(1); await expect(faces).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: 110, boundingBoxY1: 10, boundingBoxX2: 190, boundingBoxY2: 90, }), ]), ); // remove edits and verify the stored coordinates map to the original image await ctx.newEdits(asset.id, { edits: [] }); const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id }); await expect(facesAfterRemovingEdits).resolves.toEqual( expect.arrayContaining([ expect.objectContaining({ person: expect.objectContaining({ id: person.id }), boundingBoxX1: 10, boundingBoxY1: 10, boundingBoxX2: 90, boundingBoxY2: 90, }), ]), ); }); }); });