From be67147766310e4d285bcd0a8228bffd6166e145 Mon Sep 17 00:00:00 2001 From: bwees Date: Tue, 3 Feb 2026 11:55:06 -0600 Subject: [PATCH] chore: wip tests --- server/test/medium.factory.ts | 6 + .../specs/services/person.service.spec.ts | 687 +++++++++++++++++- 2 files changed, 692 insertions(+), 1 deletion(-) diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 153b568222..f1b87b50d7 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -6,6 +6,7 @@ import { Stats } from 'node:fs'; import { Writable } from 'node:stream'; import { AssetFace } from 'src/database'; import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto'; +import { AssetEditActionListDto } from 'src/dtos/editing.dto'; import { AlbumUserRole, AssetType, @@ -280,6 +281,11 @@ export class MediumTestContext { const result = await this.get(TagRepository).upsertAssetIds(tagsAssets); return { tagsAssets, result }; } + + async newEdits(assetId: string, dto: AssetEditActionListDto) { + const edits = await this.get(AssetEditRepository).replaceAll(assetId, dto.edits); + return { edits }; + } } export class SyncTestContext extends MediumTestContext { diff --git a/server/test/medium/specs/services/person.service.spec.ts b/server/test/medium/specs/services/person.service.spec.ts index f26834c5e2..bd3668287b 100644 --- a/server/test/medium/specs/services/person.service.spec.ts +++ b/server/test/medium/specs/services/person.service.spec.ts @@ -1,5 +1,9 @@ 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'; @@ -15,7 +19,7 @@ let defaultDatabase: Kysely; const setup = (db?: Kysely) => { return newMediumService(PersonService, { database: db || defaultDatabase, - real: [AccessRepository, DatabaseRepository, PersonRepository], + real: [AccessRepository, DatabaseRepository, PersonRepository, AssetRepository, AssetEditRepository], mock: [LoggingRepository, StorageRepository], }); }; @@ -77,4 +81,685 @@ describe(PersonService.name, () => { 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.get(AssetEditRepository).replaceAll(asset.id, []); + + 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.only('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: 200, height: 100 }); + await ctx.newExif({ assetId: asset.id, exifImageHeight: 100, exifImageWidth: 200 }); + + await ctx.newEdits(asset.id, { + edits: [ + { + action: AssetEditAction.Rotate, + parameters: { + angle: 90, + }, + }, + ], + }); + + const auth = factory.auth({ user }); + + const dto: AssetFaceCreateDto = { + imageWidth: 200, + imageHeight: 100, + x: 25, + y: 50, + width: 50, + 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: 50, + boundingBoxX2: 75, + boundingBoxY2: 100, + }), + ]), + ); + + // remove edits and verify the stored coordinates map to the original image + await ctx.get(AssetEditRepository).replaceAll(asset.id, []); + 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 (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.get(AssetEditRepository).replaceAll(asset.id, []); + 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: 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: 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: 100, + height: 75, + 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: 100, + }), + ]), + ); + + // remove edits and verify the stored coordinates map to the original image + await ctx.get(AssetEditRepository).replaceAll(asset.id, []); + 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: 50, + boundingBoxX2: 150, + 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.get(AssetEditRepository).replaceAll(asset.id, []); + 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: 150, height: 200 }); + 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: 100, + height: 75, + 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: 100, + }), + ]), + ); + + // remove edits and verify the stored coordinates map to the original image + await ctx.get(AssetEditRepository).replaceAll(asset.id, []); + 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: 50, + boundingBoxX2: 150, + boundingBoxY2: 150, + }), + ]), + ); + }); + + 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: 100, 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: 25, + width: 150, + 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: 25, + boundingBoxY1: 50, + boundingBoxX2: 100, + boundingBoxY2: 100, + }), + ]), + ); + + // remove edits and verify the stored coordinates map to the original image + await ctx.get(AssetEditRepository).replaceAll(asset.id, []); + 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: 75, + boundingBoxX2: 150, + boundingBoxY2: 150, + }), + ]), + ); + }); + + it('should properly transform the coordinates with multiple crops 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: 200, exifImageWidth: 200 }); + + await ctx.newEdits(asset.id, { + edits: [ + { + action: AssetEditAction.Crop, + parameters: { + x: 50, + y: 50, + width: 150, + height: 150, + }, + }, + { + action: AssetEditAction.Crop, + parameters: { + x: 25, + y: 25, + width: 100, + height: 100, + }, + }, + ], + }); + + 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.get(AssetEditRepository).replaceAll(asset.id, []); + const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id }); + + await expect(facesAfterRemovingEdits).resolves.toEqual( + expect.arrayContaining([ + expect.objectContaining({ + person: expect.objectContaining({ id: person.id }), + boundingBoxX1: 85, + boundingBoxY1: 85, + boundingBoxX2: 165, + boundingBoxY2: 165, + }), + ]), + ); + }); + + 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.get(AssetEditRepository).replaceAll(asset.id, []); + 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, + }), + ]), + ); + }); + }); });