From b3820c259e29e599f6211df592a7da816424cac1 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 6 Feb 2026 16:32:50 -0500 Subject: [PATCH 01/40] refactor: test factories (#25977) --- server/src/queries/album.repository.sql | 6 +- server/src/repositories/album.repository.ts | 8 +- .../repositories/shared-link.repository.ts | 2 +- server/src/services/album.service.spec.ts | 809 ++++++------------ server/test/factories/album-user.factory.ts | 54 ++ server/test/factories/album.factory.ts | 87 ++ server/test/factories/asset-exif.factory.ts | 55 ++ server/test/factories/asset.factory.ts | 79 ++ server/test/factories/auth.factory.ts | 48 ++ server/test/factories/builder.factory.ts | 5 + server/test/factories/shared-link.factory.ts | 63 ++ server/test/factories/types.ts | 16 + server/test/factories/user.factory.ts | 46 + 13 files changed, 727 insertions(+), 551 deletions(-) create mode 100644 server/test/factories/album-user.factory.ts create mode 100644 server/test/factories/album.factory.ts create mode 100644 server/test/factories/asset-exif.factory.ts create mode 100644 server/test/factories/asset.factory.ts create mode 100644 server/test/factories/auth.factory.ts create mode 100644 server/test/factories/builder.factory.ts create mode 100644 server/test/factories/shared-link.factory.ts create mode 100644 server/test/factories/types.ts create mode 100644 server/test/factories/user.factory.ts diff --git a/server/src/queries/album.repository.sql b/server/src/queries/album.repository.sql index f62e769a17..e3d7436c30 100644 --- a/server/src/queries/album.repository.sql +++ b/server/src/queries/album.repository.sql @@ -58,7 +58,7 @@ select from ( select - * + "shared_link".* from "shared_link" where @@ -243,7 +243,7 @@ select from ( select - * + "shared_link".* from "shared_link" where @@ -316,7 +316,7 @@ select from ( select - * + "shared_link".* from "shared_link" where diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index 100ab908c0..cf132a023d 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -44,9 +44,9 @@ const withAlbumUsers = (eb: ExpressionBuilder) => { }; const withSharedLink = (eb: ExpressionBuilder) => { - return jsonArrayFrom(eb.selectFrom('shared_link').selectAll().whereRef('shared_link.albumId', '=', 'album.id')).as( - 'sharedLinks', - ); + return jsonArrayFrom( + eb.selectFrom('shared_link').selectAll('shared_link').whereRef('shared_link.albumId', '=', 'album.id'), + ).as('sharedLinks'); }; const withAssets = (eb: ExpressionBuilder) => { @@ -283,7 +283,7 @@ export class AlbumRepository { return tx .selectFrom('album') - .selectAll() + .selectAll('album') .where('id', '=', newAlbum.id) .select(withOwner) .select(withAssets) diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 8fab087156..37a5bca718 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -260,7 +260,7 @@ export class SharedLinkRepository { .selectAll('asset') .innerJoinLateral( (eb) => - eb.selectFrom('asset_exif').whereRef('asset_exif.assetId', '=', 'asset.id').selectAll().as('exif'), + eb.selectFrom('asset_exif').whereRef('asset_exif.assetId', '=', 'asset.id').selectAll().as('exifInfo'), (join) => join.onTrue(), ) .as('assets'), diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index 209716db3a..d21185bd35 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -3,8 +3,13 @@ import _ from 'lodash'; import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto'; import { AlbumUserRole, AssetOrder, UserMetadataKey } from 'src/enum'; import { AlbumService } from 'src/services/album.service'; +import { AlbumUserFactory } from 'test/factories/album-user.factory'; +import { AlbumFactory } from 'test/factories/album.factory'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; +import { UserFactory } from 'test/factories/user.factory'; import { authStub } from 'test/fixtures/auth.stub'; -import { factory } from 'test/small.factory'; +import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(AlbumService.name, () => { @@ -38,13 +43,8 @@ describe(AlbumService.name, () => { describe('getAll', () => { it('gets list of albums for auth user', async () => { - const owner = factory.userAdmin(); - const album = { ...factory.album({ ownerId: owner.id }), owner }; - const sharedWithUserAlbum = { - ...factory.album({ ownerId: owner.id }), - owner, - albumUsers: [{ user: factory.user(), role: AlbumUserRole.Editor }], - }; + const album = AlbumFactory.from().albumUser().build(); + const sharedWithUserAlbum = AlbumFactory.from().owner(album.owner).albumUser().build(); mocks.album.getOwned.mockResolvedValue([album, sharedWithUserAlbum]); mocks.album.getMetadataForIds.mockResolvedValue([ { @@ -63,16 +63,14 @@ describe(AlbumService.name, () => { }, ]); - const result = await sut.getAll(factory.auth({ user: owner }), {}); + const result = await sut.getAll(AuthFactory.create(album.owner), {}); expect(result).toHaveLength(2); expect(result[0].id).toEqual(album.id); expect(result[1].id).toEqual(sharedWithUserAlbum.id); }); it('gets list of albums that have a specific asset', async () => { - const owner = factory.userAdmin(); - const asset = factory.asset({ ownerId: owner.id }); - const album = { ...factory.album({ ownerId: owner.id }), owner, assets: [asset] }; + const album = AlbumFactory.from().owner({ isAdmin: true }).albumUser().asset().asset().build(); mocks.album.getByAssetId.mockResolvedValue([album]); mocks.album.getMetadataForIds.mockResolvedValue([ { @@ -84,19 +82,14 @@ describe(AlbumService.name, () => { }, ]); - const result = await sut.getAll(factory.auth({ user: owner }), { assetId: asset.id }); + const result = await sut.getAll(AuthFactory.create(album.owner), { assetId: album.assets[0].id }); expect(result).toHaveLength(1); expect(result[0].id).toEqual(album.id); expect(mocks.album.getByAssetId).toHaveBeenCalledTimes(1); }); it('gets list of albums that are shared', async () => { - const owner = factory.userAdmin(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - albumUsers: [{ user: factory.user(), role: AlbumUserRole.Editor }], - }; + const album = AlbumFactory.from().albumUser().build(); mocks.album.getShared.mockResolvedValue([album]); mocks.album.getMetadataForIds.mockResolvedValue([ { @@ -108,15 +101,14 @@ describe(AlbumService.name, () => { }, ]); - const result = await sut.getAll(factory.auth({ user: owner }), { shared: true }); + const result = await sut.getAll(AuthFactory.create(album.owner), { shared: true }); expect(result).toHaveLength(1); expect(result[0].id).toEqual(album.id); expect(mocks.album.getShared).toHaveBeenCalledTimes(1); }); it('gets list of albums that are NOT shared', async () => { - const owner = factory.userAdmin(); - const album = { ...factory.album({ ownerId: owner.id }), owner }; + const album = AlbumFactory.create(); mocks.album.getNotShared.mockResolvedValue([album]); mocks.album.getMetadataForIds.mockResolvedValue([ { @@ -128,7 +120,7 @@ describe(AlbumService.name, () => { }, ]); - const result = await sut.getAll(factory.auth({ user: owner }), { shared: false }); + const result = await sut.getAll(AuthFactory.create(album.owner), { shared: false }); expect(result).toHaveLength(1); expect(result[0].id).toEqual(album.id); expect(mocks.album.getNotShared).toHaveBeenCalledTimes(1); @@ -136,9 +128,7 @@ describe(AlbumService.name, () => { }); it('counts assets correctly', async () => { - const owner = factory.userAdmin(); - const asset = factory.asset({ ownerId: owner.id }); - const album = { ...factory.album({ ownerId: owner.id }), owner, assets: [asset] }; + const album = AlbumFactory.create(); mocks.album.getOwned.mockResolvedValue([album]); mocks.album.getMetadataForIds.mockResolvedValue([ { @@ -150,8 +140,7 @@ describe(AlbumService.name, () => { }, ]); - const result = await sut.getAll(factory.auth({ user: owner }), {}); - + const result = await sut.getAll(AuthFactory.create(album.owner), {}); expect(result).toHaveLength(1); expect(result[0].assetCount).toEqual(1); expect(mocks.album.getOwned).toHaveBeenCalledTimes(1); @@ -159,60 +148,52 @@ describe(AlbumService.name, () => { describe('create', () => { it('creates album', async () => { - const owner = factory.userAdmin(); - const user = factory.userAdmin(); - const asset = { ...factory.asset({ ownerId: owner.id }), exifInfo: factory.exif() }; - const album = { - ...factory.album({ ownerId: owner.id, albumName: 'Empty album' }), - owner, - assets: [asset], - albumUsers: [{ user, role: AlbumUserRole.Editor }], - }; - mocks.album.create.mockResolvedValue(album); - mocks.user.get.mockResolvedValue(user); - mocks.user.getMetadata.mockResolvedValue([]); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + const assetId = newUuid(); + const albumUser = { userId: newUuid(), role: AlbumUserRole.Editor }; + const album = AlbumFactory.from({ albumName: 'test', description: 'description' }) + .asset({ id: assetId }, (asset) => asset.exif()) + .albumUser(albumUser) + .build(); - await sut.create(factory.auth({ user: owner }), { - albumName: 'Empty album', - albumUsers: [{ userId: user.id, role: AlbumUserRole.Editor }], - description: 'Album description', - assetIds: [asset.id], + mocks.album.create.mockResolvedValue(album); + mocks.user.get.mockResolvedValue(UserFactory.create(album.albumUsers[0].user)); + mocks.user.getMetadata.mockResolvedValue([]); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId])); + + await sut.create(AuthFactory.create(album.owner), { + albumName: 'test', + albumUsers: [albumUser], + description: 'description', + assetIds: [assetId], }); expect(mocks.album.create).toHaveBeenCalledWith( { - ownerId: owner.id, - albumName: album.albumName, - description: album.description, + ownerId: album.owner.id, + albumName: 'test', + description: 'description', order: album.order, - albumThumbnailAssetId: asset.id, + albumThumbnailAssetId: assetId, }, - [asset.id], - [{ userId: user.id, role: AlbumUserRole.Editor }], + [assetId], + [{ userId: albumUser.userId, role: AlbumUserRole.Editor }], ); - expect(mocks.user.get).toHaveBeenCalledWith(user.id, {}); - expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id); - expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([asset.id]), false); - expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { - id: album.id, - userId: user.id, - }); + expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {}); + expect(mocks.user.getMetadata).toHaveBeenCalledWith(album.owner.id); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(album.owner.id, new Set([assetId]), false); + expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { id: album.id, userId: albumUser.userId }); }); it('creates album with assetOrder from user preferences', async () => { - const owner = factory.userAdmin(); - const user = factory.userAdmin(); - const asset = { ...factory.asset({ ownerId: owner.id }), exifInfo: factory.exif() }; - const album = { - ...factory.album({ ownerId: owner.id, albumName: 'Empty album' }), - owner, - assets: [asset], - albumUsers: [{ user, role: AlbumUserRole.Editor }], - }; + const assetId = newUuid(); + const albumUser = { userId: newUuid(), role: AlbumUserRole.Editor }; + const album = AlbumFactory.from() + .asset({ id: assetId }, (asset) => asset.exif()) + .albumUser(albumUser) + .build(); mocks.album.create.mockResolvedValue(album); - mocks.user.get.mockResolvedValue(user); + mocks.user.get.mockResolvedValue(album.albumUsers[0].user); mocks.user.getMetadata.mockResolvedValue([ { key: UserMetadataKey.Preferences, @@ -223,40 +204,37 @@ describe(AlbumService.name, () => { }, }, ]); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId])); - await sut.create(factory.auth({ user: owner }), { - albumName: 'Empty album', - albumUsers: [{ userId: user.id, role: AlbumUserRole.Editor }], - description: 'Album description', - assetIds: [asset.id], + await sut.create(AuthFactory.create(album.owner), { + albumName: album.albumName, + albumUsers: [albumUser], + description: album.description, + assetIds: [assetId], }); expect(mocks.album.create).toHaveBeenCalledWith( { - ownerId: owner.id, + ownerId: album.owner.id, albumName: album.albumName, description: album.description, order: 'asc', - albumThumbnailAssetId: asset.id, + albumThumbnailAssetId: assetId, }, - [asset.id], - [{ userId: user.id, role: AlbumUserRole.Editor }], + [assetId], + [albumUser], ); - expect(mocks.user.get).toHaveBeenCalledWith(user.id, {}); - expect(mocks.user.getMetadata).toHaveBeenCalledWith(owner.id); - expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([asset.id]), false); - expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { - id: album.id, - userId: user.id, - }); + expect(mocks.user.get).toHaveBeenCalledWith(albumUser.userId, {}); + expect(mocks.user.getMetadata).toHaveBeenCalledWith(album.owner.id); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(album.owner.id, new Set([assetId]), false); + expect(mocks.event.emit).toHaveBeenCalledWith('AlbumInvite', { id: album.id, userId: albumUser.userId }); }); it('should require valid userIds', async () => { mocks.user.get.mockResolvedValue(void 0); await expect( - sut.create(factory.auth(), { + sut.create(AuthFactory.create(), { albumName: 'Empty album', albumUsers: [{ userId: 'unknown-user', role: AlbumUserRole.Editor }], }), @@ -266,47 +244,47 @@ describe(AlbumService.name, () => { }); it('should only add assets the user is allowed to access', async () => { - const owner = factory.userAdmin(); - const user = factory.userAdmin(); - const asset = { ...factory.asset({ ownerId: owner.id }), exifInfo: factory.exif() }; - const album = { - ...factory.album({ ownerId: owner.id, albumName: 'Test album' }), - owner, - assets: [asset], - albumUsers: [{ user, role: AlbumUserRole.Editor }], - }; - mocks.user.get.mockResolvedValue(user); + const assetId = newUuid(); + const album = AlbumFactory.from() + .asset({ id: assetId }, (asset) => asset.exif()) + .albumUser() + .build(); + mocks.user.get.mockResolvedValue(album.albumUsers[0].user); mocks.album.create.mockResolvedValue(album); mocks.user.getMetadata.mockResolvedValue([]); - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId])); - await sut.create(factory.auth({ user: owner }), { - albumName: 'Test album', - description: 'Album description', - assetIds: [asset.id, 'asset-2'], + await sut.create(AuthFactory.create(album.owner), { + albumName: album.albumName, + description: album.description, + assetIds: [assetId, 'asset-2'], }); expect(mocks.album.create).toHaveBeenCalledWith( { - ownerId: owner.id, + ownerId: album.owner.id, albumName: album.albumName, description: album.description, order: 'desc', - albumThumbnailAssetId: asset.id, + albumThumbnailAssetId: assetId, }, - [asset.id], + [assetId], [], ); - expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([asset.id, 'asset-2']), false); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( + album.owner.id, + new Set([assetId, 'asset-2']), + false, + ); }); it('should throw an error if the userId is the ownerId', async () => { - const owner = factory.userAdmin(); - mocks.user.get.mockResolvedValue(owner); + const album = AlbumFactory.create(); + mocks.user.get.mockResolvedValue(album.owner); await expect( - sut.create(factory.auth({ user: owner }), { + sut.create(AuthFactory.create(album.owner), { albumName: 'Empty album', - albumUsers: [{ userId: owner.id, role: AlbumUserRole.Editor }], + albumUsers: [{ userId: album.owner.id, role: AlbumUserRole.Editor }], }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.album.create).not.toHaveBeenCalled(); @@ -319,7 +297,7 @@ describe(AlbumService.name, () => { mocks.album.getById.mockResolvedValue(void 0); await expect( - sut.update(factory.auth(), 'invalid-id', { + sut.update(AuthFactory.create(), 'invalid-id', { albumName: 'Album', }), ).rejects.toBeInstanceOf(BadRequestException); @@ -328,28 +306,21 @@ describe(AlbumService.name, () => { }); it('should prevent updating a not owned album (shared with auth user)', async () => { - const owner = factory.userAdmin(); - const user = factory.userAdmin(); - const album = { ...factory.album({ ownerId: user.id }), user }; + const album = AlbumFactory.from().albumUser().build(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set()); await expect( - sut.update(factory.auth({ user: owner }), album.id, { - albumName: 'new album name', - }), + sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' }), ).rejects.toBeInstanceOf(BadRequestException); }); it('should require a valid thumbnail asset id', async () => { - const owner = factory.userAdmin(); - const album = { ...factory.album({ ownerId: owner.id }), owner }; + const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValue(new Set()); await expect( - sut.update(factory.auth({ user: owner }), album.id, { - albumThumbnailAssetId: 'not-in-album', - }), + sut.update(AuthFactory.create(album.owner), album.id, { albumThumbnailAssetId: 'not-in-album' }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.album.getAssetIds).toHaveBeenCalledWith(album.id, ['not-in-album']); @@ -357,54 +328,44 @@ describe(AlbumService.name, () => { }); it('should allow the owner to update the album', async () => { - const owner = factory.userAdmin(); - const album = { ...factory.album({ ownerId: owner.id }), owner }; + const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); - mocks.album.getById.mockResolvedValue(album); mocks.album.update.mockResolvedValue(album); - await sut.update(factory.auth({ user: owner }), album.id, { - albumName: 'new album name', - }); + await sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' }); expect(mocks.album.update).toHaveBeenCalledTimes(1); - expect(mocks.album.update).toHaveBeenCalledWith(album.id, { - id: album.id, - albumName: 'new album name', - }); + expect(mocks.album.update).toHaveBeenCalledWith(album.id, { id: album.id, albumName: 'new album name' }); }); }); describe('delete', () => { it('should require permissions', async () => { - const album = factory.album(); + const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set()); - await expect(sut.delete(factory.auth(), album.id)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.delete(AuthFactory.create(album.owner), album.id)).rejects.toBeInstanceOf(BadRequestException); expect(mocks.album.delete).not.toHaveBeenCalled(); }); it('should not let a shared user delete the album', async () => { - const owner = factory.userAdmin(); - const user = factory.userAdmin(); - const album = { ...factory.album({ ownerId: user.id }), owner: user }; + const album = AlbumFactory.create(); mocks.album.getById.mockResolvedValue(album); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set()); - await expect(sut.delete(factory.auth({ user: owner }), album.id)).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.delete(AuthFactory.create(album.owner), album.id)).rejects.toBeInstanceOf(BadRequestException); expect(mocks.album.delete).not.toHaveBeenCalled(); }); it('should let the owner delete an album', async () => { - const owner = factory.userAdmin(); - const album = { ...factory.album({ ownerId: owner.id }), owner }; + const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.album.getById.mockResolvedValue(album); - await sut.delete(factory.auth({ user: owner }), album.id); + await sut.delete(AuthFactory.create(album.owner), album.id); expect(mocks.album.delete).toHaveBeenCalledTimes(1); expect(mocks.album.delete).toHaveBeenCalledWith(album.id); @@ -413,60 +374,46 @@ describe(AlbumService.name, () => { describe('addUsers', () => { it('should throw an error if the auth user is not the owner', async () => { - const owner = factory.userAdmin(); - const user = factory.userAdmin(); - const album = { ...factory.album({ ownerId: owner.id }), owner }; + const album = AlbumFactory.create(); + const user = UserFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set()); await expect( - sut.addUsers(factory.auth({ user }), album.id, { albumUsers: [{ userId: owner.id }] }), + sut.addUsers(AuthFactory.create(user), album.id, { albumUsers: [{ userId: newUuid() }] }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should throw an error if the userId is already added', async () => { - const owner = factory.userAdmin(); - const user = factory.userAdmin(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - albumUsers: [{ user, role: AlbumUserRole.Editor }], - }; + const userId = newUuid(); + const album = AlbumFactory.from().albumUser({ userId }).build(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.album.getById.mockResolvedValue(album); await expect( - sut.addUsers(factory.auth({ user: owner }), album.id, { albumUsers: [{ userId: user.id }] }), + sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId }] }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.album.update).not.toHaveBeenCalled(); expect(mocks.user.get).not.toHaveBeenCalled(); }); it('should throw an error if the userId does not exist', async () => { - const owner = factory.userAdmin(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - }; + const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.album.getById.mockResolvedValue(album); mocks.user.get.mockResolvedValue(void 0); await expect( - sut.addUsers(factory.auth({ user: owner }), album.id, { albumUsers: [{ userId: 'unknown-user' }] }), + sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId: 'unknown-user' }] }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.album.update).not.toHaveBeenCalled(); expect(mocks.user.get).toHaveBeenCalledWith('unknown-user', {}); }); it('should throw an error if the userId is the ownerId', async () => { - const owner = factory.userAdmin(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - }; + const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.album.getById.mockResolvedValue(album); await expect( - sut.addUsers(factory.auth({ user: owner }), album.id, { - albumUsers: [{ userId: owner.id }], + sut.addUsers(AuthFactory.create(album.owner), album.id, { + albumUsers: [{ userId: album.owner.id }], }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.album.update).not.toHaveBeenCalled(); @@ -474,24 +421,16 @@ describe(AlbumService.name, () => { }); it('should add valid shared users', async () => { - const owner = factory.userAdmin(); - const user = factory.userAdmin(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - }; + const album = AlbumFactory.create(); + const user = UserFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.album.getById.mockResolvedValue(album); mocks.album.update.mockResolvedValue(album); mocks.user.get.mockResolvedValue(user); - mocks.albumUser.create.mockResolvedValue({ - userId: user.id, - albumId: album.id, - role: AlbumUserRole.Editor, - }); - await sut.addUsers(factory.auth({ user: owner }), album.id, { - albumUsers: [{ userId: user.id }], - }); + mocks.albumUser.create.mockResolvedValue(AlbumUserFactory.from().album(album).user(user).build()); + + await sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId: user.id }] }); + expect(mocks.albumUser.create).toHaveBeenCalledWith({ userId: user.id, albumId: album.id, @@ -507,105 +446,69 @@ describe(AlbumService.name, () => { it('should require a valid album id', async () => { mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set(['album-1'])); mocks.album.getById.mockResolvedValue(void 0); - await expect(sut.removeUser(factory.auth(), 'album-1', 'user-1')).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.removeUser(AuthFactory.create(), 'album-1', 'user-1')).rejects.toBeInstanceOf( + BadRequestException, + ); expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should remove a shared user from an owned album', async () => { - const owner = factory.userAdmin(); - const user = factory.userAdmin(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - albumUsers: [{ user, role: AlbumUserRole.Editor }], - }; + const userId = newUuid(); + const album = AlbumFactory.from().albumUser({ userId }).build(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.album.getById.mockResolvedValue(album); mocks.albumUser.delete.mockResolvedValue(); - await expect(sut.removeUser(factory.auth({ user: owner }), album.id, user.id)).resolves.toBeUndefined(); + await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, userId)).resolves.toBeUndefined(); expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); - expect(mocks.albumUser.delete).toHaveBeenCalledWith({ - albumId: album.id, - userId: user.id, - }); + expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumId: album.id, userId }); expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: false }); }); it('should prevent removing a shared user from a not-owned album (shared with auth user)', async () => { - const owner = factory.userAdmin(); - const user = factory.userAdmin(); - const user2 = factory.userAdmin(); - const album = { - ...factory.album({ ownerId: user.id }), - owner: user, - albumUsers: [ - { user: owner, role: AlbumUserRole.Editor }, - { user: user2, role: AlbumUserRole.Editor }, - ], - }; + const user1 = UserFactory.create(); + const user2 = UserFactory.create(); + const album = AlbumFactory.from().albumUser({ userId: user1.id }).albumUser({ userId: user2.id }).build(); mocks.album.getById.mockResolvedValue(album); - await expect(sut.removeUser(factory.auth({ user: owner }), album.id, user2.id)).rejects.toBeInstanceOf( + await expect(sut.removeUser(AuthFactory.create(user1), album.id, user2.id)).rejects.toBeInstanceOf( BadRequestException, ); expect(mocks.albumUser.delete).not.toHaveBeenCalled(); - expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([album.id])); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(user1.id, new Set([album.id])); }); it('should allow a shared user to remove themselves', async () => { - const owner = factory.userAdmin(); - const user = factory.userAdmin(); - const album = { - ...factory.album({ ownerId: user.id }), - owner: user, - albumUsers: [{ user: owner, role: AlbumUserRole.Editor }], - }; + const user1 = UserFactory.create(); + const album = AlbumFactory.from().albumUser({ userId: user1.id }).build(); mocks.album.getById.mockResolvedValue(album); mocks.albumUser.delete.mockResolvedValue(); - await sut.removeUser(factory.auth({ user: owner }), album.id, owner.id); + await sut.removeUser(AuthFactory.create(user1), album.id, user1.id); expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); - expect(mocks.albumUser.delete).toHaveBeenCalledWith({ - albumId: album.id, - userId: owner.id, - }); + expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumId: album.id, userId: user1.id }); }); it('should allow a shared user to remove themselves using "me"', async () => { - const owner = factory.userAdmin(); - const user = factory.userAdmin(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - albumUsers: [{ user, role: AlbumUserRole.Editor }], - }; + const user = UserFactory.create(); + const album = AlbumFactory.from().albumUser({ userId: user.id }).build(); mocks.album.getById.mockResolvedValue(album); mocks.albumUser.delete.mockResolvedValue(); - await sut.removeUser(factory.auth({ user }), album.id, 'me'); + await sut.removeUser(AuthFactory.create(user), album.id, 'me'); expect(mocks.albumUser.delete).toHaveBeenCalledTimes(1); - expect(mocks.albumUser.delete).toHaveBeenCalledWith({ - albumId: album.id, - userId: user.id, - }); + expect(mocks.albumUser.delete).toHaveBeenCalledWith({ albumId: album.id, userId: user.id }); }); it('should not allow the owner to be removed', async () => { - const owner = factory.userAdmin(); - const user = factory.userAdmin(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - albumUsers: [{ user, role: AlbumUserRole.Editor }], - }; + const album = AlbumFactory.from().albumUser().build(); mocks.album.getById.mockResolvedValue(album); - await expect(sut.removeUser(factory.auth({ user: owner }), album.id, owner.id)).rejects.toBeInstanceOf( + await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, album.owner.id)).rejects.toBeInstanceOf( BadRequestException, ); @@ -613,16 +516,10 @@ describe(AlbumService.name, () => { }); it('should throw an error for a user not in the album', async () => { - const owner = factory.userAdmin(); - const user = factory.userAdmin(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - albumUsers: [{ user, role: AlbumUserRole.Editor }], - }; + const album = AlbumFactory.from().albumUser().build(); mocks.album.getById.mockResolvedValue(album); - await expect(sut.removeUser(factory.auth({ user: owner }), album.id, 'user-3')).rejects.toBeInstanceOf( + await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, 'user-3')).rejects.toBeInstanceOf( BadRequestException, ); @@ -632,19 +529,13 @@ describe(AlbumService.name, () => { describe('updateUser', () => { it('should update user role', async () => { - const owner = factory.userAdmin(); - const user = factory.userAdmin(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - albumUsers: [{ user, role: AlbumUserRole.Editor }], - }; + const user = UserFactory.create(); + const album = AlbumFactory.from().albumUser({ userId: user.id }).build(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.albumUser.update.mockResolvedValue(); - await sut.updateUser(factory.auth({ user: owner }), album.id, user.id, { - role: AlbumUserRole.Viewer, - }); + await sut.updateUser(AuthFactory.create(album.owner), album.id, user.id, { role: AlbumUserRole.Viewer }); + expect(mocks.albumUser.update).toHaveBeenCalledWith( { albumId: album.id, userId: user.id }, { role: AlbumUserRole.Viewer }, @@ -654,13 +545,7 @@ describe(AlbumService.name, () => { describe('getAlbumInfo', () => { it('should get a shared album', async () => { - const owner = factory.userAdmin(); - const user = factory.userAdmin(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - albumUsers: [{ user, role: AlbumUserRole.Editor }], - }; + const album = AlbumFactory.from().albumUser().build(); mocks.album.getById.mockResolvedValue(album); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.album.getMetadataForIds.mockResolvedValue([ @@ -673,20 +558,14 @@ describe(AlbumService.name, () => { }, ]); - await sut.get(factory.auth({ user: owner }), album.id, {}); + await sut.get(AuthFactory.create(album.owner), album.id, {}); expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true }); - expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([album.id])); + expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(album.owner.id, new Set([album.id])); }); it('should get a shared album via a shared link', async () => { - const owner = factory.userAdmin(); - const user = factory.userAdmin(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - albumUsers: [{ user, role: AlbumUserRole.Editor }], - }; + const album = AlbumFactory.from().albumUser().build(); mocks.album.getById.mockResolvedValue(album); mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id])); mocks.album.getMetadataForIds.mockResolvedValue([ @@ -699,7 +578,7 @@ describe(AlbumService.name, () => { }, ]); - const auth = factory.auth({ sharedLink: {} }); + const auth = AuthFactory.from().sharedLink().build(); await sut.get(auth, album.id, {}); expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true }); @@ -707,13 +586,8 @@ describe(AlbumService.name, () => { }); it('should get a shared album via shared with user', async () => { - const owner = factory.userAdmin(); - const user = factory.userAdmin(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - albumUsers: [{ user, role: AlbumUserRole.Editor }], - }; + const user = UserFactory.create(); + const album = AlbumFactory.from().albumUser({ userId: user.id }).build(); mocks.album.getById.mockResolvedValue(album); mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id])); mocks.album.getMetadataForIds.mockResolvedValue([ @@ -726,7 +600,7 @@ describe(AlbumService.name, () => { }, ]); - await sut.get(factory.auth({ user }), album.id, {}); + await sut.get(AuthFactory.create(user), album.id, {}); expect(mocks.album.getById).toHaveBeenCalledWith(album.id, { withAssets: true }); expect(mocks.access.album.checkSharedAlbumAccess).toHaveBeenCalledWith( @@ -737,7 +611,7 @@ describe(AlbumService.name, () => { }); it('should throw an error for no access', async () => { - const auth = factory.auth(); + const auth = AuthFactory.create(); await expect(sut.get(auth, 'album-123', {})).rejects.toBeInstanceOf(BadRequestException); expect(mocks.access.album.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['album-123'])); @@ -751,19 +625,16 @@ describe(AlbumService.name, () => { describe('addAssets', () => { it('should allow the owner to add assets', async () => { - const owner = factory.userAdmin(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - }; - const [asset1, asset2, asset3] = [factory.asset(), factory.asset(), factory.asset()]; + const owner = UserFactory.create({ isAdmin: true }); + const album = AlbumFactory.from({ ownerId: owner.id }).owner(owner).build(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect( - sut.addAssets(factory.auth({ user: owner }), album.id, { ids: [asset1.id, asset2.id, asset3.id] }), + sut.addAssets(AuthFactory.create(owner), album.id, { ids: [asset1.id, asset2.id, asset3.id] }), ).resolves.toEqual([ { success: true, id: asset1.id }, { success: true, id: asset2.id }, @@ -779,19 +650,14 @@ describe(AlbumService.name, () => { }); it('should not set the thumbnail if the album has one already', async () => { - const owner = factory.userAdmin(); - const [asset1, asset2] = [factory.asset(), factory.asset()]; - const album = { - ...factory.album({ ownerId: owner.id, albumThumbnailAssetId: asset1.id }), - owner, - assets: [{ ...asset1, exifInfo: factory.exif() }], - }; + const [asset1, asset2] = [AssetFactory.create(), AssetFactory.create()]; + const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset2.id])); mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); - await expect(sut.addAssets(factory.auth({ user: owner }), album.id, { ids: [asset2.id] })).resolves.toEqual([ + await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset2.id] })).resolves.toEqual([ { success: true, id: asset2.id }, ]); @@ -804,21 +670,16 @@ describe(AlbumService.name, () => { }); it('should allow a shared user to add assets', async () => { - const owner = factory.userAdmin(); - const user = factory.user(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - albumUsers: [{ user, role: AlbumUserRole.Editor }], - }; - const [asset1, asset2, asset3] = [factory.asset(), factory.asset(), factory.asset()]; + const user = UserFactory.create(); + const album = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Editor }).build(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); await expect( - sut.addAssets(factory.auth({ user }), album.id, { ids: [asset1.id, asset2.id, asset3.id] }), + sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset1.id, asset2.id, asset3.id] }), ).resolves.toEqual([ { success: true, id: asset1.id }, { success: true, id: asset2.id }, @@ -833,37 +694,28 @@ describe(AlbumService.name, () => { expect(mocks.album.addAssetIds).toHaveBeenCalledWith(album.id, [asset1.id, asset2.id, asset3.id]); expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { id: album.id, - recipientId: owner.id, + recipientId: album.ownerId, }); }); it('should not allow a shared user with viewer access to add assets', async () => { - const owner = factory.userAdmin(); - const user = factory.user(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - albumUsers: [{ user, role: AlbumUserRole.Viewer }], - }; - const [asset1, asset2, asset3] = [factory.asset(), factory.asset(), factory.asset()]; + const user = UserFactory.create(); + const album = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Viewer }).build(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set()); mocks.album.getById.mockResolvedValue(album); await expect( - sut.addAssets(factory.auth({ user }), album.id, { ids: [asset1.id, asset2.id, asset3.id] }), + sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset1.id, asset2.id, asset3.id] }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.album.update).not.toHaveBeenCalled(); }); it('should allow a shared link user to add assets', async () => { - const owner = factory.userAdmin(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - }; - const [asset1, asset2, asset3] = [factory.asset(), factory.asset(), factory.asset()]; - const auth = factory.auth({ sharedLink: { allowUpload: true } }); + const album = AlbumFactory.create(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; + const auth = AuthFactory.from(album.owner).sharedLink({ allowUpload: true, userId: album.ownerId }).build(); mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); mocks.album.getById.mockResolvedValue(album); @@ -886,19 +738,14 @@ describe(AlbumService.name, () => { }); it('should allow adding assets shared via partner sharing', async () => { - const owner = factory.userAdmin(); - const user = factory.user(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - }; - const asset = factory.asset({ ownerId: user.id }); + const album = AlbumFactory.create(); + const asset = AssetFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id])); mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); - await expect(sut.addAssets(factory.auth({ user: owner }), album.id, { ids: [asset.id] })).resolves.toEqual([ + await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ { success: true, id: asset.id }, ]); @@ -907,23 +754,18 @@ describe(AlbumService.name, () => { updatedAt: expect.any(Date), albumThumbnailAssetId: asset.id, }); - expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(owner.id, new Set([asset.id])); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(album.ownerId, new Set([asset.id])); }); it('should skip duplicate assets', async () => { - const owner = factory.userAdmin(); - const asset = factory.asset(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - assets: [{ ...asset, exifInfo: factory.exif() }], - }; + const asset = AssetFactory.create(); + const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValueOnce(new Set([asset.id])); - await expect(sut.addAssets(factory.auth({ user: owner }), album.id, { ids: [asset.id] })).resolves.toEqual([ + await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ { success: false, id: asset.id, error: BulkIdErrorReason.DUPLICATE }, ]); @@ -931,35 +773,27 @@ describe(AlbumService.name, () => { }); it('should skip assets not shared with user', async () => { - const owner = factory.userAdmin(); - const asset = factory.asset(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - }; + const asset = AssetFactory.create(); + const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()); - await expect(sut.addAssets(factory.auth({ user: owner }), album.id, { ids: [asset.id] })).resolves.toEqual([ + await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ { success: false, id: asset.id, error: BulkIdErrorReason.NO_PERMISSION }, ]); - expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(owner.id, new Set([asset.id]), false); - expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(owner.id, new Set([asset.id])); + expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith(album.ownerId, new Set([asset.id]), false); + expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith(album.ownerId, new Set([asset.id])); }); it('should not allow unauthorized access to the album', async () => { - const owner = factory.userAdmin(); - const user = factory.user(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - }; - const asset = factory.asset({ ownerId: user.id }); + const user = UserFactory.create(); + const album = AlbumFactory.create(); + const asset = AssetFactory.create({ ownerId: user.id }); mocks.album.getById.mockResolvedValue(album); - await expect(sut.addAssets(factory.auth({ user }), album.id, { ids: [asset.id] })).rejects.toBeInstanceOf( + await expect(sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset.id] })).rejects.toBeInstanceOf( BadRequestException, ); @@ -968,16 +802,12 @@ describe(AlbumService.name, () => { }); it('should not allow unauthorized shared link access to the album', async () => { - const owner = factory.userAdmin(); - const album = { - ...factory.album({ ownerId: owner.id }), - owner, - }; - const asset = factory.asset(); + const album = AlbumFactory.create(); + const asset = AssetFactory.create(); mocks.album.getById.mockResolvedValue(album); await expect( - sut.addAssets(factory.auth({ sharedLink: { allowUpload: true } }), album.id, { ids: [asset.id] }), + sut.addAssets(AuthFactory.from().sharedLink({ allowUpload: true }).build(), album.id, { ids: [asset.id] }), ).rejects.toBeInstanceOf(BadRequestException); expect(mocks.access.album.checkSharedLinkAccess).toHaveBeenCalled(); @@ -986,23 +816,16 @@ describe(AlbumService.name, () => { describe('addAssetsToAlbums', () => { it('should allow the owner to add assets', async () => { - const owner = factory.userAdmin(); - const album1 = { - ...factory.album({ ownerId: owner.id }), - owner, - }; - const album2 = { - ...factory.album({ ownerId: owner.id }), - owner, - }; - const [asset1, asset2, asset3] = [factory.asset(), factory.asset(), factory.asset()]; + const album1 = AlbumFactory.create(); + const album2 = AlbumFactory.create(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( - sut.addAssetsToAlbums(factory.auth({ user: owner }), { + sut.addAssetsToAlbums(AuthFactory.create(album1.owner), { albumIds: [album1.id, album2.id], assetIds: [asset1.id, asset2.id, asset3.id], }), @@ -1030,26 +853,17 @@ describe(AlbumService.name, () => { }); it('should not set the thumbnail if the album has one already', async () => { - const owner = factory.userAdmin(); - const asset = factory.asset(); - const album1 = { - ...factory.album({ ownerId: owner.id, albumThumbnailAssetId: asset.id }), - owner, - albumAssets: [asset], - }; - const album2 = { - ...factory.album({ ownerId: owner.id, albumThumbnailAssetId: asset.id }), - owner, - albumAssets: [asset], - }; - const [asset1, asset2, asset3] = [factory.asset(), factory.asset(), factory.asset()]; + const asset = AssetFactory.create(); + const album1 = AlbumFactory.from({ albumThumbnailAssetId: asset.id }).build(); + const album2 = AlbumFactory.from({ albumThumbnailAssetId: asset.id }).build(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( - sut.addAssetsToAlbums(factory.auth({ user: owner }), { + sut.addAssetsToAlbums(AuthFactory.create(album1.owner), { albumIds: [album1.id, album2.id], assetIds: [asset1.id, asset2.id, asset3.id], }), @@ -1077,26 +891,17 @@ describe(AlbumService.name, () => { }); it('should allow a shared user to add assets', async () => { - const owner = factory.userAdmin(); - const user = factory.user(); - const album1 = { - ...factory.album({ ownerId: owner.id }), - owner, - albumUsers: [{ user, role: AlbumUserRole.Editor }], - }; - const album2 = { - ...factory.album({ ownerId: owner.id }), - owner, - albumUsers: [{ user, role: AlbumUserRole.Editor }], - }; - const [asset1, asset2, asset3] = [factory.asset(), factory.asset(), factory.asset()]; + const user = UserFactory.create(); + const album1 = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Editor }).build(); + const album2 = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Editor }).build(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set([album1.id, album2.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( - sut.addAssetsToAlbums(factory.auth({ user }), { + sut.addAssetsToAlbums(AuthFactory.create(user), { albumIds: [album1.id, album2.id], assetIds: [asset1.id, asset2.id, asset3.id], }), @@ -1123,35 +928,26 @@ describe(AlbumService.name, () => { ]); expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { id: album1.id, - recipientId: owner.id, + recipientId: album1.ownerId, }); expect(mocks.event.emit).toHaveBeenCalledWith('AlbumUpdate', { id: album2.id, - recipientId: owner.id, + recipientId: album2.ownerId, }); }); it('should not allow a shared user with viewer access to add assets', async () => { - const owner = factory.userAdmin(); - const user = factory.user(); - const album1 = { - ...factory.album({ ownerId: owner.id }), - owner, - albumUsers: [{ user, role: AlbumUserRole.Viewer }], - }; - const album2 = { - ...factory.album({ ownerId: owner.id }), - owner, - albumUsers: [{ user, role: AlbumUserRole.Viewer }], - }; - const [asset1, asset2, asset3] = [factory.asset(), factory.asset(), factory.asset()]; + const user = UserFactory.create(); + const album1 = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Viewer }).build(); + const album2 = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Viewer }).build(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( - sut.addAssetsToAlbums(factory.auth({ user }), { + sut.addAssetsToAlbums(AuthFactory.create(user), { albumIds: [album1.id, album2.id], assetIds: [asset1.id, asset2.id, asset3.id], }), @@ -1164,22 +960,15 @@ describe(AlbumService.name, () => { }); it('should not allow a shared link user to add assets to multiple albums', async () => { - const owner = factory.userAdmin(); - const album1 = { - ...factory.album({ ownerId: owner.id }), - owner, - }; - const album2 = { - ...factory.album({ ownerId: owner.id }), - owner, - }; - const [asset1, asset2, asset3] = [factory.asset(), factory.asset(), factory.asset()]; + const album1 = AlbumFactory.create(); + const album2 = AlbumFactory.create(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.access.album.checkSharedLinkAccess.mockResolvedValueOnce(new Set([album1.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); - const auth = factory.auth({ user: owner, sharedLink: { allowUpload: true } }); + const auth = AuthFactory.from(album1.owner).sharedLink({ allowUpload: true }).build(); await expect( sut.addAssetsToAlbums(auth, { albumIds: [album1.id, album2.id], @@ -1205,20 +994,13 @@ describe(AlbumService.name, () => { }); it('should allow adding assets shared via partner sharing', async () => { - const owner = factory.userAdmin(); - const user = factory.user(); - const album1 = { - ...factory.album({ ownerId: owner.id }), - owner, - }; - const album2 = { - ...factory.album({ ownerId: owner.id }), - owner, - }; + const user = UserFactory.create(); + const album1 = AlbumFactory.create(); + const album2 = AlbumFactory.create(); const [asset1, asset2, asset3] = [ - factory.asset({ ownerId: user.id }), - factory.asset({ ownerId: user.id }), - factory.asset({ ownerId: user.id }), + AssetFactory.create({ ownerId: user.id }), + AssetFactory.create({ ownerId: user.id }), + AssetFactory.create({ ownerId: user.id }), ]; mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id])); mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); @@ -1226,7 +1008,7 @@ describe(AlbumService.name, () => { mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( - sut.addAssetsToAlbums(factory.auth({ user: owner }), { + sut.addAssetsToAlbums(AuthFactory.create(album1.owner), { albumIds: [album1.id, album2.id], assetIds: [asset1.id, asset2.id, asset3.id], }), @@ -1252,23 +1034,15 @@ describe(AlbumService.name, () => { { albumId: album2.id, assetId: asset3.id }, ]); expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith( - owner.id, + album1.ownerId, new Set([asset1.id, asset2.id, asset3.id]), ); }); it('should skip some duplicate assets', async () => { - const owner = factory.userAdmin(); - const [asset1, asset2, asset3] = [factory.asset(), factory.asset(), factory.asset()]; - const album1 = { - ...factory.album({ ownerId: owner.id }), - owner, - albumAssets: [asset1, asset2, asset3], - }; - const album2 = { - ...factory.album({ ownerId: owner.id }), - owner, - }; + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; + const album1 = AlbumFactory.create(); + const album2 = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); mocks.album.getAssetIds @@ -1277,7 +1051,7 @@ describe(AlbumService.name, () => { mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); await expect( - sut.addAssetsToAlbums(factory.auth({ user: owner }), { + sut.addAssetsToAlbums(AuthFactory.create(album1.owner), { albumIds: [album1.id, album2.id], assetIds: [asset1.id, asset2.id, asset3.id], }), @@ -1297,18 +1071,9 @@ describe(AlbumService.name, () => { }); it('should skip all duplicate assets', async () => { - const owner = factory.userAdmin(); - const [asset1, asset2, asset3] = [factory.asset(), factory.asset(), factory.asset()]; - const album1 = { - ...factory.album({ ownerId: owner.id }), - owner, - albumAssets: [asset1, asset2, asset3], - }; - const album2 = { - ...factory.album({ ownerId: owner.id }), - owner, - albumAssets: [asset1, asset2, asset3], - }; + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; + const album1 = AlbumFactory.create(); + const album2 = AlbumFactory.create(); mocks.access.album.checkOwnerAccess .mockResolvedValueOnce(new Set([album1.id])) .mockResolvedValueOnce(new Set([album2.id])); @@ -1317,7 +1082,7 @@ describe(AlbumService.name, () => { mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id])); await expect( - sut.addAssetsToAlbums(factory.auth({ user: owner }), { + sut.addAssetsToAlbums(AuthFactory.create(album1.owner), { albumIds: [album1.id, album2.id], assetIds: [asset1.id, asset2.id, asset3.id], }), @@ -1331,20 +1096,13 @@ describe(AlbumService.name, () => { }); it('should skip assets not shared with user', async () => { - const owner = factory.userAdmin(); - const user = factory.user(); - const album1 = { - ...factory.album({ ownerId: owner.id }), - owner, - }; - const album2 = { - ...factory.album({ ownerId: owner.id }), - owner, - }; + const user = UserFactory.create(); + const album1 = AlbumFactory.create(); + const album2 = AlbumFactory.create(); const [asset1, asset2, asset3] = [ - factory.asset({ ownerId: user.id }), - factory.asset({ ownerId: user.id }), - factory.asset({ ownerId: user.id }), + AssetFactory.create({ ownerId: user.id }), + AssetFactory.create({ ownerId: user.id }), + AssetFactory.create({ ownerId: user.id }), ]; mocks.access.album.checkSharedAlbumAccess .mockResolvedValueOnce(new Set([album1.id])) @@ -1353,7 +1111,7 @@ describe(AlbumService.name, () => { mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set()); await expect( - sut.addAssetsToAlbums(factory.auth({ user: owner }), { + sut.addAssetsToAlbums(AuthFactory.create(album1.owner), { albumIds: [album1.id, album2.id], assetIds: [asset1.id, asset2.id, asset3.id], }), @@ -1365,32 +1123,25 @@ describe(AlbumService.name, () => { expect(mocks.album.update).not.toHaveBeenCalled(); expect(mocks.album.addAssetIds).not.toHaveBeenCalled(); expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledWith( - owner.id, + album1.ownerId, new Set([asset1.id, asset2.id, asset3.id]), false, ); expect(mocks.access.asset.checkPartnerAccess).toHaveBeenCalledWith( - owner.id, + album1.ownerId, new Set([asset1.id, asset2.id, asset3.id]), ); }); it('should not allow unauthorized access to the albums', async () => { - const owner = factory.userAdmin(); - const user = factory.user(); - const album1 = { - ...factory.album({ ownerId: owner.id }), - owner, - }; - const album2 = { - ...factory.album({ ownerId: owner.id }), - owner, - }; - const [asset1, asset2, asset3] = [factory.asset(), factory.asset(), factory.asset()]; + const user = UserFactory.create(); + const album1 = AlbumFactory.create(); + const album2 = AlbumFactory.create(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); await expect( - sut.addAssetsToAlbums(factory.auth({ user }), { + sut.addAssetsToAlbums(AuthFactory.create(user), { albumIds: [album1.id, album2.id], assetIds: [asset1.id, asset2.id, asset3.id], }), @@ -1406,20 +1157,13 @@ describe(AlbumService.name, () => { }); it('should not allow unauthorized shared link access to the album', async () => { - const owner = factory.userAdmin(); - const album1 = { - ...factory.album({ ownerId: owner.id }), - owner, - }; - const album2 = { - ...factory.album({ ownerId: owner.id }), - owner, - }; - const [asset1, asset2, asset3] = [factory.asset(), factory.asset(), factory.asset()]; + const album1 = AlbumFactory.create(); + const album2 = AlbumFactory.create(); + const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()]; mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2); await expect( - sut.addAssetsToAlbums(factory.auth({ sharedLink: { allowUpload: true } }), { + sut.addAssetsToAlbums(AuthFactory.from().sharedLink({ allowUpload: true }).build(), { albumIds: [album1.id, album2.id], assetIds: [asset1.id, asset2.id, asset3.id], }), @@ -1434,19 +1178,14 @@ describe(AlbumService.name, () => { describe('removeAssets', () => { it('should allow the owner to remove assets', async () => { - const owner = factory.userAdmin(); - const asset = factory.asset(); - const album = { - ...factory.album({ ownerId: owner.id, albumThumbnailAssetId: asset.id }), - owner, - albumAssets: [asset, factory.asset()], - }; + const asset = AssetFactory.create(); + const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id])); mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id])); - await expect(sut.removeAssets(factory.auth({ user: owner }), album.id, { ids: [asset.id] })).resolves.toEqual([ + await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ { success: true, id: asset.id }, ]); @@ -1454,19 +1193,13 @@ describe(AlbumService.name, () => { }); it('should skip assets not in the album', async () => { - const owner = factory.userAdmin(); - const asset = factory.asset(); - const albumAsset = factory.asset(); - const album = { - ...factory.album({ ownerId: owner.id, albumThumbnailAssetId: albumAsset.id }), - owner, - albumAssets: [albumAsset], - }; + const asset = AssetFactory.create(); + const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValue(new Set()); - await expect(sut.removeAssets(factory.auth({ user: owner }), album.id, { ids: [asset.id] })).resolves.toEqual([ + await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ { success: false, id: asset.id, error: BulkIdErrorReason.NOT_FOUND }, ]); @@ -1474,37 +1207,27 @@ describe(AlbumService.name, () => { }); it('should allow owner to remove all assets from the album', async () => { - const owner = factory.userAdmin(); - const asset = factory.asset(); - const album = { - ...factory.album({ ownerId: owner.id, albumThumbnailAssetId: asset.id }), - owner, - albumAssets: [asset], - }; + const asset = AssetFactory.create(); + const album = AlbumFactory.create(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id])); - await expect(sut.removeAssets(factory.auth({ user: owner }), album.id, { ids: [asset.id] })).resolves.toEqual([ + await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([ { success: true, id: asset.id }, ]); }); it('should reset the thumbnail if it is removed', async () => { - const owner = factory.userAdmin(); - const asset1 = factory.asset(); - const asset2 = factory.asset(); - const album = { - ...factory.album({ ownerId: owner.id, albumThumbnailAssetId: asset1.id }), - owner, - albumAssets: [asset1, asset2], - }; + const asset1 = AssetFactory.create(); + const asset2 = AssetFactory.create(); + const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build(); mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id])); mocks.album.getById.mockResolvedValue(album); mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id])); - await expect(sut.removeAssets(factory.auth({ user: owner }), album.id, { ids: [asset1.id] })).resolves.toEqual([ + await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset1.id] })).resolves.toEqual([ { success: true, id: asset1.id }, ]); diff --git a/server/test/factories/album-user.factory.ts b/server/test/factories/album-user.factory.ts new file mode 100644 index 0000000000..6e2f8cb832 --- /dev/null +++ b/server/test/factories/album-user.factory.ts @@ -0,0 +1,54 @@ +import { Selectable } from 'kysely'; +import { AlbumUserRole } from 'src/enum'; +import { AlbumUserTable } from 'src/schema/tables/album-user.table'; +import { AlbumFactory } from 'test/factories/album.factory'; +import { build } from 'test/factories/builder.factory'; +import { AlbumUserLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class AlbumUserFactory { + #user!: UserFactory; + + private constructor(private readonly value: Selectable) { + value.userId ??= newUuid(); + this.#user = UserFactory.from({ id: value.userId }); + } + + static create(dto: AlbumUserLike = {}) { + return AlbumUserFactory.from(dto).build(); + } + + static from(dto: AlbumUserLike = {}) { + return new AlbumUserFactory({ + albumId: newUuid(), + userId: newUuid(), + role: AlbumUserRole.Editor, + createId: newUuidV7(), + createdAt: newDate(), + updateId: newUuidV7(), + updatedAt: newDate(), + ...dto, + }); + } + + album(dto: AlbumUserLike = {}, builder?: FactoryBuilder) { + const album = build(AlbumFactory.from(dto), builder); + this.value.albumId = album.build().id; + return this; + } + + user(dto: UserLike = {}, builder?: FactoryBuilder) { + const user = build(UserFactory.from(dto), builder); + this.value.userId = user.build().id; + this.#user = user; + return this; + } + + build() { + return { + ...this.value, + user: this.#user.build(), + }; + } +} diff --git a/server/test/factories/album.factory.ts b/server/test/factories/album.factory.ts new file mode 100644 index 0000000000..f401cd343d --- /dev/null +++ b/server/test/factories/album.factory.ts @@ -0,0 +1,87 @@ +import { Selectable } from 'kysely'; +import { AssetOrder } from 'src/enum'; +import { AlbumTable } from 'src/schema/tables/album.table'; +import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; +import { AlbumUserFactory } from 'test/factories/album-user.factory'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { build } from 'test/factories/builder.factory'; +import { AlbumLike, AlbumUserLike, AssetLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class AlbumFactory { + #owner: UserFactory; + #sharedLinks: Selectable[] = []; + #albumUsers: AlbumUserFactory[] = []; + #assets: AssetFactory[] = []; + + private constructor(private readonly value: Selectable) { + value.ownerId ??= newUuid(); + this.#owner = UserFactory.from({ id: value.ownerId }); + } + + static create(dto: AlbumLike = {}) { + return AlbumFactory.from(dto).build(); + } + + static from(dto: AlbumLike = {}) { + return new AlbumFactory({ + id: newUuid(), + ownerId: newUuid(), + albumName: 'My Album', + albumThumbnailAssetId: null, + createdAt: newDate(), + deletedAt: null, + description: 'Album description', + isActivityEnabled: false, + order: AssetOrder.Desc, + updatedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }).owner(); + } + + owner(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#owner = build(UserFactory.from(dto), builder); + this.value.ownerId = this.#owner.build().id; + return this; + } + + sharedLinks() { + this.#sharedLinks = []; + return this; + } + + albumUser(dto: AlbumUserLike = {}, builder?: FactoryBuilder) { + const albumUser = build(AlbumUserFactory.from(dto).album(this.value), builder); + this.#albumUsers.push(albumUser); + return this; + } + + asset(dto: AssetLike = {}, builder?: FactoryBuilder) { + const asset = build(AssetFactory.from(dto), builder); + + // use album owner by default + if (!dto.ownerId) { + asset.owner(this.#owner.build()); + } + + if (!this.#assets) { + this.#assets = []; + } + + this.#assets.push(asset); + + return this; + } + + build() { + return { + ...this.value, + owner: this.#owner.build(), + assets: this.#assets.map((asset) => asset.build()), + albumUsers: this.#albumUsers.map((albumUser) => albumUser.build()), + sharedLinks: this.#sharedLinks, + }; + } +} diff --git a/server/test/factories/asset-exif.factory.ts b/server/test/factories/asset-exif.factory.ts new file mode 100644 index 0000000000..da4d689ebf --- /dev/null +++ b/server/test/factories/asset-exif.factory.ts @@ -0,0 +1,55 @@ +import { Selectable } from 'kysely'; +import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; +import { AssetExifLike } from 'test/factories/types'; +import { factory } from 'test/small.factory'; + +export class AssetExifFactory { + private constructor(private readonly value: Selectable) {} + + static create(dto: AssetExifLike = {}) { + return AssetExifFactory.from(dto).build(); + } + + static from(dto: AssetExifLike = {}) { + return new AssetExifFactory({ + updatedAt: factory.date(), + updateId: factory.uuid(), + assetId: factory.uuid(), + autoStackId: null, + bitsPerSample: null, + city: 'Austin', + colorspace: null, + country: 'United States of America', + dateTimeOriginal: factory.date(), + description: '', + exifImageHeight: 420, + exifImageWidth: 42, + exposureTime: null, + fileSizeInByte: 69, + fNumber: 1.7, + focalLength: 4.38, + fps: null, + iso: 947, + latitude: 30.267_334_570_570_195, + longitude: -97.789_833_534_282_07, + lensModel: null, + livePhotoCID: null, + make: 'Google', + model: 'Pixel 7', + modifyDate: factory.date(), + orientation: '1', + profileDescription: null, + projectionType: null, + rating: 4, + lockedProperties: [], + state: 'Texas', + tags: ['parent/child'], + timeZone: 'UTC-6', + ...dto, + }); + } + + build() { + return { ...this.value }; + } +} diff --git a/server/test/factories/asset.factory.ts b/server/test/factories/asset.factory.ts new file mode 100644 index 0000000000..41714dbf67 --- /dev/null +++ b/server/test/factories/asset.factory.ts @@ -0,0 +1,79 @@ +import { Selectable } from 'kysely'; +import { AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { AssetExifFactory } from 'test/factories/asset-exif.factory'; +import { build } from 'test/factories/builder.factory'; +import { AssetExifLike, AssetLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { factory, newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory'; + +export class AssetFactory { + #assetExif?: AssetExifFactory; + #owner!: UserFactory; + + private constructor(private readonly value: Selectable) { + value.ownerId ??= newUuid(); + this.#owner = UserFactory.from({ id: value.ownerId }); + } + + static create(dto: AssetLike = {}) { + return AssetFactory.from(dto).build(); + } + + static from(dto: AssetLike = {}) { + return new AssetFactory({ + id: factory.uuid(), + createdAt: newDate(), + updatedAt: newDate(), + deletedAt: null, + updateId: newUuidV7(), + status: AssetStatus.Active, + checksum: newSha1(), + deviceAssetId: '', + deviceId: '', + duplicateId: null, + duration: null, + encodedVideoPath: null, + fileCreatedAt: newDate(), + fileModifiedAt: newDate(), + isExternal: false, + isFavorite: false, + isOffline: false, + libraryId: null, + livePhotoVideoId: null, + localDateTime: newDate(), + originalFileName: 'IMG_123.jpg', + originalPath: `/data/12/34/IMG_123.jpg`, + ownerId: newUuid(), + stackId: null, + thumbhash: null, + type: AssetType.Image, + visibility: AssetVisibility.Timeline, + width: null, + height: null, + isEdited: false, + ...dto, + }); + } + + owner(dto: UserLike = {}, builder?: FactoryBuilder) { + this.#owner = build(UserFactory.from(dto), builder); + this.value.ownerId = this.#owner.build().id; + return this; + } + + exif(dto: AssetExifLike = {}, builder?: FactoryBuilder) { + this.#assetExif = build(AssetExifFactory.from(dto), builder); + return this; + } + + build() { + const exif = this.#assetExif?.build(); + + return { + ...this.value, + exifInfo: exif as NonNullable, + owner: this.#owner.build(), + }; + } +} diff --git a/server/test/factories/auth.factory.ts b/server/test/factories/auth.factory.ts new file mode 100644 index 0000000000..9c738aabac --- /dev/null +++ b/server/test/factories/auth.factory.ts @@ -0,0 +1,48 @@ +import { AuthDto } from 'src/dtos/auth.dto'; +import { build } from 'test/factories/builder.factory'; +import { SharedLinkFactory } from 'test/factories/shared-link.factory'; +import { FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; + +export class AuthFactory { + #user: UserFactory; + #sharedLink?: SharedLinkFactory; + + private constructor(user: UserFactory) { + this.#user = user; + } + + static create(dto: UserLike = {}) { + return AuthFactory.from(dto).build(); + } + + static from(dto: UserLike = {}) { + return new AuthFactory(UserFactory.from(dto)); + } + + apiKey() { + // TODO + return this; + } + + sharedLink(dto: SharedLinkLike = {}, builder?: FactoryBuilder) { + this.#sharedLink = build(SharedLinkFactory.from(dto), builder); + return this; + } + + build(): AuthDto { + const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes } = this.#user.build(); + + return { + user: { + id, + isAdmin, + name, + email, + quotaUsageInBytes, + quotaSizeInBytes, + }, + sharedLink: this.#sharedLink?.build(), + }; + } +} diff --git a/server/test/factories/builder.factory.ts b/server/test/factories/builder.factory.ts new file mode 100644 index 0000000000..4efa7a498f --- /dev/null +++ b/server/test/factories/builder.factory.ts @@ -0,0 +1,5 @@ +import { FactoryBuilder } from 'test/factories/types'; + +export const build = (factory: T, builder?: FactoryBuilder) => { + return builder ? builder(factory) : factory; +}; diff --git a/server/test/factories/shared-link.factory.ts b/server/test/factories/shared-link.factory.ts new file mode 100644 index 0000000000..585b43dd84 --- /dev/null +++ b/server/test/factories/shared-link.factory.ts @@ -0,0 +1,63 @@ +import { Selectable } from 'kysely'; +import { SharedLinkType } from 'src/enum'; +import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; +import { AlbumFactory } from 'test/factories/album.factory'; +import { build } from 'test/factories/builder.factory'; +import { AlbumLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types'; +import { UserFactory } from 'test/factories/user.factory'; +import { factory, newDate, newUuid } from 'test/small.factory'; + +export class SharedLinkFactory { + #owner: UserFactory; + #album?: AlbumFactory; + + private constructor(private readonly value: Selectable) { + value.userId ??= newUuid(); + this.#owner = UserFactory.from({ id: value.userId }); + } + + static create(dto: SharedLinkLike = {}) { + return SharedLinkFactory.from(dto).build(); + } + + static from(dto: SharedLinkLike = {}) { + const type = dto.type ?? SharedLinkType.Individual; + const albumId = (dto.albumId ?? type === SharedLinkType.Album) ? newUuid() : null; + + return new SharedLinkFactory({ + id: factory.uuid(), + description: 'Shared link description', + userId: newUuid(), + key: factory.buffer(), + type, + albumId, + createdAt: newDate(), + expiresAt: null, + allowUpload: true, + allowDownload: true, + showExif: true, + password: null, + slug: null, + ...dto, + }); + } + + owner(dto: UserLike = {}, builder?: FactoryBuilder): SharedLinkFactory { + this.#owner = build(UserFactory.from(dto), builder); + return this; + } + + album(dto: AlbumLike = {}, builder?: FactoryBuilder) { + this.#album = build(AlbumFactory.from(dto), builder); + return this; + } + + build() { + return { + ...this.value, + owner: this.#owner.build(), + album: this.#album?.build(), + assets: [], + }; + } +} diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts new file mode 100644 index 0000000000..43ae26c9f4 --- /dev/null +++ b/server/test/factories/types.ts @@ -0,0 +1,16 @@ +import { Selectable } from 'kysely'; +import { AlbumUserTable } from 'src/schema/tables/album-user.table'; +import { AlbumTable } from 'src/schema/tables/album.table'; +import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; +import { AssetTable } from 'src/schema/tables/asset.table'; +import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; +import { UserTable } from 'src/schema/tables/user.table'; + +export type FactoryBuilder = (builder: T) => R; + +export type AssetLike = Partial>; +export type AssetExifLike = Partial>; +export type AlbumLike = Partial>; +export type AlbumUserLike = Partial>; +export type SharedLinkLike = Partial>; +export type UserLike = Partial>; diff --git a/server/test/factories/user.factory.ts b/server/test/factories/user.factory.ts new file mode 100644 index 0000000000..e6e84d94a1 --- /dev/null +++ b/server/test/factories/user.factory.ts @@ -0,0 +1,46 @@ +import { Selectable } from 'kysely'; +import { UserStatus } from 'src/enum'; +import { UserMetadataTable } from 'src/schema/tables/user-metadata.table'; +import { UserTable } from 'src/schema/tables/user.table'; +import { UserLike } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class UserFactory { + private constructor(private value: Selectable) {} + + static create(dto: UserLike = {}) { + return UserFactory.from(dto).build(); + } + + static from(dto: UserLike = {}) { + return new UserFactory({ + id: newUuid(), + email: 'test@immich.cloud', + password: '', + pinCode: null, + createdAt: newDate(), + profileImagePath: '', + isAdmin: false, + shouldChangePassword: false, + avatarColor: null, + deletedAt: null, + oauthId: '', + updatedAt: newDate(), + storageLabel: null, + name: 'Test User', + quotaSizeInBytes: null, + quotaUsageInBytes: 0, + status: UserStatus.Active, + profileChangedAt: newDate(), + updateId: newUuidV7(), + ...dto, + }); + } + + build() { + return { + ...this.value, + metadata: [] as UserMetadataTable[], + }; + } +} From e3e243fa2b9627c5e516d4fd2402637bb28045d8 Mon Sep 17 00:00:00 2001 From: Jason Rasmussen Date: Fri, 6 Feb 2026 18:47:54 -0500 Subject: [PATCH 02/40] refactor: tests (#25987) --- server/src/dtos/album-response.dto.spec.ts | 13 +- server/src/services/asset.service.spec.ts | 96 ++--- server/src/services/download.service.spec.ts | 33 +- server/src/services/media.service.spec.ts | 119 +++--- server/src/services/media.service.ts | 2 +- server/src/services/metadata.service.spec.ts | 267 ++++++------ server/src/services/stack.service.spec.ts | 7 +- server/test/factories/asset-edit.factory.ts | 38 ++ server/test/factories/asset-file.factory.ts | 43 ++ server/test/factories/asset.factory.ts | 63 ++- server/test/factories/types.ts | 4 + server/test/fixtures/album.stub.ts | 68 --- server/test/fixtures/asset.stub.ts | 410 ------------------- server/test/fixtures/user.stub.ts | 17 - 14 files changed, 415 insertions(+), 765 deletions(-) create mode 100644 server/test/factories/asset-edit.factory.ts create mode 100644 server/test/factories/asset-file.factory.ts diff --git a/server/src/dtos/album-response.dto.spec.ts b/server/src/dtos/album-response.dto.spec.ts index dd8642598f..d3536a3482 100644 --- a/server/src/dtos/album-response.dto.spec.ts +++ b/server/src/dtos/album-response.dto.spec.ts @@ -1,15 +1,18 @@ import { mapAlbum } from 'src/dtos/album.dto'; -import { albumStub } from 'test/fixtures/album.stub'; +import { AlbumFactory } from 'test/factories/album.factory'; describe('mapAlbum', () => { it('should set start and end dates', () => { - const dto = mapAlbum(albumStub.twoAssets, false); - expect(dto.startDate).toEqual(new Date('2020-12-31T23:59:00.000Z')); - expect(dto.endDate).toEqual(new Date('2025-01-01T01:02:03.456Z')); + const startDate = new Date('2023-02-22T05:06:29.716Z'); + const endDate = new Date('2025-01-01T01:02:03.456Z'); + const album = AlbumFactory.from().asset({ localDateTime: endDate }).asset({ localDateTime: startDate }).build(); + const dto = mapAlbum(album, false); + expect(dto.startDate).toEqual(startDate); + expect(dto.endDate).toEqual(endDate); }); it('should not set start and end dates for empty assets', () => { - const dto = mapAlbum(albumStub.empty, false); + const dto = mapAlbum(AlbumFactory.create(), false); expect(dto.startDate).toBeUndefined(); expect(dto.endDate).toBeUndefined(); }); diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 707faa326d..ff4dfa96ff 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -6,9 +6,10 @@ import { AssetEditAction } from 'src/dtos/editing.dto'; import { AssetMetadataKey, AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; import { AssetStats } from 'src/repositories/asset.repository'; import { AssetService } from 'src/services/asset.service'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { AuthFactory } from 'test/factories/auth.factory'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; -import { userStub } from 'test/fixtures/user.stub'; import { factory } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -45,35 +46,33 @@ describe(AssetService.name, () => { describe('getStatistics', () => { it('should get the statistics for a user, excluding archived assets', async () => { + const auth = AuthFactory.create(); mocks.asset.getStatistics.mockResolvedValue(stats); - await expect(sut.getStatistics(authStub.admin, { visibility: AssetVisibility.Timeline })).resolves.toEqual( - statResponse, - ); - expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { - visibility: AssetVisibility.Timeline, - }); + await expect(sut.getStatistics(auth, { visibility: AssetVisibility.Timeline })).resolves.toEqual(statResponse); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, { visibility: AssetVisibility.Timeline }); }); it('should get the statistics for a user for archived assets', async () => { + const auth = AuthFactory.create(); mocks.asset.getStatistics.mockResolvedValue(stats); - await expect(sut.getStatistics(authStub.admin, { visibility: AssetVisibility.Archive })).resolves.toEqual( - statResponse, - ); - expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { + await expect(sut.getStatistics(auth, { visibility: AssetVisibility.Archive })).resolves.toEqual(statResponse); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, { visibility: AssetVisibility.Archive, }); }); it('should get the statistics for a user for favorite assets', async () => { + const auth = AuthFactory.create(); mocks.asset.getStatistics.mockResolvedValue(stats); - await expect(sut.getStatistics(authStub.admin, { isFavorite: true })).resolves.toEqual(statResponse); - expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, { isFavorite: true }); + await expect(sut.getStatistics(auth, { isFavorite: true })).resolves.toEqual(statResponse); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, { isFavorite: true }); }); it('should get the statistics for a user for all assets', async () => { + const auth = AuthFactory.create(); mocks.asset.getStatistics.mockResolvedValue(stats); - await expect(sut.getStatistics(authStub.admin, {})).resolves.toEqual(statResponse); - expect(mocks.asset.getStatistics).toHaveBeenCalledWith(authStub.admin.user.id, {}); + await expect(sut.getStatistics(auth, {})).resolves.toEqual(statResponse); + expect(mocks.asset.getStatistics).toHaveBeenCalledWith(auth.user.id, {}); }); }); @@ -249,10 +248,11 @@ describe(AssetService.name, () => { }); it('should fail linking a live video if the motion part could not be found', async () => { + const auth = AuthFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); await expect( - sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { + sut.update(auth, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }), ).rejects.toBeInstanceOf(BadRequestException); @@ -267,11 +267,12 @@ describe(AssetService.name, () => { }); expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', { assetId: assetStub.livePhotoMotionAsset.id, - userId: userStub.admin.id, + userId: auth.user.id, }); }); it('should fail linking a live video if the motion part is not a video', async () => { + const auth = AuthFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); @@ -291,16 +292,17 @@ describe(AssetService.name, () => { }); expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', { assetId: assetStub.livePhotoMotionAsset.id, - userId: userStub.admin.id, + userId: auth.user.id, }); }); it('should fail linking a live video if the motion part has a different owner', async () => { + const auth = AuthFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); await expect( - sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { + sut.update(auth, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: assetStub.livePhotoMotionAsset.id, }), ).rejects.toBeInstanceOf(BadRequestException); @@ -315,52 +317,41 @@ describe(AssetService.name, () => { }); expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', { assetId: assetStub.livePhotoMotionAsset.id, - userId: userStub.admin.id, + userId: auth.user.id, }); }); it('should link a live video', async () => { - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); - mocks.asset.getById.mockResolvedValueOnce({ - ...assetStub.livePhotoMotionAsset, - ownerId: authStub.admin.user.id, - visibility: AssetVisibility.Timeline, - }); - mocks.asset.getById.mockResolvedValueOnce(assetStub.image); - mocks.asset.update.mockResolvedValue(assetStub.image); + const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Timeline }); + const stillAsset = AssetFactory.create(); + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([stillAsset.id])); + mocks.asset.getById.mockResolvedValueOnce(motionAsset); + mocks.asset.getById.mockResolvedValueOnce(stillAsset); + mocks.asset.update.mockResolvedValue(stillAsset); + const auth = AuthFactory.from(motionAsset.owner).build(); - await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { - livePhotoVideoId: assetStub.livePhotoMotionAsset.id, - }); + await sut.update(auth, stillAsset.id, { livePhotoVideoId: motionAsset.id }); - expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.livePhotoMotionAsset.id, - visibility: AssetVisibility.Hidden, - }); - expect(mocks.event.emit).toHaveBeenCalledWith('AssetHide', { - assetId: assetStub.livePhotoMotionAsset.id, - userId: userStub.admin.id, - }); - expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.livePhotoStillAsset.id, - livePhotoVideoId: assetStub.livePhotoMotionAsset.id, - }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: motionAsset.id, visibility: AssetVisibility.Hidden }); + expect(mocks.event.emit).toHaveBeenCalledWith('AssetHide', { assetId: motionAsset.id, userId: auth.user.id }); + expect(mocks.asset.update).toHaveBeenCalledWith({ id: stillAsset.id, livePhotoVideoId: motionAsset.id }); }); it('should throw an error if asset could not be found after update', async () => { mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - await expect(sut.update(authStub.admin, 'asset-1', { isFavorite: true })).rejects.toBeInstanceOf( + await expect(sut.update(AuthFactory.create(), 'asset-1', { isFavorite: true })).rejects.toBeInstanceOf( BadRequestException, ); }); it('should unlink a live video', async () => { + const auth = AuthFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset); mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); mocks.asset.update.mockResolvedValueOnce(assetStub.image); - await sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }); + await sut.update(auth, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }); expect(mocks.asset.update).toHaveBeenCalledWith({ id: assetStub.livePhotoStillAsset.id, @@ -372,7 +363,7 @@ describe(AssetService.name, () => { }); expect(mocks.event.emit).toHaveBeenCalledWith('AssetShow', { assetId: assetStub.livePhotoMotionAsset.id, - userId: userStub.admin.id, + userId: auth.user.id, }); }); @@ -392,17 +383,15 @@ describe(AssetService.name, () => { describe('updateAll', () => { it('should require asset write access for all ids', async () => { - await expect( - sut.updateAll(authStub.admin, { - ids: ['asset-1'], - }), - ).rejects.toBeInstanceOf(BadRequestException); + const auth = AuthFactory.create(); + await expect(sut.updateAll(auth, { ids: ['asset-1'] })).rejects.toBeInstanceOf(BadRequestException); }); it('should update all assets', async () => { + const auth = AuthFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - await sut.updateAll(authStub.admin, { ids: ['asset-1', 'asset-2'], visibility: AssetVisibility.Archive }); + await sut.updateAll(auth, { ids: ['asset-1', 'asset-2'], visibility: AssetVisibility.Archive }); expect(mocks.asset.updateAll).toHaveBeenCalledWith(['asset-1', 'asset-2'], { visibility: AssetVisibility.Archive, @@ -410,9 +399,10 @@ describe(AssetService.name, () => { }); it('should not update Assets table if no relevant fields are provided', async () => { + const auth = AuthFactory.create(); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1'])); - await sut.updateAll(authStub.admin, { + await sut.updateAll(auth, { ids: ['asset-1'], latitude: 0, longitude: 0, diff --git a/server/src/services/download.service.spec.ts b/server/src/services/download.service.spec.ts index 86d0bda7f8..7721b12ffc 100644 --- a/server/src/services/download.service.spec.ts +++ b/server/src/services/download.service.spec.ts @@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common'; import { Readable } from 'node:stream'; import { DownloadResponseDto } from 'src/dtos/download.dto'; import { DownloadService } from 'src/services/download.service'; +import { AssetFactory } from 'test/factories/asset.factory'; import { assetStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; @@ -60,22 +61,22 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); + const asset1 = AssetFactory.create(); + const asset2 = AssetFactory.create(); + + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); mocks.storage.realpath.mockRejectedValue(new Error('Could not read file')); - mocks.asset.getByIds.mockResolvedValue([ - { ...assetStub.noResizePath, id: 'asset-1' }, - { ...assetStub.noWebpPath, id: 'asset-2' }, - ]); + mocks.asset.getByIds.mockResolvedValue([asset1, asset2]); mocks.storage.createZipStream.mockReturnValue(archiveMock); - await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ stream: archiveMock.stream, }); expect(mocks.logger.warn).toHaveBeenCalledTimes(2); expect(archiveMock.addFile).toHaveBeenCalledTimes(2); - expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, '/data/library/IMG_123.jpg', 'IMG_123.jpg'); - expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, '/data/library/IMG_456.jpg', 'IMG_456.jpg'); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, asset1.originalPath, asset1.originalFileName); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, asset2.originalPath, asset2.originalFileName); }); it('should download an archive', async () => { @@ -85,20 +86,20 @@ describe(DownloadService.name, () => { stream: new Readable(), }; - mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2'])); - mocks.asset.getByIds.mockResolvedValue([ - { ...assetStub.noResizePath, id: 'asset-1' }, - { ...assetStub.noWebpPath, id: 'asset-2' }, - ]); + const asset1 = AssetFactory.create(); + const asset2 = AssetFactory.create(); + + mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id])); + mocks.asset.getByIds.mockResolvedValue([asset1, asset2]); mocks.storage.createZipStream.mockReturnValue(archiveMock); - await expect(sut.downloadArchive(authStub.admin, { assetIds: ['asset-1', 'asset-2'] })).resolves.toEqual({ + await expect(sut.downloadArchive(authStub.admin, { assetIds: [asset1.id, asset2.id] })).resolves.toEqual({ stream: archiveMock.stream, }); expect(archiveMock.addFile).toHaveBeenCalledTimes(2); - expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, '/data/library/IMG_123.jpg', 'IMG_123.jpg'); - expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, '/data/library/IMG_456.jpg', 'IMG_456.jpg'); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(1, asset1.originalPath, asset1.originalFileName); + expect(archiveMock.addFile).toHaveBeenNthCalledWith(2, asset2.originalPath, asset2.originalFileName); }); it('should handle duplicate file names', async () => { diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index 75812e2fcb..bee1ed67d9 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -18,6 +18,7 @@ import { } from 'src/enum'; import { MediaService } from 'src/services/media.service'; import { JobCounts, RawImageInfo } from 'src/types'; +import { AssetFactory } from 'test/factories/asset.factory'; import { assetStub, previewFile } from 'test/fixtures/asset.stub'; import { faceStub } from 'test/fixtures/face.stub'; import { probeStub } from 'test/fixtures/media.stub'; @@ -139,33 +140,30 @@ describe(MediaService.name, () => { expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); - it('should queue all assets with missing webp path', async () => { - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.noWebpPath])); + it('should queue all assets with missing preview', async () => { + const asset = AssetFactory.create(); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { - name: JobName.AssetGenerateThumbnails, - data: { id: assetStub.image.id }, - }, + { name: JobName.AssetGenerateThumbnails, data: { id: asset.id } }, ]); - expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); }); it('should queue all assets with missing thumbhash', async () => { - mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.noThumbhash])); + const asset = AssetFactory.from({ thumbhash: null }) + .files([AssetFileType.Thumbnail, AssetFileType.Preview]) + .build(); + mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset])); mocks.person.getAll.mockReturnValue(makeStream()); await sut.handleQueueGenerateThumbnails({ force: false }); expect(mocks.assetJob.streamForThumbnailJob).toHaveBeenCalledWith({ force: false, fullsizeEnabled: false }); expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { - name: JobName.AssetGenerateThumbnails, - data: { id: assetStub.image.id }, - }, + { name: JobName.AssetGenerateThumbnails, data: { id: asset.id } }, ]); expect(mocks.person.getAll).toHaveBeenCalledWith({ thumbnailPath: '' }); @@ -1052,12 +1050,19 @@ describe(MediaService.name, () => { mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif); + const asset = AssetFactory.from({ originalFileName: 'image.hif' }) + .exif({ + fileSizeInByte: 5000, + profileDescription: 'Adobe RGB', + bitsPerSample: 14, + }) + .build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageHif.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, }); @@ -1107,12 +1112,19 @@ describe(MediaService.name, () => { mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); mocks.media.copyTagGroup.mockResolvedValue(true); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.panoramaTif); + const asset = AssetFactory.from({ originalFileName: 'panorama.tif' }) + .exif({ + fileSizeInByte: 5000, + projectionType: 'EQUIRECTANGULAR', + }) + .build(); + + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.panoramaTif.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.Srgb, orientation: undefined, processInvalidImages: false, @@ -1135,11 +1147,7 @@ describe(MediaService.name, () => { ); expect(mocks.media.copyTagGroup).toHaveBeenCalledTimes(2); - expect(mocks.media.copyTagGroup).toHaveBeenCalledWith( - 'XMP-GPano', - assetStub.panoramaTif.originalPath, - expect.any(String), - ); + expect(mocks.media.copyTagGroup).toHaveBeenCalledWith('XMP-GPano', asset.originalPath, expect.any(String)); }); it('should respect encoding options when generating full-size preview', async () => { @@ -1149,12 +1157,19 @@ describe(MediaService.name, () => { mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); // HEIF/HIF image taken by cameras are not web-friendly, only has limited support on Safari. - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif); + const asset = AssetFactory.from({ originalFileName: 'image.hif' }) + .exif({ + fileSizeInByte: 5000, + profileDescription: 'Adobe RGB', + bitsPerSample: 14, + }) + .build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); await sut.handleGenerateThumbnails({ id: assetStub.image.id }); expect(mocks.media.decodeImage).toHaveBeenCalledOnce(); - expect(mocks.media.decodeImage).toHaveBeenCalledWith(assetStub.imageHif.originalPath, { + expect(mocks.media.decodeImage).toHaveBeenCalledWith(asset.originalPath, { colorspace: Colorspace.P3, processInvalidImages: false, }); @@ -1181,9 +1196,16 @@ describe(MediaService.name, () => { }); mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg }); mocks.media.getImageDimensions.mockResolvedValue({ width: 3840, height: 2160 }); - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.imageHif); + const asset = AssetFactory.from({ originalFileName: 'image.hif' }) + .exif({ + fileSizeInByte: 5000, + profileDescription: 'Adobe RGB', + bitsPerSample: 14, + }) + .build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); - await sut.handleGenerateThumbnails({ id: assetStub.image.id }); + await sut.handleGenerateThumbnails({ id: asset.id }); expect(mocks.media.generateThumbnail).toHaveBeenCalledTimes(3); expect(mocks.media.generateThumbnail).toHaveBeenCalledWith( @@ -1263,30 +1285,25 @@ describe(MediaService.name, () => { }); it('should clean up edited files if an asset has no edits', async () => { - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ - ...assetStub.withoutEdits, - }); + const asset = AssetFactory.from({ thumbhash: factory.buffer() }) + .exif() + .files([ + { type: AssetFileType.Preview, path: 'edited1.jpg', isEdited: true }, + { type: AssetFileType.Thumbnail, path: 'edited2.jpg', isEdited: true }, + { type: AssetFileType.FullSize, path: 'edited3.jpg', isEdited: true }, + ]) + .build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + + const status = await sut.handleAssetEditThumbnailGeneration({ id: asset.id }); - const status = await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); expect(mocks.job.queue).toHaveBeenCalledWith({ name: JobName.FileDelete, data: { - files: expect.arrayContaining([ - '/uploads/user-id/fullsize/path_edited.jpg', - '/uploads/user-id/preview/path_edited.jpg', - '/uploads/user-id/thumbnail/path_edited.jpg', - ]), + files: expect.arrayContaining(['edited1.jpg', 'edited2.jpg', 'edited3.jpg']), }, }); - expect(mocks.asset.deleteFiles).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ path: '/uploads/user-id/preview/path_edited.jpg' }), - expect.objectContaining({ path: '/uploads/user-id/thumbnail/path_edited.jpg' }), - expect.objectContaining({ path: '/uploads/user-id/fullsize/path_edited.jpg' }), - ]), - ); - expect(status).toBe(JobStatus.Success); expect(mocks.media.generateThumbnail).not.toHaveBeenCalled(); expect(mocks.asset.upsertFiles).not.toHaveBeenCalled(); @@ -1320,11 +1337,9 @@ describe(MediaService.name, () => { }); it('should generate the original thumbhash if no edits exist', async () => { - mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ - ...assetStub.withoutEdits, - }); - const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); - mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); + const asset = AssetFactory.from().exif().build(); + mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset); + mocks.media.generateThumbhash.mockResolvedValue(factory.buffer()); await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id, source: 'upload' }); @@ -1335,18 +1350,14 @@ describe(MediaService.name, () => { mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue({ ...assetStub.withCropEdit, }); - const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8'); + const thumbhashBuffer = factory.buffer(); mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer); mocks.person.getFaces.mockResolvedValue([]); mocks.ocr.getByAssetId.mockResolvedValue([]); await sut.handleAssetEditThumbnailGeneration({ id: assetStub.image.id }); - expect(mocks.asset.update).toHaveBeenCalledWith( - expect.objectContaining({ - thumbhash: thumbhashBuffer, - }), - ); + expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ thumbhash: thumbhashBuffer })); }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 00bd0305dd..2c4005f436 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -185,7 +185,7 @@ export class MediaService extends BaseService { const generated = await this.generateEditedThumbnails(asset, config); await this.syncFiles( - asset.files.filter((asset) => asset.isEdited), + asset.files.filter((file) => file.isEdited), generated?.files ?? [], ); diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index eda4e1a063..d94de020e0 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -16,6 +16,7 @@ import { } from 'src/enum'; import { ImmichTags } from 'src/repositories/metadata.repository'; import { firstDateTime, MetadataService } from 'src/services/metadata.service'; +import { AssetFactory } from 'test/factories/asset.factory'; import { assetStub } from 'test/fixtures/asset.stub'; import { fileStub } from 'test/fixtures/file.stub'; import { probeStub } from 'test/fixtures/media.stub'; @@ -24,13 +25,6 @@ import { tagStub } from 'test/fixtures/tag.stub'; import { factory } from 'test/small.factory'; import { makeStream, newTestService, ServiceMocks } from 'test/utils'; -const removeNonSidecarFiles = (asset: any) => { - return { - ...asset, - files: asset.files.filter((file: any) => file.type === AssetFileType.Sidecar), - }; -}; - const forSidecarJob = ( asset: { id?: string; @@ -182,17 +176,18 @@ describe(MetadataService.name, () => { it('should handle a date in a sidecar file', async () => { const originalDate = new Date('2023-11-21T16:13:17.517Z'); const sidecarDate = new Date('2022-01-01T00:00:00.000Z'); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar)); + const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.sidecar.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith(expect.objectContaining({ dateTimeOriginal: sidecarDate }), { lockedPropertiesBehavior: 'skip', }); expect(mocks.asset.update).toHaveBeenCalledWith( expect.objectContaining({ - id: assetStub.image.id, + id: asset.id, duration: null, fileCreatedAt: sidecarDate, localDateTime: sidecarDate, @@ -203,7 +198,8 @@ describe(MetadataService.name, () => { it('should take the file modification date when missing exif and earlier than creation date', async () => { const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z'); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: fileModifiedAt, @@ -212,14 +208,14 @@ describe(MetadataService.name, () => { } as Stats); mockReadTags(); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ dateTimeOriginal: fileModifiedAt }), { lockedPropertiesBehavior: 'skip' }, ); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.image.id, + id: asset.id, duration: null, fileCreatedAt: fileModifiedAt, fileModifiedAt, @@ -232,7 +228,8 @@ describe(MetadataService.name, () => { it('should take the file creation date when missing exif and earlier than modification date', async () => { const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z'); const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z'); - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.storage.stat.mockResolvedValue({ size: 123_456, mtime: fileModifiedAt, @@ -241,14 +238,14 @@ describe(MetadataService.name, () => { } as Stats); mockReadTags(); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ dateTimeOriginal: fileCreatedAt }), { lockedPropertiesBehavior: 'skip' }, ); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.image.id, + id: asset.id, duration: null, fileCreatedAt, fileModifiedAt, @@ -260,10 +257,11 @@ describe(MetadataService.name, () => { it('should determine dateTimeOriginal regardless of the server time zone', async () => { process.env.TZ = 'America/Los_Angeles'; - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.sidecar)); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ dateTimeOriginal: new Date('2022-01-01T00:00:00.000Z'), @@ -279,16 +277,15 @@ describe(MetadataService.name, () => { }); it('should handle lists of numbers', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.storage.stat.mockResolvedValue({ size: 123_456, - mtime: assetStub.image.fileModifiedAt, - mtimeMs: assetStub.image.fileModifiedAt.valueOf(), - birthtimeMs: assetStub.image.fileCreatedAt.valueOf(), + mtime: asset.fileModifiedAt, + mtimeMs: asset.fileModifiedAt.valueOf(), + birthtimeMs: asset.fileCreatedAt.valueOf(), } as Stats); - mockReadTags({ - ISO: [160], - }); + mockReadTags({ ISO: [160] }); await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); @@ -296,11 +293,11 @@ describe(MetadataService.name, () => { lockedPropertiesBehavior: 'skip', }); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.image.id, + id: asset.id, duration: null, - fileCreatedAt: assetStub.image.fileCreatedAt, - fileModifiedAt: assetStub.image.fileCreatedAt, - localDateTime: assetStub.image.fileCreatedAt, + fileCreatedAt: asset.fileCreatedAt, + fileModifiedAt: asset.fileCreatedAt, + localDateTime: asset.fileCreatedAt, width: null, height: null, }); @@ -308,77 +305,77 @@ describe(MetadataService.name, () => { it('should not delete latituide and longitude without reverse geocode', async () => { // regression test for issue 17511 - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.withLocation); + const asset = AssetFactory.from().exif().build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: false } }); mocks.storage.stat.mockResolvedValue({ size: 123_456, - mtime: assetStub.withLocation.fileModifiedAt, - mtimeMs: assetStub.withLocation.fileModifiedAt.valueOf(), - birthtimeMs: assetStub.withLocation.fileCreatedAt.valueOf(), + mtime: asset.fileModifiedAt, + mtimeMs: asset.fileModifiedAt.valueOf(), + birthtimeMs: asset.fileCreatedAt.valueOf(), } as Stats); mockReadTags({ - GPSLatitude: assetStub.withLocation.exifInfo!.latitude!, - GPSLongitude: assetStub.withLocation.exifInfo!.longitude!, + GPSLatitude: asset.exifInfo.latitude!, + GPSLongitude: asset.exifInfo.longitude!, }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ city: null, state: null, country: null }), { lockedPropertiesBehavior: 'skip' }, ); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.withLocation.id, + id: asset.id, duration: null, - fileCreatedAt: assetStub.withLocation.fileCreatedAt, - fileModifiedAt: assetStub.withLocation.fileModifiedAt, - localDateTime: new Date('2023-02-22T05:06:29.716Z'), + fileCreatedAt: asset.fileCreatedAt, + fileModifiedAt: asset.fileModifiedAt, + localDateTime: asset.localDateTime, width: null, height: null, }); }); it('should apply reverse geocoding', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.withLocation)); + const asset = AssetFactory.from().exif({ latitude: 10, longitude: 20 }).build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } }); mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' }); mocks.storage.stat.mockResolvedValue({ size: 123_456, - mtime: assetStub.withLocation.fileModifiedAt, - mtimeMs: assetStub.withLocation.fileModifiedAt.valueOf(), - birthtimeMs: assetStub.withLocation.fileCreatedAt.valueOf(), + mtime: asset.fileModifiedAt, + mtimeMs: asset.fileModifiedAt.valueOf(), + birthtimeMs: asset.fileCreatedAt.valueOf(), } as Stats); - mockReadTags({ - GPSLatitude: assetStub.withLocation.exifInfo!.latitude!, - GPSLongitude: assetStub.withLocation.exifInfo!.longitude!, - }); + mockReadTags({ GPSLatitude: 10, GPSLongitude: 20 }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ city: 'City', state: 'State', country: 'Country' }), { lockedPropertiesBehavior: 'skip' }, ); expect(mocks.asset.update).toHaveBeenCalledWith({ - id: assetStub.withLocation.id, + id: asset.id, duration: null, - fileCreatedAt: assetStub.withLocation.fileCreatedAt, - fileModifiedAt: assetStub.withLocation.fileModifiedAt, - localDateTime: new Date('2023-02-22T05:06:29.716Z'), + fileCreatedAt: asset.fileCreatedAt, + fileModifiedAt: asset.fileModifiedAt, + localDateTime: asset.localDateTime, width: null, height: null, }); }); it('should discard latitude and longitude on null island', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.withLocation)); + const asset = AssetFactory.create(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); mockReadTags({ GPSLatitude: 0, GPSLongitude: 0, }); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.image.id); + await sut.handleMetadataExtraction({ id: asset.id }); + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id); expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect.objectContaining({ latitude: null, longitude: null }), { lockedPropertiesBehavior: 'skip' }, @@ -386,19 +383,25 @@ describe(MetadataService.name, () => { }); it('should extract tags from TagsList', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) }); + const asset = AssetFactory.from() + .exif({ tags: ['Parent'] }) + .build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset); mockReadTags({ TagsList: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined }); }); it('should extract hierarchy from TagsList', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) }); + const asset = AssetFactory.from() + .exif({ tags: ['Parent/Child'] }) + .build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset); mockReadTags({ TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); @@ -406,135 +409,147 @@ describe(MetadataService.name, () => { await sut.handleMetadataExtraction({ id: assetStub.image.id }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent', parentId: undefined, }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent/Child', parentId: 'tag-parent', }); }); it('should extract tags from Keywords as a string', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) }); + const asset = AssetFactory.from() + .exif({ tags: ['Parent'] }) + .build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset); mockReadTags({ Keywords: 'Parent' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined }); }); it('should extract tags from Keywords as a list', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) }); + const asset = AssetFactory.from() + .exif({ tags: ['Parent'] }) + .build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset); mockReadTags({ Keywords: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined }); }); it('should extract tags from Keywords as a list with a number', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ - ...factory.asset(), - exifInfo: factory.exif({ tags: ['Parent', '2024'] }), - }); + const asset = AssetFactory.from() + .exif({ tags: ['Parent', '2024'] }) + .build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset); mockReadTags({ Keywords: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); - expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); - expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: '2024', parent: undefined }); }); it('should extract hierarchal tags from Keywords', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) }); + const asset = AssetFactory.from() + .exif({ tags: ['Parent/Child'] }) + .build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset); mockReadTags({ Keywords: 'Parent/Child' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); - + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent', parentId: undefined, }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent/Child', parentId: 'tag-parent', }); }); it('should ignore Keywords when TagsList is present', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ - ...factory.asset(), - exifInfo: factory.exif({ tags: ['Parent/Child', 'Child'] }), - }); + const asset = AssetFactory.from() + .exif({ tags: ['Parent/Child', 'Child'] }) + .build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset); mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent', parentId: undefined, }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent/Child', parentId: 'tag-parent', }); }); it('should extract hierarchy from HierarchicalSubject', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ - ...factory.asset(), - exifInfo: factory.exif({ tags: ['Parent/Child', 'TagA'] }), - }); + const asset = AssetFactory.from() + .exif({ tags: ['Parent/Child', 'TagA'] }) + .build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset); mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); - await sut.handleMetadataExtraction({ id: assetStub.image.id }); + await sut.handleMetadataExtraction({ id: asset.id }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(1, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent', parentId: undefined, }); expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(2, { - userId: 'user-id', + userId: asset.ownerId, value: 'Parent/Child', parentId: 'tag-parent', }); - expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(3, { userId: 'user-id', value: 'TagA', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenNthCalledWith(3, { + userId: asset.ownerId, + value: 'TagA', + parent: undefined, + }); }); it('should extract tags from HierarchicalSubject as a list with a number', async () => { - mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ - ...factory.asset(), - exifInfo: factory.exif({ tags: ['Parent', '2024'] }), - }); + const asset = AssetFactory.from() + .exif({ tags: ['Parent', '2024'] }) + .build(); + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset); + mocks.asset.getById.mockResolvedValue(asset); mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); await sut.handleMetadataExtraction({ id: assetStub.image.id }); - expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: 'Parent', parent: undefined }); - expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: 'user-id', value: '2024', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: 'Parent', parent: undefined }); + expect(mocks.tag.upsertValue).toHaveBeenCalledWith({ userId: asset.ownerId, value: '2024', parent: undefined }); }); it('should extract ignore / characters in a HierarchicalSubject tag', async () => { @@ -1646,31 +1661,23 @@ describe(MetadataService.name, () => { describe('handleQueueSidecar', () => { it('should queue assets with sidecar files', async () => { - mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([asset])); await sut.handleQueueSidecar({ force: true }); - expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(true); - expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { - name: JobName.SidecarCheck, - data: { id: assetStub.sidecar.id }, - }, - ]); + expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(true); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarCheck, data: { id: asset.id } }]); }); it('should queue assets without sidecar files', async () => { - mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([assetStub.image])); + const asset = AssetFactory.create(); + mocks.assetJob.streamForSidecar.mockReturnValue(makeStream([asset])); await sut.handleQueueSidecar({ force: false }); expect(mocks.assetJob.streamForSidecar).toHaveBeenCalledWith(false); - expect(mocks.job.queueAll).toHaveBeenCalledWith([ - { - name: JobName.SidecarCheck, - data: { id: assetStub.image.id }, - }, - ]); + expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.SidecarCheck, data: { id: asset.id } }]); }); }); diff --git a/server/src/services/stack.service.spec.ts b/server/src/services/stack.service.spec.ts index 5517cf17f8..1dc87f4348 100644 --- a/server/src/services/stack.service.spec.ts +++ b/server/src/services/stack.service.spec.ts @@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common'; import { StackService } from 'src/services/stack.service'; import { assetStub, stackStub } from 'test/fixtures/asset.stub'; import { authStub } from 'test/fixtures/auth.stub'; +import { newUuid } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(StackService.name, () => { @@ -204,9 +205,9 @@ describe(StackService.name, () => { mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id'])); mocks.stack.getForAssetRemoval.mockResolvedValue({ id: null, primaryAssetId: null }); - await expect( - sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: assetStub.imageFrom2015.id }), - ).rejects.toBeInstanceOf(BadRequestException); + await expect(sut.removeAsset(authStub.admin, { id: 'stack-id', assetId: newUuid() })).rejects.toBeInstanceOf( + BadRequestException, + ); expect(mocks.asset.update).not.toHaveBeenCalled(); expect(mocks.event.emit).not.toHaveBeenCalled(); diff --git a/server/test/factories/asset-edit.factory.ts b/server/test/factories/asset-edit.factory.ts new file mode 100644 index 0000000000..e16b0c2e4b --- /dev/null +++ b/server/test/factories/asset-edit.factory.ts @@ -0,0 +1,38 @@ +import { Selectable } from 'kysely'; +import { AssetEditAction } from 'src/dtos/editing.dto'; +import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { build } from 'test/factories/builder.factory'; +import { AssetEditLike, AssetLike, FactoryBuilder } from 'test/factories/types'; +import { newUuid } from 'test/small.factory'; + +export class AssetEditFactory { + private constructor(private readonly value: Selectable) {} + + static create(dto: AssetEditLike = {}) { + return AssetEditFactory.from(dto).build(); + } + + static from(dto: AssetEditLike = {}) { + const id = dto.id ?? newUuid(); + + return new AssetEditFactory({ + id, + assetId: newUuid(), + action: AssetEditAction.Crop, + parameters: { x: 5, y: 6, width: 200, height: 100 }, + sequence: 1, + ...dto, + }); + } + + asset(dto: AssetLike = {}, builder?: FactoryBuilder) { + const asset = build(AssetFactory.from(dto), builder); + this.value.assetId = asset.build().id; + return this; + } + + build() { + return { ...this.value } as Selectable>; + } +} diff --git a/server/test/factories/asset-file.factory.ts b/server/test/factories/asset-file.factory.ts new file mode 100644 index 0000000000..109cd5adc4 --- /dev/null +++ b/server/test/factories/asset-file.factory.ts @@ -0,0 +1,43 @@ +import { Selectable } from 'kysely'; +import { AssetFileType } from 'src/enum'; +import { AssetFileTable } from 'src/schema/tables/asset-file.table'; +import { AssetFactory } from 'test/factories/asset.factory'; +import { build } from 'test/factories/builder.factory'; +import { AssetFileLike, AssetLike, FactoryBuilder } from 'test/factories/types'; +import { newDate, newUuid, newUuidV7 } from 'test/small.factory'; + +export class AssetFileFactory { + private constructor(private readonly value: Selectable) {} + + static create(dto: AssetFileLike = {}) { + return AssetFileFactory.from(dto).build(); + } + + static from(dto: AssetFileLike = {}) { + const id = dto.id ?? newUuid(); + const isEdited = dto.isEdited ?? false; + + return new AssetFileFactory({ + id, + assetId: newUuid(), + createdAt: newDate(), + updatedAt: newDate(), + type: AssetFileType.Thumbnail, + path: `/data/12/34/thumbs/${id.slice(0, 2)}/${id.slice(2, 4)}/${id}${isEdited ? '_edited' : ''}.jpg`, + updateId: newUuidV7(), + isProgressive: false, + isEdited, + ...dto, + }); + } + + asset(dto: AssetLike = {}, builder?: FactoryBuilder) { + const asset = build(AssetFactory.from(dto), builder); + this.value.assetId = asset.build().id; + return this; + } + + build() { + return { ...this.value }; + } +} diff --git a/server/test/factories/asset.factory.ts b/server/test/factories/asset.factory.ts index 41714dbf67..8cbf704abf 100644 --- a/server/test/factories/asset.factory.ts +++ b/server/test/factories/asset.factory.ts @@ -1,15 +1,20 @@ import { Selectable } from 'kysely'; -import { AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; +import { AssetFaceTable } from 'src/schema/tables/asset-face.table'; import { AssetTable } from 'src/schema/tables/asset.table'; +import { AssetEditFactory } from 'test/factories/asset-edit.factory'; import { AssetExifFactory } from 'test/factories/asset-exif.factory'; +import { AssetFileFactory } from 'test/factories/asset-file.factory'; import { build } from 'test/factories/builder.factory'; -import { AssetExifLike, AssetLike, FactoryBuilder, UserLike } from 'test/factories/types'; +import { AssetEditLike, AssetExifLike, AssetFileLike, AssetLike, FactoryBuilder, UserLike } from 'test/factories/types'; import { UserFactory } from 'test/factories/user.factory'; -import { factory, newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory'; +import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory'; export class AssetFactory { - #assetExif?: AssetExifFactory; #owner!: UserFactory; + #assetExif?: AssetExifFactory; + #files: AssetFileFactory[] = []; + #edits: AssetEditFactory[] = []; private constructor(private readonly value: Selectable) { value.ownerId ??= newUuid(); @@ -21,8 +26,12 @@ export class AssetFactory { } static from(dto: AssetLike = {}) { + const id = dto.id ?? newUuid(); + + const originalFileName = dto.originalFileName ?? `IMG_${id}.jpg`; + return new AssetFactory({ - id: factory.uuid(), + id, createdAt: newDate(), updatedAt: newDate(), deletedAt: null, @@ -42,8 +51,8 @@ export class AssetFactory { libraryId: null, livePhotoVideoId: null, localDateTime: newDate(), - originalFileName: 'IMG_123.jpg', - originalPath: `/data/12/34/IMG_123.jpg`, + originalFileName, + originalPath: `/data/library/${originalFileName}`, ownerId: newUuid(), stackId: null, thumbhash: null, @@ -67,13 +76,51 @@ export class AssetFactory { return this; } + edit(dto: AssetEditLike = {}, builder?: FactoryBuilder) { + this.#edits.push(build(AssetEditFactory.from(dto).asset(this.value), builder)); + this.value.isEdited = true; + return this; + } + + file(dto: AssetFileLike = {}, builder?: FactoryBuilder) { + this.#files.push(build(AssetFileFactory.from(dto).asset(this.value), builder)); + return this; + } + + files(dto?: 'edits'): AssetFactory; + files(items: AssetFileLike[], builder?: FactoryBuilder): AssetFactory; + files(items: AssetFileType[], builder?: FactoryBuilder): AssetFactory; + files(dto?: 'edits' | AssetFileLike[] | AssetFileType[], builder?: FactoryBuilder): AssetFactory { + const items: AssetFileLike[] = []; + + if (dto === undefined || dto === 'edits') { + items.push(...Object.values(AssetFileType).map((type) => ({ type }))); + + if (dto === 'edits') { + items.push(...Object.values(AssetFileType).map((type) => ({ type, isEdited: true }))); + } + } else { + for (const item of dto) { + items.push(typeof item === 'string' ? { type: item as AssetFileType } : item); + } + } + for (const item of items) { + this.file(item, builder); + } + + return this; + } + build() { const exif = this.#assetExif?.build(); return { ...this.value, - exifInfo: exif as NonNullable, owner: this.#owner.build(), + exifInfo: exif as NonNullable, + files: this.#files.map((file) => file.build()), + edits: this.#edits.map((edit) => edit.build()), + faces: [] as Selectable[], }; } } diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts index 43ae26c9f4..534e290f59 100644 --- a/server/test/factories/types.ts +++ b/server/test/factories/types.ts @@ -1,7 +1,9 @@ import { Selectable } from 'kysely'; import { AlbumUserTable } from 'src/schema/tables/album-user.table'; import { AlbumTable } from 'src/schema/tables/album.table'; +import { AssetEditTable } from 'src/schema/tables/asset-edit.table'; import { AssetExifTable } from 'src/schema/tables/asset-exif.table'; +import { AssetFileTable } from 'src/schema/tables/asset-file.table'; import { AssetTable } from 'src/schema/tables/asset.table'; import { SharedLinkTable } from 'src/schema/tables/shared-link.table'; import { UserTable } from 'src/schema/tables/user.table'; @@ -10,6 +12,8 @@ export type FactoryBuilder = (builder: T) => R; export type AssetLike = Partial>; export type AssetExifLike = Partial>; +export type AssetEditLike = Partial>; +export type AssetFileLike = Partial>; export type AlbumLike = Partial>; export type AlbumUserLike = Partial>; export type SharedLinkLike = Partial>; diff --git a/server/test/fixtures/album.stub.ts b/server/test/fixtures/album.stub.ts index d36989bbcf..9480fdd5ab 100644 --- a/server/test/fixtures/album.stub.ts +++ b/server/test/fixtures/album.stub.ts @@ -45,56 +45,6 @@ export const albumStub = { order: AssetOrder.Desc, updateId: '42', }), - sharedWithMultiple: Object.freeze({ - id: 'album-3', - albumName: 'Empty album shared with users', - description: '', - ownerId: authStub.admin.user.id, - owner: userStub.admin, - assets: [], - albumThumbnailAsset: null, - albumThumbnailAssetId: null, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - sharedLinks: [], - albumUsers: [ - { - user: userStub.user1, - role: AlbumUserRole.Editor, - }, - { - user: userStub.user2, - role: AlbumUserRole.Editor, - }, - ], - isActivityEnabled: true, - order: AssetOrder.Desc, - updateId: '42', - }), - sharedWithAdmin: Object.freeze({ - id: 'album-3', - albumName: 'Empty album shared with admin', - description: '', - ownerId: authStub.user1.user.id, - owner: userStub.user1, - assets: [], - albumThumbnailAsset: null, - albumThumbnailAssetId: null, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - sharedLinks: [], - albumUsers: [ - { - user: userStub.admin, - role: AlbumUserRole.Editor, - }, - ], - isActivityEnabled: true, - order: AssetOrder.Desc, - updateId: '42', - }), oneAsset: Object.freeze({ id: 'album-4', albumName: 'Album with one asset', @@ -113,24 +63,6 @@ export const albumStub = { order: AssetOrder.Desc, updateId: '42', }), - twoAssets: Object.freeze({ - id: 'album-4a', - albumName: 'Album with two assets', - description: '', - ownerId: authStub.admin.user.id, - owner: userStub.admin, - assets: [assetStub.image, assetStub.withLocation], - albumThumbnailAsset: assetStub.image, - albumThumbnailAssetId: assetStub.image.id, - createdAt: new Date(), - updatedAt: new Date(), - deletedAt: null, - sharedLinks: [], - albumUsers: [], - isActivityEnabled: true, - order: AssetOrder.Desc, - updateId: '42', - }), emptyWithValidThumbnail: Object.freeze({ id: 'album-5', albumName: 'Empty album with valid thumbnail', diff --git a/server/test/fixtures/asset.stub.ts b/server/test/fixtures/asset.stub.ts index 05219c92e7..3c89056f37 100644 --- a/server/test/fixtures/asset.stub.ts +++ b/server/test/fixtures/asset.stub.ts @@ -20,45 +20,8 @@ const fullsizeFile = factory.assetFile({ path: '/uploads/user-id/fullsize/path.webp', }); -const sidecarFileWithExt = factory.assetFile({ - type: AssetFileType.Sidecar, - path: '/original/path.ext.xmp', -}); - -const sidecarFileWithoutExt = factory.assetFile({ - type: AssetFileType.Sidecar, - path: '/original/path.xmp', -}); - -const editedPreviewFile = factory.assetFile({ - type: AssetFileType.Preview, - path: '/uploads/user-id/preview/path_edited.jpg', - isEdited: true, -}); - -const editedThumbnailFile = factory.assetFile({ - type: AssetFileType.Thumbnail, - path: '/uploads/user-id/thumbnail/path_edited.jpg', - isEdited: true, -}); - -const editedFullsizeFile = factory.assetFile({ - type: AssetFileType.FullSize, - path: '/uploads/user-id/fullsize/path_edited.jpg', - isEdited: true, -}); - const files = [fullsizeFile, previewFile, thumbnailFile]; -const editedFiles = [ - fullsizeFile, - previewFile, - thumbnailFile, - editedFullsizeFile, - editedPreviewFile, - editedThumbnailFile, -]; - export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => { return { id: stackId, @@ -132,87 +95,6 @@ export const assetStub = { isEdited: false, }), - noWebpPath: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/data/library/IMG_456.jpg', - files: [previewFile], - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'IMG_456.jpg', - faces: [], - isExternal: false, - exifInfo: { - fileSizeInByte: 123_000, - } as Exif, - deletedAt: null, - duplicateId: null, - isOffline: false, - libraryId: null, - stackId: null, - updateId: '42', - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - - noThumbhash: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.ext', - files, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - thumbhash: null, - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - duration: null, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'asset-id.ext', - faces: [], - deletedAt: null, - duplicateId: null, - isOffline: false, - libraryId: null, - stackId: null, - updateId: '42', - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - primaryImage: Object.freeze({ id: 'primary-asset-id', status: AssetStatus.Active, @@ -526,48 +408,6 @@ export const assetStub = { isEdited: false, }), - imageFrom2015: Object.freeze({ - id: 'asset-id-2015', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2015-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2015-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.ext', - files, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2015-02-23T05:06:29.716Z'), - updatedAt: new Date('2015-02-23T05:06:29.716Z'), - localDateTime: new Date('2015-02-23T05:06:29.716Z'), - isFavorite: true, - isExternal: false, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - updateId: 'foo', - libraryId: null, - stackId: null, - sharedLinks: [], - originalFileName: 'asset-id.ext', - faces: [], - exifInfo: { - fileSizeInByte: 5000, - } as Exif, - deletedAt: null, - duplicateId: null, - isOffline: false, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - video: Object.freeze({ id: 'asset-id', status: AssetStatus.Active, @@ -736,81 +576,6 @@ export const assetStub = { isEdited: false, }), - sidecar: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.ext', - thumbhash: null, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - files: [previewFile, sidecarFileWithExt], - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isExternal: false, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'asset-id.ext', - faces: [], - deletedAt: null, - duplicateId: null, - isOffline: false, - updateId: 'foo', - libraryId: null, - stackId: null, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - - sidecarWithoutExt: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.ext', - thumbhash: null, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - files: [previewFile, sidecarFileWithoutExt], - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isExternal: false, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'asset-id.ext', - faces: [], - deletedAt: null, - duplicateId: null, - isOffline: false, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - hasEncodedVideo: Object.freeze({ id: 'asset-id', status: AssetStatus.Active, @@ -854,46 +619,6 @@ export const assetStub = { isEdited: false, }), - hasFileExtension: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/data/user1/photo.jpg', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - files, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - isExternal: true, - duration: null, - livePhotoVideo: null, - livePhotoVideoId: null, - libraryId: 'library-id', - sharedLinks: [], - originalFileName: 'photo.jpg', - faces: [], - deletedAt: null, - exifInfo: { - fileSizeInByte: 5000, - } as Exif, - duplicateId: null, - isOffline: false, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - imageDng: Object.freeze({ id: 'asset-id', status: AssetStatus.Active, @@ -938,93 +663,6 @@ export const assetStub = { isEdited: false, }), - imageHif: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.hif', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - files, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - duration: null, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'asset-id.hif', - faces: [], - deletedAt: null, - exifInfo: { - fileSizeInByte: 5000, - profileDescription: 'Adobe RGB', - bitsPerSample: 14, - } as Exif, - duplicateId: null, - isOffline: false, - updateId: '42', - libraryId: null, - stackId: null, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - isEdited: false, - }), - - panoramaTif: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.tif', - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - files, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2023-02-23T05:06:29.716Z'), - isFavorite: true, - duration: null, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - sharedLinks: [], - originalFileName: 'asset-id.tif', - faces: [], - deletedAt: null, - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - projectionType: 'EQUIRECTANGULAR', - } as Exif, - duplicateId: null, - isOffline: false, - updateId: '42', - libraryId: null, - stackId: null, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - edits: [], - }), - withCropEdit: Object.freeze({ id: 'asset-id', status: AssetStatus.Active, @@ -1082,52 +720,4 @@ export const assetStub = { ] as AssetEditActionItem[], isEdited: true, }), - - withoutEdits: Object.freeze({ - id: 'asset-id', - status: AssetStatus.Active, - deviceAssetId: 'device-asset-id', - fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), - fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'), - owner: userStub.user1, - ownerId: 'user-id', - deviceId: 'device-id', - originalPath: '/original/path.jpg', - files: editedFiles, - checksum: Buffer.from('file hash', 'utf8'), - type: AssetType.Image, - thumbhash: Buffer.from('blablabla', 'base64'), - encodedVideoPath: null, - createdAt: new Date('2023-02-23T05:06:29.716Z'), - updatedAt: new Date('2023-02-23T05:06:29.716Z'), - localDateTime: new Date('2025-01-01T01:02:03.456Z'), - isFavorite: true, - duration: null, - isExternal: false, - livePhotoVideo: null, - livePhotoVideoId: null, - updateId: 'foo', - libraryId: null, - stackId: null, - sharedLinks: [], - originalFileName: 'asset-id.jpg', - faces: [], - deletedAt: null, - sidecarPath: null, - exifInfo: { - fileSizeInByte: 5000, - exifImageHeight: 3840, - exifImageWidth: 2160, - } as Exif, - duplicateId: null, - isOffline: false, - stack: null, - orientation: '', - projectionType: null, - height: 3840, - width: 2160, - visibility: AssetVisibility.Timeline, - edits: [], - isEdited: false, - }), }; diff --git a/server/test/fixtures/user.stub.ts b/server/test/fixtures/user.stub.ts index 807da5197f..21b49ab899 100644 --- a/server/test/fixtures/user.stub.ts +++ b/server/test/fixtures/user.stub.ts @@ -38,21 +38,4 @@ export const userStub = { quotaSizeInBytes: null, quotaUsageInBytes: 0, }, - user2: { - ...authStub.user2.user, - status: UserStatus.Active, - profileChangedAt: new Date('2021-01-01'), - metadata: [], - name: 'immich_name', - storageLabel: null, - oauthId: '', - shouldChangePassword: false, - avatarColor: null, - profileImagePath: '', - createdAt: new Date('2021-01-01'), - deletedAt: null, - updatedAt: new Date('2021-01-01'), - quotaSizeInBytes: null, - quotaUsageInBytes: 0, - }, }; From 84e30abe5d97863cbfcb72f3ffd62b2f6ed7f728 Mon Sep 17 00:00:00 2001 From: Matthew Momjian <50788000+mmomjian@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:31:57 -0500 Subject: [PATCH 03/40] feat(docs): version policy (#25979) version policy --- docs/docs/install/upgrading.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/docs/install/upgrading.md b/docs/docs/install/upgrading.md index bf788cb680..12e5c9c342 100644 --- a/docs/docs/install/upgrading.md +++ b/docs/docs/install/upgrading.md @@ -26,6 +26,16 @@ docker image prune [breaking]: https://github.com/immich-app/immich/discussions?discussions_q=label%3Achangelog%3Abreaking-change+sort%3Adate_created [releases]: https://github.com/immich-app/immich/releases +## Versioning Policy + +Immich follows [semantic versioning][semver], which tags releases in the format `..`. We intend for breaking changes to be limited to major version releases. +You can configure your Docker image to point to the current major version by using a metatag, such as `:v2`. + +Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich. +Switching back to an earlier version, even within the same minor release tag, is not supported. + +[semver]: https://semver.org/ + ## Migrating to VectorChord :::info From bcea64875fbd753cff64a7b3bce882406dbd67db Mon Sep 17 00:00:00 2001 From: Romo <85642291+romoisverycool@users.noreply.github.com> Date: Sat, 7 Feb 2026 03:56:14 +0100 Subject: [PATCH 04/40] fix: image and video download complete notification shows "file_name" (#25975) * fix: image and video download complete notification shows "file_name" * fix lint --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/utils/bootstrap.dart | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/mobile/lib/utils/bootstrap.dart b/mobile/lib/utils/bootstrap.dart index 84a9ab52e1..d63a92ba37 100644 --- a/mobile/lib/utils/bootstrap.dart +++ b/mobile/lib/utils/bootstrap.dart @@ -27,19 +27,17 @@ import 'package:isar/isar.dart'; import 'package:path_provider/path_provider.dart'; void configureFileDownloaderNotifications() { - final fileName = 'file_name'.t(args: {'file_name': '{filename}'}); - FileDownloader().configureNotificationForGroup( kDownloadGroupImage, - running: TaskNotification('downloading_media'.t(), fileName), - complete: TaskNotification('download_finished'.t(), fileName), + running: TaskNotification('downloading_media'.t(), '${'file_name_text'.t()}: {filename}'), + complete: TaskNotification('download_finished'.t(), '${'file_name_text'.t()}: {filename}'), progressBar: true, ); FileDownloader().configureNotificationForGroup( kDownloadGroupVideo, - running: TaskNotification('downloading_media'.t(), fileName), - complete: TaskNotification('download_finished'.t(), fileName), + running: TaskNotification('downloading_media'.t(), '${'file_name_text'.t()}: {filename}'), + complete: TaskNotification('download_finished'.t(), '${'file_name_text'.t()}: {filename}'), progressBar: true, ); From 57483a1e7fb6d9f6765a638bb55140cb06fd0d96 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Sat, 7 Feb 2026 10:10:17 +0530 Subject: [PATCH 05/40] fix: user profile refetched each time on opening app dialog (#25992) fix: user profile on opening app dialog Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- mobile/lib/widgets/common/user_circle_avatar.dart | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/mobile/lib/widgets/common/user_circle_avatar.dart b/mobile/lib/widgets/common/user_circle_avatar.dart index 0e6d6761e3..352d686e7c 100644 --- a/mobile/lib/widgets/common/user_circle_avatar.dart +++ b/mobile/lib/widgets/common/user_circle_avatar.dart @@ -1,5 +1,3 @@ -import 'dart:math'; - import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; @@ -20,7 +18,7 @@ class UserCircleAvatar extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final userAvatarColor = user.avatarColor.toColor(); final profileImageUrl = - '${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${Random().nextInt(1024)}'; + '${Store.get(StoreKey.serverEndpoint)}/users/${user.id}/profile-image?d=${user.profileChangedAt.millisecondsSinceEpoch}'; final textIcon = DefaultTextStyle( style: TextStyle( From 354dd3cc3c110150ba4e22ec35a75cb44ea38eb5 Mon Sep 17 00:00:00 2001 From: Luis Nachtigall <31982496+LeLunZ@users.noreply.github.com> Date: Sat, 7 Feb 2026 05:41:37 +0100 Subject: [PATCH 06/40] feat(mobile): enhance album sorting functionality with order handling (#24816) * feat: enhance album sorting functionality with effective order handling * mobile: formatting * test: align album sorting order in unit tests with defaultSortOrder * test(mobile): add reverse order validation for album sorting * chore(PR): remove OppositeSortOrder Extension and move it directly into SortOrder enum * refactor: return sorted list directly in album sorting function * refactor: remove sort_order_extensions.dart --- mobile/lib/constants/enums.dart | 9 +++++++- .../domain/services/remote_album.service.dart | 7 ++++-- .../widgets/album/album_selector.widget.dart | 8 +++++-- .../album/album_sort_by_options.provider.dart | 18 +++++++++------ .../domain/services/album.service_test.dart | 22 ++++++++++++++----- 5 files changed, 47 insertions(+), 17 deletions(-) diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 26c223afad..350f6b80fa 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -1,4 +1,11 @@ -enum SortOrder { asc, desc } +enum SortOrder { + asc, + desc; + + SortOrder reverse() { + return this == SortOrder.asc ? SortOrder.desc : SortOrder.asc; + } +} enum TextSearchType { context, filename, description, ocr } diff --git a/mobile/lib/domain/services/remote_album.service.dart b/mobile/lib/domain/services/remote_album.service.dart index 68c72255b0..0cf3f3e1c1 100644 --- a/mobile/lib/domain/services/remote_album.service.dart +++ b/mobile/lib/domain/services/remote_album.service.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:collection/collection.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/user.model.dart'; @@ -36,6 +37,7 @@ class RemoteAlbumService { AlbumSortMode sortMode, { bool isReverse = false, }) async { + // list of albums sorted ascendingly according to the selected sort mode final List sorted = switch (sortMode) { AlbumSortMode.created => albums.sortedBy((album) => album.createdAt), AlbumSortMode.title => albums.sortedBy((album) => album.name), @@ -44,8 +46,9 @@ class RemoteAlbumService { AlbumSortMode.mostRecent => await _sortByNewestAsset(albums), AlbumSortMode.mostOldest => await _sortByOldestAsset(albums), }; + final effectiveOrder = isReverse ? sortMode.defaultOrder.reverse() : sortMode.defaultOrder; - return (isReverse ? sorted.reversed : sorted).toList(); + return (effectiveOrder == SortOrder.asc ? sorted : sorted.reversed).toList(); } List searchAlbums( @@ -209,6 +212,6 @@ class RemoteAlbumService { return aDate.compareTo(bDate); }); - return sorted.reversed.toList(); + return sorted; } } diff --git a/mobile/lib/presentation/widgets/album/album_selector.widget.dart b/mobile/lib/presentation/widgets/album/album_selector.widget.dart index e35fbf7433..8f3cee9215 100644 --- a/mobile/lib/presentation/widgets/album/album_selector.widget.dart +++ b/mobile/lib/presentation/widgets/album/album_selector.widget.dart @@ -5,6 +5,7 @@ import 'package:auto_route/auto_route.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; @@ -281,6 +282,8 @@ class _SortButtonState extends ConsumerState<_SortButton> { setState(() { albumSortOption = sortMode; isSorting = true; + // reset sort order to default state when switching option + albumSortIsReverse = false; }); } @@ -293,6 +296,7 @@ class _SortButtonState extends ConsumerState<_SortButton> { @override Widget build(BuildContext context) { + final effectiveOrder = albumSortOption.effectiveOrder(albumSortIsReverse); return MenuAnchor( controller: widget.controller, style: MenuStyle( @@ -307,7 +311,7 @@ class _SortButtonState extends ConsumerState<_SortButton> { .map( (sortMode) => MenuItemButton( leadingIcon: albumSortOption == sortMode - ? albumSortIsReverse + ? effectiveOrder == SortOrder.desc ? Icon( Icons.keyboard_arrow_down, color: albumSortOption == sortMode @@ -355,7 +359,7 @@ class _SortButtonState extends ConsumerState<_SortButton> { children: [ Padding( padding: const EdgeInsets.only(right: 5), - child: albumSortIsReverse + child: effectiveOrder == SortOrder.desc ? Icon(Icons.keyboard_arrow_down, color: context.colorScheme.onSurface) : Icon(Icons.keyboard_arrow_up_rounded, color: context.colorScheme.onSurface), ), diff --git a/mobile/lib/providers/album/album_sort_by_options.provider.dart b/mobile/lib/providers/album/album_sort_by_options.provider.dart index 3dd09f1282..c969dbd37d 100644 --- a/mobile/lib/providers/album/album_sort_by_options.provider.dart +++ b/mobile/lib/providers/album/album_sort_by_options.provider.dart @@ -1,4 +1,5 @@ import 'package:collection/collection.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/providers/app_settings.provider.dart'; import 'package:immich_mobile/services/app_settings.service.dart'; import 'package:immich_mobile/entities/album.entity.dart'; @@ -73,18 +74,21 @@ class _AlbumSortHandlers { // Store index allows us to re-arrange the values without affecting the saved prefs enum AlbumSortMode { - title(1, "library_page_sort_title", _AlbumSortHandlers.title), - assetCount(4, "library_page_sort_asset_count", _AlbumSortHandlers.assetCount), - lastModified(3, "library_page_sort_last_modified", _AlbumSortHandlers.lastModified), - created(0, "library_page_sort_created", _AlbumSortHandlers.created), - mostRecent(2, "sort_recent", _AlbumSortHandlers.mostRecent), - mostOldest(5, "sort_oldest", _AlbumSortHandlers.mostOldest); + title(1, "library_page_sort_title", _AlbumSortHandlers.title, SortOrder.asc), + assetCount(4, "library_page_sort_asset_count", _AlbumSortHandlers.assetCount, SortOrder.desc), + lastModified(3, "library_page_sort_last_modified", _AlbumSortHandlers.lastModified, SortOrder.desc), + created(0, "library_page_sort_created", _AlbumSortHandlers.created, SortOrder.desc), + mostRecent(2, "sort_recent", _AlbumSortHandlers.mostRecent, SortOrder.desc), + mostOldest(5, "sort_oldest", _AlbumSortHandlers.mostOldest, SortOrder.asc); final int storeIndex; final String label; final AlbumSortFn sortFn; + final SortOrder defaultOrder; - const AlbumSortMode(this.storeIndex, this.label, this.sortFn); + const AlbumSortMode(this.storeIndex, this.label, this.sortFn, this.defaultOrder); + + SortOrder effectiveOrder(bool isReverse) => isReverse ? defaultOrder.reverse() : defaultOrder; } @riverpod diff --git a/mobile/test/domain/services/album.service_test.dart b/mobile/test/domain/services/album.service_test.dart index b86819536d..1a36a811c3 100644 --- a/mobile/test/domain/services/album.service_test.dart +++ b/mobile/test/domain/services/album.service_test.dart @@ -85,35 +85,47 @@ void main() { final albums = [albumB, albumA]; final result = await sut.sortAlbums(albums, AlbumSortMode.created); - expect(result, [albumA, albumB]); + expect(result, [albumB, albumA]); }); test('should sort correctly based on updatedAt', () async { final albums = [albumB, albumA]; final result = await sut.sortAlbums(albums, AlbumSortMode.lastModified); - expect(result, [albumA, albumB]); + expect(result, [albumB, albumA]); }); test('should sort correctly based on assetCount', () async { final albums = [albumB, albumA]; final result = await sut.sortAlbums(albums, AlbumSortMode.assetCount); - expect(result, [albumA, albumB]); + expect(result, [albumB, albumA]); }); test('should sort correctly based on newestAssetTimestamp', () async { final albums = [albumB, albumA]; final result = await sut.sortAlbums(albums, AlbumSortMode.mostRecent); - expect(result, [albumA, albumB]); + expect(result, [albumB, albumA]); }); test('should sort correctly based on oldestAssetTimestamp', () async { final albums = [albumB, albumA]; final result = await sut.sortAlbums(albums, AlbumSortMode.mostOldest); - expect(result, [albumB, albumA]); + expect(result, [albumA, albumB]); + }); + + test('should flip order when isReverse is true for all modes', () async { + final albums = [albumB, albumA]; + + for (final mode in AlbumSortMode.values) { + final normal = await sut.sortAlbums(albums, mode, isReverse: false); + final reversed = await sut.sortAlbums(albums, mode, isReverse: true); + + // reversed should be the exact inverse of normal + expect(reversed, normal.reversed.toList(), reason: 'Mode: $mode'); + } }); }); } From 5b705cb72323e37d81182d57a190df3e8f1885cb Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sun, 8 Feb 2026 18:03:17 +0100 Subject: [PATCH 07/40] fix: improve albums page load time on firefox (#26025) --- pnpm-lock.yaml | 10 +++++----- web/package.json | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2030bbc08c..b0a78ab795 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -741,8 +741,8 @@ importers: specifier: file:../open-api/typescript-sdk version: link:../open-api/typescript-sdk '@immich/ui': - specifier: ^0.61.3 - version: 0.61.3(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0) + specifier: ^0.61.4 + version: 0.61.4(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0) '@mapbox/mapbox-gl-rtl-text': specifier: 0.2.3 version: 0.2.3(mapbox-gl@1.13.3) @@ -3131,8 +3131,8 @@ packages: peerDependencies: svelte: ^5.0.0 - '@immich/ui@0.61.3': - resolution: {integrity: sha512-9cz/7kc/CSmJ37gH5nIZNiHxw5OlBCGbdlSGkCOtaMJ458wmcdUFVmF5arjGioaOa4NMwseOVyln7rMhkNU7ww==} + '@immich/ui@0.61.4': + resolution: {integrity: sha512-32nrY7GW6BdBQ12ZI/E4VgrgY40Yn2K31vSO6GPiOvmNgt8h3s3TSKUXbh7pY4yJgfnr7f2QL2EYRV9KkjRybQ==} peerDependencies: svelte: ^5.0.0 @@ -15762,7 +15762,7 @@ snapshots: node-emoji: 2.2.0 svelte: 5.48.0 - '@immich/ui@0.61.3(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)': + '@immich/ui@0.61.4(@sveltejs/kit@2.49.5(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.4(svelte@5.48.0)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)(typescript@5.9.3)(vite@7.3.1(@types/node@25.0.9)(jiti@2.6.1)(lightningcss@1.30.2)(sass@1.97.1)(terser@5.44.1)(tsx@4.21.0)(yaml@2.8.2)))(svelte@5.48.0)': dependencies: '@immich/svelte-markdown-preprocess': 0.2.1(svelte@5.48.0) '@internationalized/date': 3.10.0 diff --git a/web/package.json b/web/package.json index 971becec09..7af0474ba1 100644 --- a/web/package.json +++ b/web/package.json @@ -27,7 +27,7 @@ "@formatjs/icu-messageformat-parser": "^3.0.0", "@immich/justified-layout-wasm": "^0.4.3", "@immich/sdk": "file:../open-api/typescript-sdk", - "@immich/ui": "^0.61.3", + "@immich/ui": "^0.61.4", "@mapbox/mapbox-gl-rtl-text": "0.2.3", "@mdi/js": "^7.4.47", "@photo-sphere-viewer/core": "^5.14.0", From b524d7b6fd91d9202cd8e808f7ee2605e07d3b08 Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sun, 8 Feb 2026 18:03:59 +0100 Subject: [PATCH 08/40] fix: reduce queue graph jitter and include paused count (#26023) fix: reduce queue graph jitter and show paused count --- web/src/lib/components/QueueGraph.svelte | 3 ++- web/src/routes/admin/queues/[name]/+page.svelte | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/web/src/lib/components/QueueGraph.svelte b/web/src/lib/components/QueueGraph.svelte index 7dc553c29a..f2a23216df 100644 --- a/web/src/lib/components/QueueGraph.svelte +++ b/web/src/lib/components/QueueGraph.svelte @@ -75,6 +75,7 @@ show: false, }, width: 2, + pxAlign: 0, }; const options: uPlot.Options = { @@ -91,7 +92,7 @@ width: 200, height: 200, ms: 1, - pxAlign: true, + pxAlign: 0, scales: { y: { distr: 1, diff --git a/web/src/routes/admin/queues/[name]/+page.svelte b/web/src/routes/admin/queues/[name]/+page.svelte index 482076d146..0b0e741f97 100644 --- a/web/src/routes/admin/queues/[name]/+page.svelte +++ b/web/src/routes/admin/queues/[name]/+page.svelte @@ -53,7 +53,7 @@
{$t('active_count', { values: { count: queue.statistics.active } })} - {$t('waiting_count', { values: { count: queue.statistics.waiting } })} + {$t('waiting_count', { values: { count: queue.statistics.waiting + queue.statistics.paused } })} {#if queue.statistics.failed > 0} {$t('failed_count', { values: { count: queue.statistics.failed } })} {/if} From 00486cbcc81d319a69a88afa650ef751bfc3a353 Mon Sep 17 00:00:00 2001 From: Yaros Date: Sun, 8 Feb 2026 18:15:09 +0100 Subject: [PATCH 09/40] fix(web): toast fixed location (#25966) fix: toast fixed location --- web/src/routes/+layout.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/routes/+layout.svelte b/web/src/routes/+layout.svelte index 4bea4febfe..64ad0abc20 100644 --- a/web/src/routes/+layout.svelte +++ b/web/src/routes/+layout.svelte @@ -60,7 +60,7 @@ return new URL(page.url.pathname + page.url.search, 'https://my.immich.app'); }; - toastManager.setOptions({ class: 'top-16' }); + toastManager.setOptions({ class: 'top-16 fixed' }); onMount(() => { const element = document.querySelector('#stencil'); From 59c4a49ffd111fce40386bf9c04b88da7ecca33e Mon Sep 17 00:00:00 2001 From: Michel Heusschen <59014050+michelheusschen@users.noreply.github.com> Date: Sun, 8 Feb 2026 18:19:35 +0100 Subject: [PATCH 10/40] fix: scroll jump when opening show & hide people (#25932) --- .../manage-people-visibility.svelte | 124 +++++++++--------- web/src/routes/(user)/people/+page.svelte | 7 +- 2 files changed, 65 insertions(+), 66 deletions(-) diff --git a/web/src/lib/components/faces-page/manage-people-visibility.svelte b/web/src/lib/components/faces-page/manage-people-visibility.svelte index 77696b5983..619a908507 100644 --- a/web/src/lib/components/faces-page/manage-people-visibility.svelte +++ b/web/src/lib/components/faces-page/manage-people-visibility.svelte @@ -105,72 +105,74 @@ -
-
- -
-

{$t('show_and_hide_people')}

-

({totalPeopleCount.toLocaleString($locale)})

-
-
-
-
+
+
+
overrides.clear()} - /> - +
+

{$t('show_and_hide_people')}

+

({totalPeopleCount.toLocaleString($locale)})

+
- -
-
- -
- - {#snippet children({ person })} - {@const hidden = overrides.get(person.id) ?? person.isHidden} - - {/snippet} - + +
+ +
+
+ +
+ + {#snippet children({ person })} + {@const hidden = overrides.get(person.id) ?? person.isHidden} + + {/snippet} + +
diff --git a/web/src/routes/(user)/people/+page.svelte b/web/src/routes/(user)/people/+page.svelte index 0d1825b63a..2e921da80c 100644 --- a/web/src/routes/(user)/people/+page.svelte +++ b/web/src/routes/(user)/people/+page.svelte @@ -1,7 +1,6 @@
{ - isTransitioned = true; - }} - onoutrostart={() => { - isTransitioned = false; - }} >