refactor: more small tests (#26159)

This commit is contained in:
Daniel Dietzler 2026-02-12 14:34:32 +01:00 committed by GitHub
parent 913904f418
commit 7e0356e227
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 463 additions and 782 deletions

View file

@ -429,45 +429,40 @@ describe(AssetMediaService.name, () => {
}); });
it('should handle a live photo', async () => { it('should handle a live photo', async () => {
mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden })
mocks.asset.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset); .owner(authStub.user1.user)
.build();
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
mocks.asset.getById.mockResolvedValueOnce(motionAsset);
mocks.asset.create.mockResolvedValueOnce(asset);
await expect( await expect(
sut.uploadAsset( sut.uploadAsset(authStub.user1, { ...createDto, livePhotoVideoId: motionAsset.id }, fileStub.livePhotoStill),
authStub.user1,
{ ...createDto, livePhotoVideoId: 'live-photo-motion-asset' },
fileStub.livePhotoStill,
),
).resolves.toEqual({ ).resolves.toEqual({
status: AssetMediaStatus.CREATED, status: AssetMediaStatus.CREATED,
id: 'live-photo-still-asset', id: asset.id,
}); });
expect(mocks.asset.getById).toHaveBeenCalledWith('live-photo-motion-asset'); expect(mocks.asset.getById).toHaveBeenCalledWith(motionAsset.id);
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalled();
}); });
it('should hide the linked motion asset', async () => { it('should hide the linked motion asset', async () => {
mocks.asset.getById.mockResolvedValueOnce({ const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build();
...assetStub.livePhotoMotionAsset, const asset = AssetFactory.create();
visibility: AssetVisibility.Timeline, mocks.asset.getById.mockResolvedValueOnce(motionAsset);
}); mocks.asset.create.mockResolvedValueOnce(asset);
mocks.asset.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
await expect( await expect(
sut.uploadAsset( sut.uploadAsset(authStub.user1, { ...createDto, livePhotoVideoId: motionAsset.id }, fileStub.livePhotoStill),
authStub.user1,
{ ...createDto, livePhotoVideoId: 'live-photo-motion-asset' },
fileStub.livePhotoStill,
),
).resolves.toEqual({ ).resolves.toEqual({
status: AssetMediaStatus.CREATED, status: AssetMediaStatus.CREATED,
id: 'live-photo-still-asset', id: asset.id,
}); });
expect(mocks.asset.getById).toHaveBeenCalledWith('live-photo-motion-asset'); expect(mocks.asset.getById).toHaveBeenCalledWith(motionAsset.id);
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: 'live-photo-motion-asset', id: motionAsset.id,
visibility: AssetVisibility.Hidden, visibility: AssetVisibility.Hidden,
}); });
}); });
@ -777,12 +772,13 @@ describe(AssetMediaService.name, () => {
}); });
it('should fall back to the original path', async () => { it('should fall back to the original path', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.video.id])); const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
mocks.asset.getForVideo.mockResolvedValue(assetStub.video); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getForVideo.mockResolvedValue(asset);
await expect(sut.playbackVideo(authStub.admin, assetStub.video.id)).resolves.toEqual( await expect(sut.playbackVideo(authStub.admin, asset.id)).resolves.toEqual(
new ImmichFileResponse({ new ImmichFileResponse({
path: assetStub.video.originalPath, path: asset.originalPath,
cacheControl: CacheControl.PrivateWithCache, cacheControl: CacheControl.PrivateWithCache,
contentType: 'application/octet-stream', contentType: 'application/octet-stream',
}), }),

View file

@ -1,6 +1,5 @@
import { BadRequestException } from '@nestjs/common'; import { BadRequestException } from '@nestjs/common';
import { DateTime } from 'luxon'; import { DateTime } from 'luxon';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto'; import { AssetJobName, AssetStatsResponseDto } from 'src/dtos/asset.dto';
import { AssetEditAction } from 'src/dtos/editing.dto'; import { AssetEditAction } from 'src/dtos/editing.dto';
import { AssetFileType, AssetMetadataKey, AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum'; import { AssetFileType, AssetMetadataKey, AssetStatus, AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
@ -10,7 +9,7 @@ import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory'; import { AuthFactory } from 'test/factories/auth.factory';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { factory } from 'test/small.factory'; import { factory, newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { makeStream, newTestService, ServiceMocks } from 'test/utils';
const stats: AssetStats = { const stats: AssetStats = {
@ -34,14 +33,8 @@ describe(AssetService.name, () => {
expect(sut).toBeDefined(); expect(sut).toBeDefined();
}); });
const mockGetById = (assets: MapAsset[]) => {
mocks.asset.getById.mockImplementation((assetId) => Promise.resolve(assets.find((asset) => asset.id === assetId)));
};
beforeEach(() => { beforeEach(() => {
({ sut, mocks } = newTestService(AssetService)); ({ sut, mocks } = newTestService(AssetService));
mockGetById([assetStub.livePhotoStillAsset, assetStub.livePhotoMotionAsset]);
}); });
describe('getStatistics', () => { describe('getStatistics', () => {
@ -254,74 +247,79 @@ describe(AssetService.name, () => {
it('should fail linking a live video if the motion part could not be found', async () => { it('should fail linking a live video if the motion part could not be found', async () => {
const auth = AuthFactory.create(); const auth = AuthFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); const asset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
await expect( await expect(
sut.update(auth, assetStub.livePhotoStillAsset.id, { sut.update(auth, asset.id, {
livePhotoVideoId: assetStub.livePhotoMotionAsset.id, livePhotoVideoId: 'unknown',
}), }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.update).not.toHaveBeenCalledWith({ expect(mocks.asset.update).not.toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id, id: asset.id,
livePhotoVideoId: assetStub.livePhotoMotionAsset.id, livePhotoVideoId: 'unknown',
}); });
expect(mocks.asset.update).not.toHaveBeenCalledWith({ expect(mocks.asset.update).not.toHaveBeenCalledWith({
id: assetStub.livePhotoMotionAsset.id, id: 'unknown',
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
}); });
expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', { expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', {
assetId: assetStub.livePhotoMotionAsset.id, assetId: 'unknown',
userId: auth.user.id, userId: auth.user.id,
}); });
}); });
it('should fail linking a live video if the motion part is not a video', async () => { it('should fail linking a live video if the motion part is not a video', async () => {
const auth = AuthFactory.create(); const auth = AuthFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); const motionAsset = AssetFactory.from().owner(auth.user).build();
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoStillAsset); const asset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getById.mockResolvedValue(asset);
await expect( await expect(
sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { sut.update(authStub.admin, asset.id, {
livePhotoVideoId: assetStub.livePhotoMotionAsset.id, livePhotoVideoId: motionAsset.id,
}), }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.update).not.toHaveBeenCalledWith({ expect(mocks.asset.update).not.toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id, id: asset.id,
livePhotoVideoId: assetStub.livePhotoMotionAsset.id, livePhotoVideoId: motionAsset.id,
}); });
expect(mocks.asset.update).not.toHaveBeenCalledWith({ expect(mocks.asset.update).not.toHaveBeenCalledWith({
id: assetStub.livePhotoMotionAsset.id, id: motionAsset.id,
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
}); });
expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', { expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', {
assetId: assetStub.livePhotoMotionAsset.id, assetId: motionAsset.id,
userId: auth.user.id, userId: auth.user.id,
}); });
}); });
it('should fail linking a live video if the motion part has a different owner', async () => { it('should fail linking a live video if the motion part has a different owner', async () => {
const auth = AuthFactory.create(); const auth = AuthFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); const motionAsset = AssetFactory.create({ type: AssetType.Video });
mocks.asset.getById.mockResolvedValue(assetStub.livePhotoMotionAsset); const asset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getById.mockResolvedValue(motionAsset);
await expect( await expect(
sut.update(auth, assetStub.livePhotoStillAsset.id, { sut.update(auth, asset.id, {
livePhotoVideoId: assetStub.livePhotoMotionAsset.id, livePhotoVideoId: motionAsset.id,
}), }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.asset.update).not.toHaveBeenCalledWith({ expect(mocks.asset.update).not.toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id, id: asset.id,
livePhotoVideoId: assetStub.livePhotoMotionAsset.id, livePhotoVideoId: motionAsset.id,
}); });
expect(mocks.asset.update).not.toHaveBeenCalledWith({ expect(mocks.asset.update).not.toHaveBeenCalledWith({
id: assetStub.livePhotoMotionAsset.id, id: motionAsset.id,
visibility: AssetVisibility.Timeline, visibility: AssetVisibility.Timeline,
}); });
expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', { expect(mocks.event.emit).not.toHaveBeenCalledWith('AssetShow', {
assetId: assetStub.livePhotoMotionAsset.id, assetId: motionAsset.id,
userId: auth.user.id, userId: auth.user.id,
}); });
}); });
@ -351,36 +349,40 @@ describe(AssetService.name, () => {
it('should unlink a live video', async () => { it('should unlink a live video', async () => {
const auth = AuthFactory.create(); const auth = AuthFactory.create();
const asset = AssetFactory.create(); const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden })
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); .owner(auth.user)
mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoStillAsset); .build();
mocks.asset.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset); const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
mocks.asset.update.mockResolvedValueOnce(asset); const unlinkedAsset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getById.mockResolvedValueOnce(asset);
mocks.asset.getById.mockResolvedValueOnce(motionAsset);
mocks.asset.update.mockResolvedValueOnce(unlinkedAsset);
await sut.update(auth, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }); await sut.update(auth, asset.id, { livePhotoVideoId: null });
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id, id: asset.id,
livePhotoVideoId: null, livePhotoVideoId: null,
}); });
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoMotionAsset.id, id: motionAsset.id,
visibility: assetStub.livePhotoStillAsset.visibility, visibility: asset.visibility,
}); });
expect(mocks.event.emit).toHaveBeenCalledWith('AssetShow', { expect(mocks.event.emit).toHaveBeenCalledWith('AssetShow', {
assetId: assetStub.livePhotoMotionAsset.id, assetId: motionAsset.id,
userId: auth.user.id, userId: auth.user.id,
}); });
}); });
it('should fail unlinking a live video if the asset could not be found', async () => { it('should fail unlinking a live video if the asset could not be found', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.livePhotoStillAsset.id])); const asset = AssetFactory.create();
// eslint-disable-next-line unicorn/no-useless-undefined mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getById.mockResolvedValueOnce(undefined); mocks.asset.getById.mockResolvedValueOnce(void 0);
await expect( await expect(sut.update(authStub.admin, asset.id, { livePhotoVideoId: null })).rejects.toBeInstanceOf(
sut.update(authStub.admin, assetStub.livePhotoStillAsset.id, { livePhotoVideoId: null }), BadRequestException,
).rejects.toBeInstanceOf(BadRequestException); );
expect(mocks.asset.update).not.toHaveBeenCalled(); expect(mocks.asset.update).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled(); expect(mocks.event.emit).not.toHaveBeenCalled();
@ -600,63 +602,31 @@ describe(AssetService.name, () => {
}); });
it('should delete a live photo', async () => { it('should delete a live photo', async () => {
mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.livePhotoStillAsset as any); const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build();
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
mocks.asset.getLivePhotoCount.mockResolvedValue(0); mocks.asset.getLivePhotoCount.mockResolvedValue(0);
await sut.handleAssetDeletion({ await sut.handleAssetDeletion({
id: assetStub.livePhotoStillAsset.id, id: asset.id,
deleteOnDisk: true, deleteOnDisk: true,
}); });
expect(mocks.job.queue.mock.calls).toEqual([ expect(mocks.job.queue.mock.calls).toEqual([
[ [{ name: JobName.AssetDelete, data: { id: motionAsset.id, deleteOnDisk: true } }],
{ [{ name: JobName.FileDelete, data: { files: [asset.originalPath] } }],
name: JobName.AssetDelete,
data: {
id: assetStub.livePhotoMotionAsset.id,
deleteOnDisk: true,
},
},
],
[
{
name: JobName.FileDelete,
data: {
files: [
'/uploads/user-id/webp/path.ext',
'/uploads/user-id/thumbs/path.jpg',
'/uploads/user-id/fullsize/path.webp',
'fake_path/asset_1.jpeg',
],
},
},
],
]); ]);
}); });
it('should not delete a live motion part if it is being used by another asset', async () => { it('should not delete a live motion part if it is being used by another asset', async () => {
const asset = AssetFactory.create({ livePhotoVideoId: newUuid() });
mocks.asset.getLivePhotoCount.mockResolvedValue(2); mocks.asset.getLivePhotoCount.mockResolvedValue(2);
mocks.assetJob.getForAssetDeletion.mockResolvedValue(assetStub.livePhotoStillAsset as any); mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
await sut.handleAssetDeletion({ await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
id: assetStub.livePhotoStillAsset.id,
deleteOnDisk: true,
});
expect(mocks.job.queue.mock.calls).toEqual([ expect(mocks.job.queue.mock.calls).toEqual([
[ [{ name: JobName.FileDelete, data: { files: [`/data/library/IMG_${asset.id}.jpg`] } }],
{
name: JobName.FileDelete,
data: {
files: [
'/uploads/user-id/webp/path.ext',
'/uploads/user-id/thumbs/path.jpg',
'/uploads/user-id/fullsize/path.webp',
'fake_path/asset_1.jpeg',
],
},
},
],
]); ]);
}); });

View file

@ -4,6 +4,7 @@ import { SearchService } from 'src/services/search.service';
import { AssetFactory } from 'test/factories/asset.factory'; import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { makeStream, newTestService, ServiceMocks } from 'test/utils';
import { beforeEach, vitest } from 'vitest'; import { beforeEach, vitest } from 'vitest';
@ -151,9 +152,7 @@ describe(SearchService.name, () => {
}, },
}, },
}); });
const id = assetStub.livePhotoMotionAsset.id; const result = await sut.handleSearchDuplicates({ id: newUuid() });
const result = await sut.handleSearchDuplicates({ id });
expect(result).toBe(JobStatus.Skipped); expect(result).toBe(JobStatus.Skipped);
expect(mocks.assetJob.getForSearchDuplicatesJob).not.toHaveBeenCalled(); expect(mocks.assetJob.getForSearchDuplicatesJob).not.toHaveBeenCalled();
@ -168,9 +167,7 @@ describe(SearchService.name, () => {
}, },
}, },
}); });
const id = assetStub.livePhotoMotionAsset.id; const result = await sut.handleSearchDuplicates({ id: newUuid() });
const result = await sut.handleSearchDuplicates({ id });
expect(result).toBe(JobStatus.Skipped); expect(result).toBe(JobStatus.Skipped);
expect(mocks.assetJob.getForSearchDuplicatesJob).not.toHaveBeenCalled(); expect(mocks.assetJob.getForSearchDuplicatesJob).not.toHaveBeenCalled();
@ -197,16 +194,13 @@ describe(SearchService.name, () => {
}); });
it('should skip if asset is not visible', async () => { it('should skip if asset is not visible', async () => {
const id = assetStub.livePhotoMotionAsset.id; const asset = AssetFactory.create({ visibility: AssetVisibility.Hidden });
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, ...asset });
...hasEmbedding,
visibility: AssetVisibility.Hidden,
});
const result = await sut.handleSearchDuplicates({ id }); const result = await sut.handleSearchDuplicates({ id: asset.id });
expect(result).toBe(JobStatus.Skipped); expect(result).toBe(JobStatus.Skipped);
expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is not visible, skipping`); expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${asset.id} is not visible, skipping`);
}); });
it('should fail if asset is missing embedding', async () => { it('should fail if asset is missing embedding', async () => {

View file

@ -1,8 +1,8 @@
import { ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum'; import { AssetType, ImmichWorker, JobName, JobStatus, QueueName } from 'src/enum';
import { JobService } from 'src/services/job.service'; import { JobService } from 'src/services/job.service';
import { JobItem } from 'src/types'; import { JobItem } from 'src/types';
import { AssetFactory } from 'test/factories/asset.factory'; import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub'; import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
describe(JobService.name, () => { describe(JobService.name, () => {
@ -56,22 +56,22 @@ describe(JobService.name, () => {
{ {
item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } }, item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } },
jobs: [], jobs: [],
stub: [AssetFactory.create({ id: 'asset-id' })], stub: [AssetFactory.create({ id: 'asset-1' })],
}, },
{ {
item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } }, item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1' } },
jobs: [], jobs: [],
stub: [assetStub.video], stub: [AssetFactory.create({ id: 'asset-1', type: AssetType.Video })],
}, },
{ {
item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1', source: 'upload' } }, item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1', source: 'upload' } },
jobs: [JobName.SmartSearch, JobName.AssetDetectFaces, JobName.Ocr], jobs: [JobName.SmartSearch, JobName.AssetDetectFaces, JobName.Ocr],
stub: [assetStub.livePhotoStillAsset], stub: [AssetFactory.create({ id: 'asset-1', livePhotoVideoId: newUuid() })],
}, },
{ {
item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1', source: 'upload' } }, item: { name: JobName.AssetGenerateThumbnails, data: { id: 'asset-1', source: 'upload' } },
jobs: [JobName.SmartSearch, JobName.AssetDetectFaces, JobName.Ocr, JobName.AssetEncodeVideo], jobs: [JobName.SmartSearch, JobName.AssetDetectFaces, JobName.Ocr, JobName.AssetEncodeVideo],
stub: [assetStub.video], stub: [AssetFactory.create({ id: 'asset-1', type: AssetType.Video })],
}, },
{ {
item: { name: JobName.SmartSearch, data: { id: 'asset-1' } }, item: { name: JobName.SmartSearch, data: { id: 'asset-1' } },

View file

@ -7,11 +7,10 @@ import { AssetType, CronJob, ImmichWorker, JobName, JobStatus } from 'src/enum';
import { LibraryService } from 'src/services/library.service'; import { LibraryService } from 'src/services/library.service';
import { ILibraryBulkIdsJob, ILibraryFileJob } from 'src/types'; import { ILibraryBulkIdsJob, ILibraryFileJob } from 'src/types';
import { AssetFactory } from 'test/factories/asset.factory'; import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub'; import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { makeMockWatcher } from 'test/repositories/storage.repository.mock'; import { makeMockWatcher } from 'test/repositories/storage.repository.mock';
import { factory, newUuid } from 'test/small.factory'; import { factory, newDate, newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils'; import { makeStream, newTestService, ServiceMocks } from 'test/utils';
import { vitest } from 'vitest'; import { vitest } from 'vitest';
@ -307,13 +306,13 @@ describe(LibraryService.name, () => {
it('should queue asset sync', async () => { it('should queue asset sync', async () => {
const library = factory.library({ importPaths: ['/foo', '/bar'] }); const library = factory.library({ importPaths: ['/foo', '/bar'] });
const asset = AssetFactory.create({ libraryId: library.id, isExternal: true });
mocks.library.get.mockResolvedValue(library); mocks.library.get.mockResolvedValue(library);
mocks.storage.walk.mockImplementation(async function* generator() {}); mocks.storage.walk.mockImplementation(async function* generator() {});
mocks.library.streamAssetIds.mockReturnValue(makeStream([assetStub.external])); mocks.library.streamAssetIds.mockReturnValue(makeStream([asset]));
mocks.asset.getLibraryAssetCount.mockResolvedValue(1); mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 0n }); mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 0n });
mocks.library.streamAssetIds.mockReturnValue(makeStream([assetStub.external]));
const response = await sut.handleQueueSyncAssets({ id: library.id }); const response = await sut.handleQueueSyncAssets({ id: library.id });
@ -323,7 +322,7 @@ describe(LibraryService.name, () => {
libraryId: library.id, libraryId: library.id,
importPaths: library.importPaths, importPaths: library.importPaths,
exclusionPatterns: library.exclusionPatterns, exclusionPatterns: library.exclusionPatterns,
assetIds: [assetStub.external.id], assetIds: [asset.id],
progressCounter: 1, progressCounter: 1,
totalAssets: 1, totalAssets: 1,
}, },
@ -344,8 +343,9 @@ describe(LibraryService.name, () => {
describe('handleSyncAssets', () => { describe('handleSyncAssets', () => {
it('should offline assets no longer on disk', async () => { it('should offline assets no longer on disk', async () => {
const asset = AssetFactory.create({ libraryId: 'library-id', isExternal: true });
const mockAssetJob: ILibraryBulkIdsJob = { const mockAssetJob: ILibraryBulkIdsJob = {
assetIds: [assetStub.external.id], assetIds: [asset.id],
libraryId: newUuid(), libraryId: newUuid(),
importPaths: ['/'], importPaths: ['/'],
exclusionPatterns: [], exclusionPatterns: [],
@ -353,20 +353,21 @@ describe(LibraryService.name, () => {
progressCounter: 0, progressCounter: 0,
}; };
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]); mocks.assetJob.getForSyncAssets.mockResolvedValue([asset]);
mocks.storage.stat.mockRejectedValue(new Error('ENOENT, no such file or directory')); mocks.storage.stat.mockRejectedValue(new Error('ENOENT, no such file or directory'));
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success);
expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { expect(mocks.asset.updateAll).toHaveBeenCalledWith([asset.id], {
isOffline: true, isOffline: true,
deletedAt: expect.anything(), deletedAt: expect.anything(),
}); });
}); });
it('should set assets deleted from disk as offline', async () => { it('should set assets deleted from disk as offline', async () => {
const asset = AssetFactory.create({ libraryId: 'library-id', isExternal: true });
const mockAssetJob: ILibraryBulkIdsJob = { const mockAssetJob: ILibraryBulkIdsJob = {
assetIds: [assetStub.external.id], assetIds: [asset.id],
libraryId: newUuid(), libraryId: newUuid(),
importPaths: ['/data/user2'], importPaths: ['/data/user2'],
exclusionPatterns: [], exclusionPatterns: [],
@ -374,20 +375,21 @@ describe(LibraryService.name, () => {
progressCounter: 0, progressCounter: 0,
}; };
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]); mocks.assetJob.getForSyncAssets.mockResolvedValue([asset]);
mocks.storage.stat.mockRejectedValue(new Error('Could not read file')); mocks.storage.stat.mockRejectedValue(new Error('Could not read file'));
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success);
expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { expect(mocks.asset.updateAll).toHaveBeenCalledWith([asset.id], {
isOffline: true, isOffline: true,
deletedAt: expect.anything(), deletedAt: expect.anything(),
}); });
}); });
it('should do nothing with offline assets deleted from disk', async () => { it('should do nothing with offline assets deleted from disk', async () => {
const asset = AssetFactory.create({ isOffline: true, deletedAt: newDate() });
const mockAssetJob: ILibraryBulkIdsJob = { const mockAssetJob: ILibraryBulkIdsJob = {
assetIds: [assetStub.trashedOffline.id], assetIds: [asset.id],
libraryId: newUuid(), libraryId: newUuid(),
importPaths: ['/data/user2'], importPaths: ['/data/user2'],
exclusionPatterns: [], exclusionPatterns: [],
@ -395,7 +397,7 @@ describe(LibraryService.name, () => {
progressCounter: 0, progressCounter: 0,
}; };
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]); mocks.assetJob.getForSyncAssets.mockResolvedValue([asset]);
mocks.storage.stat.mockRejectedValue(new Error('Could not read file')); mocks.storage.stat.mockRejectedValue(new Error('Could not read file'));
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success);
@ -404,8 +406,9 @@ describe(LibraryService.name, () => {
}); });
it('should un-trash an asset previously marked as offline', async () => { it('should un-trash an asset previously marked as offline', async () => {
const asset = AssetFactory.create({ originalPath: '/original/path.jpg', isOffline: true, deletedAt: newDate() });
const mockAssetJob: ILibraryBulkIdsJob = { const mockAssetJob: ILibraryBulkIdsJob = {
assetIds: [assetStub.trashedOffline.id], assetIds: [asset.id],
libraryId: newUuid(), libraryId: newUuid(),
importPaths: ['/original/'], importPaths: ['/original/'],
exclusionPatterns: [], exclusionPatterns: [],
@ -413,20 +416,21 @@ describe(LibraryService.name, () => {
progressCounter: 0, progressCounter: 0,
}; };
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]); mocks.assetJob.getForSyncAssets.mockResolvedValue([asset]);
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); mocks.storage.stat.mockResolvedValue({ mtime: newDate() } as Stats);
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success);
expect(mocks.asset.updateAll).toHaveBeenCalledWith([assetStub.external.id], { expect(mocks.asset.updateAll).toHaveBeenCalledWith([asset.id], {
isOffline: false, isOffline: false,
deletedAt: null, deletedAt: null,
}); });
}); });
it('should do nothing with offline asset if covered by exclusion pattern', async () => { it('should do nothing with offline asset if covered by exclusion pattern', async () => {
const asset = AssetFactory.create({ originalPath: '/original/path.jpg', isOffline: true, deletedAt: newDate() });
const mockAssetJob: ILibraryBulkIdsJob = { const mockAssetJob: ILibraryBulkIdsJob = {
assetIds: [assetStub.trashedOffline.id], assetIds: [asset.id],
libraryId: newUuid(), libraryId: newUuid(),
importPaths: ['/original/'], importPaths: ['/original/'],
exclusionPatterns: ['**/path.jpg'], exclusionPatterns: ['**/path.jpg'],
@ -434,8 +438,8 @@ describe(LibraryService.name, () => {
progressCounter: 0, progressCounter: 0,
}; };
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]); mocks.assetJob.getForSyncAssets.mockResolvedValue([asset]);
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); mocks.storage.stat.mockResolvedValue({ mtime: newDate() } as Stats);
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success);
@ -445,8 +449,9 @@ describe(LibraryService.name, () => {
}); });
it('should do nothing with offline asset if not in import path', async () => { it('should do nothing with offline asset if not in import path', async () => {
const asset = AssetFactory.create({ originalPath: '/original/path.jpg', isOffline: true, deletedAt: newDate() });
const mockAssetJob: ILibraryBulkIdsJob = { const mockAssetJob: ILibraryBulkIdsJob = {
assetIds: [assetStub.trashedOffline.id], assetIds: [asset.id],
libraryId: newUuid(), libraryId: newUuid(),
importPaths: ['/import/'], importPaths: ['/import/'],
exclusionPatterns: [], exclusionPatterns: [],
@ -454,8 +459,8 @@ describe(LibraryService.name, () => {
progressCounter: 0, progressCounter: 0,
}; };
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]); mocks.assetJob.getForSyncAssets.mockResolvedValue([asset]);
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); mocks.storage.stat.mockResolvedValue({ mtime: newDate() } as Stats);
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success);
@ -465,8 +470,9 @@ describe(LibraryService.name, () => {
}); });
it('should do nothing with unchanged online assets', async () => { it('should do nothing with unchanged online assets', async () => {
const asset = AssetFactory.create({ libraryId: 'library-id', isExternal: true });
const mockAssetJob: ILibraryBulkIdsJob = { const mockAssetJob: ILibraryBulkIdsJob = {
assetIds: [assetStub.external.id], assetIds: [asset.id],
libraryId: newUuid(), libraryId: newUuid(),
importPaths: ['/'], importPaths: ['/'],
exclusionPatterns: [], exclusionPatterns: [],
@ -474,8 +480,8 @@ describe(LibraryService.name, () => {
progressCounter: 0, progressCounter: 0,
}; };
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]); mocks.assetJob.getForSyncAssets.mockResolvedValue([asset]);
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.external.fileModifiedAt } as Stats); mocks.storage.stat.mockResolvedValue({ mtime: asset.fileModifiedAt } as Stats);
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success);
@ -483,8 +489,9 @@ describe(LibraryService.name, () => {
}); });
it('should not touch fileCreatedAt when un-trashing an asset previously marked as offline', async () => { it('should not touch fileCreatedAt when un-trashing an asset previously marked as offline', async () => {
const asset = AssetFactory.create({ isOffline: true, deletedAt: newDate() });
const mockAssetJob: ILibraryBulkIdsJob = { const mockAssetJob: ILibraryBulkIdsJob = {
assetIds: [assetStub.trashedOffline.id], assetIds: [asset.id],
libraryId: newUuid(), libraryId: newUuid(),
importPaths: ['/'], importPaths: ['/'],
exclusionPatterns: [], exclusionPatterns: [],
@ -492,13 +499,13 @@ describe(LibraryService.name, () => {
progressCounter: 0, progressCounter: 0,
}; };
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.trashedOffline]); mocks.assetJob.getForSyncAssets.mockResolvedValue([asset]);
mocks.storage.stat.mockResolvedValue({ mtime: assetStub.trashedOffline.fileModifiedAt } as Stats); mocks.storage.stat.mockResolvedValue({ mtime: newDate() } as Stats);
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success);
expect(mocks.asset.updateAll).toHaveBeenCalledWith( expect(mocks.asset.updateAll).toHaveBeenCalledWith(
[assetStub.trashedOffline.id], [asset.id],
expect.not.objectContaining({ expect.not.objectContaining({
fileCreatedAt: expect.anything(), fileCreatedAt: expect.anything(),
}), }),
@ -506,8 +513,9 @@ describe(LibraryService.name, () => {
}); });
it('should update with online assets that have changed', async () => { it('should update with online assets that have changed', async () => {
const asset = AssetFactory.create({ libraryId: 'library-id', isExternal: true });
const mockAssetJob: ILibraryBulkIdsJob = { const mockAssetJob: ILibraryBulkIdsJob = {
assetIds: [assetStub.external.id], assetIds: [asset.id],
libraryId: newUuid(), libraryId: newUuid(),
importPaths: ['/'], importPaths: ['/'],
exclusionPatterns: [], exclusionPatterns: [],
@ -515,13 +523,9 @@ describe(LibraryService.name, () => {
progressCounter: 0, progressCounter: 0,
}; };
if (assetStub.external.fileModifiedAt == null) { const mtime = new Date(asset.fileModifiedAt.getDate() + 1);
throw new Error('fileModifiedAt is null');
}
const mtime = new Date(assetStub.external.fileModifiedAt.getDate() + 1); mocks.assetJob.getForSyncAssets.mockResolvedValue([asset]);
mocks.assetJob.getForSyncAssets.mockResolvedValue([assetStub.external]);
mocks.storage.stat.mockResolvedValue({ mtime } as Stats); mocks.storage.stat.mockResolvedValue({ mtime } as Stats);
await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success); await expect(sut.handleSyncAssets(mockAssetJob)).resolves.toBe(JobStatus.Success);
@ -530,7 +534,7 @@ describe(LibraryService.name, () => {
{ {
name: JobName.SidecarCheck, name: JobName.SidecarCheck,
data: { data: {
id: assetStub.external.id, id: asset.id,
source: 'upload', source: 'upload',
}, },
}, },
@ -1023,9 +1027,10 @@ describe(LibraryService.name, () => {
it('should handle an error event', async () => { it('should handle an error event', async () => {
const library = factory.library({ importPaths: ['/foo', '/bar'] }); const library = factory.library({ importPaths: ['/foo', '/bar'] });
const asset = AssetFactory.create({ libraryId: library.id, isExternal: true });
mocks.library.get.mockResolvedValue(library); mocks.library.get.mockResolvedValue(library);
mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(assetStub.external); mocks.asset.getByLibraryIdAndOriginalPath.mockResolvedValue(asset);
mocks.library.getAll.mockResolvedValue([library]); mocks.library.getAll.mockResolvedValue([library]);
mocks.storage.watch.mockImplementation( mocks.storage.watch.mockImplementation(
makeMockWatcher({ makeMockWatcher({

File diff suppressed because it is too large Load diff

View file

@ -3,7 +3,6 @@ import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto'; import { randomBytes } from 'node:crypto';
import { Stats } from 'node:fs'; import { Stats } from 'node:fs';
import { defaults } from 'src/config'; import { defaults } from 'src/config';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { import {
AssetFileType, AssetFileType,
AssetType, AssetType,
@ -18,7 +17,6 @@ import { ImmichTags } from 'src/repositories/metadata.repository';
import { firstDateTime, MetadataService } from 'src/services/metadata.service'; import { firstDateTime, MetadataService } from 'src/services/metadata.service';
import { AssetFactory } from 'test/factories/asset.factory'; import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub'; import { assetStub } from 'test/fixtures/asset.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { probeStub } from 'test/fixtures/media.stub'; import { probeStub } from 'test/fixtures/media.stub';
import { personStub } from 'test/fixtures/person.stub'; import { personStub } from 'test/fixtures/person.stub';
import { tagStub } from 'test/fixtures/tag.stub'; import { tagStub } from 'test/fixtures/tag.stub';
@ -604,14 +602,12 @@ describe(MetadataService.name, () => {
}); });
it('should not apply motion photos if asset is video', async () => { it('should not apply motion photos if asset is video', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ const asset = AssetFactory.create({ type: AssetType.Video });
...assetStub.livePhotoMotionAsset, mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
visibility: AssetVisibility.Timeline,
});
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer); mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled(); expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled();
expect(mocks.job.queue).not.toHaveBeenCalled(); expect(mocks.job.queue).not.toHaveBeenCalled();
expect(mocks.job.queueAll).not.toHaveBeenCalled(); expect(mocks.job.queueAll).not.toHaveBeenCalled();
@ -632,13 +628,14 @@ describe(MetadataService.name, () => {
}); });
it('should extract the correct video orientation', async () => { it('should extract the correct video orientation', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video); const asset = AssetFactory.create({ type: AssetType.Video });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
mockReadTags({}); mockReadTags({});
await sut.handleMetadataExtraction({ id: assetStub.video.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.upsertExif).toHaveBeenCalledWith( expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }), expect.objectContaining({ orientation: ExifOrientation.Rotate270CW.toString() }),
{ lockedPropertiesBehavior: 'skip' }, { lockedPropertiesBehavior: 'skip' },
@ -646,16 +643,14 @@ describe(MetadataService.name, () => {
}); });
it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => { it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ const asset = AssetFactory.create();
...assetStub.livePhotoWithOriginalFileName, const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
livePhotoVideoId: null, mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
libraryId: null,
});
mocks.storage.stat.mockResolvedValue({ mocks.storage.stat.mockResolvedValue({
size: 123_456, size: 123_456,
mtime: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, mtime: asset.fileModifiedAt,
mtimeMs: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.valueOf(), mtimeMs: asset.fileModifiedAt.valueOf(),
birthtimeMs: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.valueOf(), birthtimeMs: asset.fileCreatedAt.valueOf(),
} as Stats); } as Stats);
mockReadTags({ mockReadTags({
Directory: 'foo/bar/', Directory: 'foo/bar/',
@ -667,57 +662,52 @@ describe(MetadataService.name, () => {
EmbeddedVideoType: 'MotionPhoto_Data', EmbeddedVideoType: 'MotionPhoto_Data',
}); });
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); mocks.asset.create.mockResolvedValue(motionAsset);
mocks.crypto.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); mocks.crypto.randomUUID.mockReturnValue(motionAsset.id);
const video = randomBytes(512); const video = randomBytes(512);
mocks.metadata.extractBinaryTag.mockResolvedValue(video); mocks.metadata.extractBinaryTag.mockResolvedValue(video);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.metadata.extractBinaryTag).toHaveBeenCalledWith( expect(mocks.metadata.extractBinaryTag).toHaveBeenCalledWith(asset.originalPath, 'MotionPhotoVideo');
assetStub.livePhotoWithOriginalFileName.originalPath, expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
'MotionPhotoVideo',
);
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoWithOriginalFileName.id);
expect(mocks.asset.create).toHaveBeenCalledWith({ expect(mocks.asset.create).toHaveBeenCalledWith({
checksum: expect.any(Buffer), checksum: expect.any(Buffer),
deviceAssetId: 'NONE', deviceAssetId: 'NONE',
deviceId: 'NONE', deviceId: 'NONE',
fileCreatedAt: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, fileModifiedAt: asset.fileModifiedAt,
id: fileStub.livePhotoMotion.uuid, id: motionAsset.id,
visibility: AssetVisibility.Hidden, visibility: AssetVisibility.Hidden,
libraryId: assetStub.livePhotoWithOriginalFileName.libraryId, libraryId: asset.libraryId,
localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, localDateTime: asset.fileCreatedAt,
originalFileName: 'asset_1.mp4', originalFileName: `IMG_${asset.id}.mp4`,
originalPath: expect.stringContaining('/data/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4'), originalPath: expect.stringContaining(`${motionAsset.id}-MP.mp4`),
ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, ownerId: asset.ownerId,
type: AssetType.Video, type: AssetType.Video,
}); });
expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); expect(mocks.user.updateUsage).toHaveBeenCalledWith(asset.ownerId, 512);
expect(mocks.storage.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(mocks.storage.createFile).toHaveBeenCalledWith(motionAsset.originalPath, video);
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoWithOriginalFileName.id, id: asset.id,
livePhotoVideoId: fileStub.livePhotoMotion.uuid, livePhotoVideoId: motionAsset.id,
}); });
expect(mocks.asset.update).toHaveBeenCalledTimes(3); expect(mocks.asset.update).toHaveBeenCalledTimes(3);
expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({ expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({
name: JobName.AssetEncodeVideo, name: JobName.AssetEncodeVideo,
data: { id: assetStub.livePhotoMotionAsset.id }, data: { id: motionAsset.id },
}); });
}); });
it('should extract the EmbeddedVideo tag from Samsung JPEG motion photos', async () => { it('should extract the EmbeddedVideo tag from Samsung JPEG motion photos', async () => {
const asset = AssetFactory.create();
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
mocks.storage.stat.mockResolvedValue({ mocks.storage.stat.mockResolvedValue({
size: 123_456, size: 123_456,
mtime: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, mtime: asset.fileModifiedAt,
mtimeMs: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.valueOf(), mtimeMs: asset.fileModifiedAt.valueOf(),
birthtimeMs: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.valueOf(), birthtimeMs: asset.fileCreatedAt.valueOf(),
} as Stats); } as Stats);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
...assetStub.livePhotoWithOriginalFileName,
livePhotoVideoId: null,
libraryId: null,
});
mockReadTags({ mockReadTags({
Directory: 'foo/bar/', Directory: 'foo/bar/',
EmbeddedVideoFile: new BinaryField(0, ''), EmbeddedVideoFile: new BinaryField(0, ''),
@ -725,56 +715,51 @@ describe(MetadataService.name, () => {
MotionPhoto: 1, MotionPhoto: 1,
}); });
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); mocks.asset.create.mockResolvedValue(motionAsset);
mocks.crypto.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); mocks.crypto.randomUUID.mockReturnValue(motionAsset.id);
const video = randomBytes(512); const video = randomBytes(512);
mocks.metadata.extractBinaryTag.mockResolvedValue(video); mocks.metadata.extractBinaryTag.mockResolvedValue(video);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.metadata.extractBinaryTag).toHaveBeenCalledWith( expect(mocks.metadata.extractBinaryTag).toHaveBeenCalledWith(asset.originalPath, 'EmbeddedVideoFile');
assetStub.livePhotoWithOriginalFileName.originalPath, expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
'EmbeddedVideoFile',
);
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoWithOriginalFileName.id);
expect(mocks.asset.create).toHaveBeenCalledWith({ expect(mocks.asset.create).toHaveBeenCalledWith({
checksum: expect.any(Buffer), checksum: expect.any(Buffer),
deviceAssetId: 'NONE', deviceAssetId: 'NONE',
deviceId: 'NONE', deviceId: 'NONE',
fileCreatedAt: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, fileModifiedAt: asset.fileModifiedAt,
id: fileStub.livePhotoMotion.uuid, id: motionAsset.id,
visibility: AssetVisibility.Hidden, visibility: AssetVisibility.Hidden,
libraryId: assetStub.livePhotoWithOriginalFileName.libraryId, libraryId: asset.libraryId,
localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, localDateTime: asset.fileCreatedAt,
originalFileName: 'asset_1.mp4', originalFileName: `IMG_${asset.id}.mp4`,
originalPath: expect.stringContaining('/data/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4'), originalPath: expect.stringContaining(`${motionAsset.id}-MP.mp4`),
ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, ownerId: asset.ownerId,
type: AssetType.Video, type: AssetType.Video,
}); });
expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); expect(mocks.user.updateUsage).toHaveBeenCalledWith(asset.ownerId, 512);
expect(mocks.storage.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(mocks.storage.createFile).toHaveBeenCalledWith(motionAsset.originalPath, video);
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoWithOriginalFileName.id, id: asset.id,
livePhotoVideoId: fileStub.livePhotoMotion.uuid, livePhotoVideoId: motionAsset.id,
}); });
expect(mocks.asset.update).toHaveBeenCalledTimes(3); expect(mocks.asset.update).toHaveBeenCalledTimes(3);
expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({ expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({
name: JobName.AssetEncodeVideo, name: JobName.AssetEncodeVideo,
data: { id: assetStub.livePhotoMotionAsset.id }, data: { id: motionAsset.id },
}); });
}); });
it('should extract the motion photo video from the XMP directory entry ', async () => { it('should extract the motion photo video from the XMP directory entry ', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ const asset = AssetFactory.create();
...assetStub.livePhotoWithOriginalFileName, const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
livePhotoVideoId: null, mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
libraryId: null,
});
mocks.storage.stat.mockResolvedValue({ mocks.storage.stat.mockResolvedValue({
size: 123_456, size: 123_456,
mtime: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, mtime: asset.fileModifiedAt,
mtimeMs: assetStub.livePhotoWithOriginalFileName.fileModifiedAt.valueOf(), mtimeMs: asset.fileModifiedAt.valueOf(),
birthtimeMs: assetStub.livePhotoWithOriginalFileName.fileCreatedAt.valueOf(), birthtimeMs: asset.fileCreatedAt.valueOf(),
} as Stats); } as Stats);
mockReadTags({ mockReadTags({
Directory: 'foo/bar/', Directory: 'foo/bar/',
@ -783,47 +768,46 @@ describe(MetadataService.name, () => {
MicroVideoOffset: 1, MicroVideoOffset: 1,
}); });
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); mocks.asset.create.mockResolvedValue(motionAsset);
mocks.crypto.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid); mocks.crypto.randomUUID.mockReturnValue(motionAsset.id);
const video = randomBytes(512); const video = randomBytes(512);
mocks.storage.readFile.mockResolvedValue(video); mocks.storage.readFile.mockResolvedValue(video);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoWithOriginalFileName.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.storage.readFile).toHaveBeenCalledWith( expect(mocks.storage.readFile).toHaveBeenCalledWith(asset.originalPath, expect.any(Object));
assetStub.livePhotoWithOriginalFileName.originalPath,
expect.any(Object),
);
expect(mocks.asset.create).toHaveBeenCalledWith({ expect(mocks.asset.create).toHaveBeenCalledWith({
checksum: expect.any(Buffer), checksum: expect.any(Buffer),
deviceAssetId: 'NONE', deviceAssetId: 'NONE',
deviceId: 'NONE', deviceId: 'NONE',
fileCreatedAt: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: assetStub.livePhotoWithOriginalFileName.fileModifiedAt, fileModifiedAt: asset.fileModifiedAt,
id: fileStub.livePhotoMotion.uuid, id: motionAsset.id,
visibility: AssetVisibility.Hidden, visibility: AssetVisibility.Hidden,
libraryId: assetStub.livePhotoWithOriginalFileName.libraryId, libraryId: asset.libraryId,
localDateTime: assetStub.livePhotoWithOriginalFileName.fileCreatedAt, localDateTime: asset.fileCreatedAt,
originalFileName: 'asset_1.mp4', originalFileName: `IMG_${asset.id}.mp4`,
originalPath: expect.stringContaining('/data/encoded-video/user-id/li/ve/live-photo-motion-asset-MP.mp4'), originalPath: expect.stringContaining(`${motionAsset.id}-MP.mp4`),
ownerId: assetStub.livePhotoWithOriginalFileName.ownerId, ownerId: asset.ownerId,
type: AssetType.Video, type: AssetType.Video,
}); });
expect(mocks.user.updateUsage).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.ownerId, 512); expect(mocks.user.updateUsage).toHaveBeenCalledWith(asset.ownerId, 512);
expect(mocks.storage.createFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video); expect(mocks.storage.createFile).toHaveBeenCalledWith(motionAsset.originalPath, video);
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoWithOriginalFileName.id, id: asset.id,
livePhotoVideoId: fileStub.livePhotoMotion.uuid, livePhotoVideoId: motionAsset.id,
}); });
expect(mocks.asset.update).toHaveBeenCalledTimes(3); expect(mocks.asset.update).toHaveBeenCalledTimes(3);
expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({ expect(mocks.job.queue).toHaveBeenCalledExactlyOnceWith({
name: JobName.AssetEncodeVideo, name: JobName.AssetEncodeVideo,
data: { id: assetStub.livePhotoMotionAsset.id }, data: { id: motionAsset.id },
}); });
}); });
it('should delete old motion photo video assets if they do not match what is extracted', async () => { it('should delete old motion photo video assets if they do not match what is extracted', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoWithOriginalFileName); const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mockReadTags({ mockReadTags({
Directory: 'foo/bar/', Directory: 'foo/bar/',
MotionPhoto: 1, MotionPhoto: 1,
@ -831,21 +815,21 @@ describe(MetadataService.name, () => {
MicroVideoOffset: 1, MicroVideoOffset: 1,
}); });
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
mocks.asset.create.mockImplementation( mocks.asset.create.mockResolvedValue(AssetFactory.create({ type: AssetType.Video }));
(asset) => Promise.resolve({ ...assetStub.livePhotoMotionAsset, ...asset }) as Promise<MapAsset>,
);
const video = randomBytes(512); const video = randomBytes(512);
mocks.storage.readFile.mockResolvedValue(video); mocks.storage.readFile.mockResolvedValue(video);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoWithOriginalFileName.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.job.queue).toHaveBeenNthCalledWith(1, { expect(mocks.job.queue).toHaveBeenNthCalledWith(1, {
name: JobName.AssetDelete, name: JobName.AssetDelete,
data: { id: assetStub.livePhotoWithOriginalFileName.livePhotoVideoId, deleteOnDisk: true }, data: { id: asset.livePhotoVideoId, deleteOnDisk: true },
}); });
}); });
it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => { it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoStillAsset); const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mockReadTags({ mockReadTags({
Directory: 'foo/bar/', Directory: 'foo/bar/',
MotionPhoto: 1, MotionPhoto: 1,
@ -853,12 +837,12 @@ describe(MetadataService.name, () => {
MicroVideoOffset: 1, MicroVideoOffset: 1,
}); });
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
mocks.asset.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset); mocks.asset.getByChecksum.mockResolvedValue(motionAsset);
const video = randomBytes(512); const video = randomBytes(512);
mocks.storage.readFile.mockResolvedValue(video); mocks.storage.readFile.mockResolvedValue(video);
mocks.storage.checkFileExists.mockResolvedValue(true); mocks.storage.checkFileExists.mockResolvedValue(true);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.create).not.toHaveBeenCalled(); expect(mocks.asset.create).not.toHaveBeenCalled();
expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled(); expect(mocks.storage.createOrOverwriteFile).not.toHaveBeenCalled();
// The still asset gets saved by handleMetadataExtraction, but not the video // The still asset gets saved by handleMetadataExtraction, but not the video
@ -867,10 +851,9 @@ describe(MetadataService.name, () => {
}); });
it('should link and hide motion video asset to still asset if the hash of the extracted video matches an existing asset', async () => { it('should link and hide motion video asset to still asset if the hash of the extracted video matches an existing asset', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ const motionAsset = AssetFactory.create({ type: AssetType.Video });
...assetStub.livePhotoStillAsset, const asset = AssetFactory.create();
livePhotoVideoId: null, mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
});
mockReadTags({ mockReadTags({
Directory: 'foo/bar/', Directory: 'foo/bar/',
MotionPhoto: 1, MotionPhoto: 1,
@ -878,31 +861,26 @@ describe(MetadataService.name, () => {
MicroVideoOffset: 1, MicroVideoOffset: 1,
}); });
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
mocks.asset.getByChecksum.mockResolvedValue({ mocks.asset.getByChecksum.mockResolvedValue(motionAsset);
...assetStub.livePhotoMotionAsset,
visibility: AssetVisibility.Timeline,
});
const video = randomBytes(512); const video = randomBytes(512);
mocks.storage.readFile.mockResolvedValue(video); mocks.storage.readFile.mockResolvedValue(video);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoMotionAsset.id, id: motionAsset.id,
visibility: AssetVisibility.Hidden, visibility: AssetVisibility.Hidden,
}); });
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id, id: asset.id,
livePhotoVideoId: assetStub.livePhotoMotionAsset.id, livePhotoVideoId: motionAsset.id,
}); });
expect(mocks.asset.update).toHaveBeenCalledTimes(4); expect(mocks.asset.update).toHaveBeenCalledTimes(4);
}); });
it('should not update storage usage if motion photo is external', async () => { it('should not update storage usage if motion photo is external', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
...assetStub.livePhotoStillAsset, const asset = AssetFactory.create({ isExternal: true });
livePhotoVideoId: null, mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
isExternal: true,
});
mockReadTags({ mockReadTags({
Directory: 'foo/bar/', Directory: 'foo/bar/',
MotionPhoto: 1, MotionPhoto: 1,
@ -910,11 +888,11 @@ describe(MetadataService.name, () => {
MicroVideoOffset: 1, MicroVideoOffset: 1,
}); });
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512)); mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
mocks.asset.create.mockResolvedValue(assetStub.livePhotoMotionAsset); mocks.asset.create.mockResolvedValue(motionAsset);
const video = randomBytes(512); const video = randomBytes(512);
mocks.storage.readFile.mockResolvedValue(video); mocks.storage.readFile.mockResolvedValue(video);
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.user.updateUsage).not.toHaveBeenCalled(); expect(mocks.user.updateUsage).not.toHaveBeenCalled();
}); });
@ -1026,7 +1004,8 @@ describe(MetadataService.name, () => {
}); });
it('should extract duration', async () => { it('should extract duration', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video); const asset = AssetFactory.create({ type: AssetType.Video });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.media.probe.mockResolvedValue({ mocks.media.probe.mockResolvedValue({
...probeStub.videoStreamH264, ...probeStub.videoStreamH264,
format: { format: {
@ -1035,13 +1014,13 @@ describe(MetadataService.name, () => {
}, },
}); });
await sut.handleMetadataExtraction({ id: assetStub.video.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.upsertExif).toHaveBeenCalled();
expect(mocks.asset.update).toHaveBeenCalledWith( expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
id: assetStub.video.id, id: asset.id,
duration: '00:00:06.210', duration: '00:00:06.210',
}), }),
); );
@ -1070,7 +1049,8 @@ describe(MetadataService.name, () => {
}); });
it('should omit duration of zero', async () => { it('should omit duration of zero', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video); const asset = AssetFactory.create({ type: AssetType.Video });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.media.probe.mockResolvedValue({ mocks.media.probe.mockResolvedValue({
...probeStub.videoStreamH264, ...probeStub.videoStreamH264,
format: { format: {
@ -1079,20 +1059,21 @@ describe(MetadataService.name, () => {
}, },
}); });
await sut.handleMetadataExtraction({ id: assetStub.video.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.upsertExif).toHaveBeenCalled();
expect(mocks.asset.update).toHaveBeenCalledWith( expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
id: assetStub.video.id, id: asset.id,
duration: null, duration: null,
}), }),
); );
}); });
it('should a handle duration of 1 week', async () => { it('should a handle duration of 1 week', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video); const asset = AssetFactory.create({ type: AssetType.Video });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.media.probe.mockResolvedValue({ mocks.media.probe.mockResolvedValue({
...probeStub.videoStreamH264, ...probeStub.videoStreamH264,
format: { format: {
@ -1101,13 +1082,13 @@ describe(MetadataService.name, () => {
}, },
}); });
await sut.handleMetadataExtraction({ id: assetStub.video.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.upsertExif).toHaveBeenCalled(); expect(mocks.asset.upsertExif).toHaveBeenCalled();
expect(mocks.asset.update).toHaveBeenCalledWith( expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({ expect.objectContaining({
id: assetStub.video.id, id: asset.id,
duration: '168:00:00.000', duration: '168:00:00.000',
}), }),
); );
@ -1148,7 +1129,8 @@ describe(MetadataService.name, () => {
}); });
it('should ignore Duration from exif for videos', async () => { it('should ignore Duration from exif for videos', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video); const asset = AssetFactory.create({ type: AssetType.Video });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mockReadTags({ Duration: 123 }, {}); mockReadTags({ Duration: 123 }, {});
mocks.media.probe.mockResolvedValue({ mocks.media.probe.mockResolvedValue({
...probeStub.videoStreamH264, ...probeStub.videoStreamH264,
@ -1158,7 +1140,7 @@ describe(MetadataService.name, () => {
}, },
}); });
await sut.handleMetadataExtraction({ id: assetStub.video.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1); expect(mocks.metadata.readTags).toHaveBeenCalledTimes(1);
expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:07:36.000' })); expect(mocks.asset.update).toHaveBeenCalledWith(expect.objectContaining({ duration: '00:07:36.000' }));
@ -1487,17 +1469,18 @@ describe(MetadataService.name, () => {
}); });
it('should handle not finding a match', async () => { it('should handle not finding a match', async () => {
const asset = AssetFactory.create({ type: AssetType.Video });
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p); mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoMotionAsset); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mockReadTags({ ContentIdentifier: 'CID' }); mockReadTags({ ContentIdentifier: 'CID' });
await sut.handleMetadataExtraction({ id: assetStub.livePhotoMotionAsset.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
livePhotoCID: 'CID', livePhotoCID: 'CID',
ownerId: assetStub.livePhotoMotionAsset.ownerId, ownerId: asset.ownerId,
otherAssetId: assetStub.livePhotoMotionAsset.id, otherAssetId: asset.id,
libraryId: null, libraryId: null,
type: AssetType.Image, type: AssetType.Image,
}); });
@ -1508,65 +1491,67 @@ describe(MetadataService.name, () => {
}); });
it('should link photo and video', async () => { it('should link photo and video', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.livePhotoStillAsset); const motionAsset = AssetFactory.create({ type: AssetType.Video });
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.asset.findLivePhotoMatch.mockResolvedValue(motionAsset);
mockReadTags({ ContentIdentifier: 'CID' }); mockReadTags({ ContentIdentifier: 'CID' });
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.id); expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
libraryId: null,
livePhotoCID: 'CID', livePhotoCID: 'CID',
ownerId: assetStub.livePhotoStillAsset.ownerId, ownerId: asset.ownerId,
otherAssetId: assetStub.livePhotoStillAsset.id, otherAssetId: asset.id,
type: AssetType.Video, type: AssetType.Video,
}); });
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoStillAsset.id, id: asset.id,
livePhotoVideoId: assetStub.livePhotoMotionAsset.id, livePhotoVideoId: motionAsset.id,
}); });
expect(mocks.asset.update).toHaveBeenCalledWith({ expect(mocks.asset.update).toHaveBeenCalledWith({
id: assetStub.livePhotoMotionAsset.id, id: motionAsset.id,
visibility: AssetVisibility.Hidden, visibility: AssetVisibility.Hidden,
}); });
expect(mocks.album.removeAssetsFromAll).toHaveBeenCalledWith([assetStub.livePhotoMotionAsset.id]); expect(mocks.album.removeAssetsFromAll).toHaveBeenCalledWith([motionAsset.id]);
}); });
it('should notify clients on live photo link', async () => { it('should notify clients on live photo link', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ const motionAsset = AssetFactory.create({ type: AssetType.Video });
...assetStub.livePhotoStillAsset, const asset = AssetFactory.create();
}); mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset); mocks.asset.findLivePhotoMatch.mockResolvedValue(motionAsset);
mockReadTags({ ContentIdentifier: 'CID' }); mockReadTags({ ContentIdentifier: 'CID' });
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.event.emit).toHaveBeenCalledWith('AssetHide', { expect(mocks.event.emit).toHaveBeenCalledWith('AssetHide', {
userId: assetStub.livePhotoMotionAsset.ownerId, userId: motionAsset.ownerId,
assetId: assetStub.livePhotoMotionAsset.id, assetId: motionAsset.id,
}); });
}); });
it('should search by libraryId', async () => { it('should search by libraryId', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ const motionAsset = AssetFactory.create({ type: AssetType.Video, libraryId: 'library-id' });
...assetStub.livePhotoStillAsset, const asset = AssetFactory.create({ libraryId: 'library-id' });
libraryId: 'library-id', mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
}); mocks.asset.findLivePhotoMatch.mockResolvedValue(motionAsset);
mocks.asset.findLivePhotoMatch.mockResolvedValue(assetStub.livePhotoMotionAsset);
mockReadTags({ ContentIdentifier: 'CID' }); mockReadTags({ ContentIdentifier: 'CID' });
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id }); await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.event.emit).toHaveBeenCalledWith('AssetMetadataExtracted', { expect(mocks.event.emit).toHaveBeenCalledWith('AssetMetadataExtracted', {
assetId: assetStub.livePhotoStillAsset.id, assetId: asset.id,
userId: assetStub.livePhotoStillAsset.ownerId, userId: asset.ownerId,
}); });
expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({ expect(mocks.asset.findLivePhotoMatch).toHaveBeenCalledWith({
ownerId: 'user-id', ownerId: asset.ownerId,
otherAssetId: 'live-photo-still-asset', otherAssetId: asset.id,
livePhotoCID: 'CID', livePhotoCID: 'CID',
libraryId: 'library-id', libraryId: 'library-id',
type: 'VIDEO', type: AssetType.Video,
}); });
}); });

View file

@ -1,7 +1,6 @@
import { mapAsset } from 'src/dtos/asset-response.dto'; import { mapAsset } from 'src/dtos/asset-response.dto';
import { SyncService } from 'src/services/sync.service'; import { SyncService } from 'src/services/sync.service';
import { AssetFactory } from 'test/factories/asset.factory'; import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub'; import { authStub } from 'test/fixtures/auth.stub';
import { factory } from 'test/small.factory'; import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils'; import { newTestService, ServiceMocks } from 'test/utils';
@ -23,10 +22,14 @@ describe(SyncService.name, () => {
describe('getAllAssetsForUserFullSync', () => { describe('getAllAssetsForUserFullSync', () => {
it('should return a list of all assets owned by the user', async () => { it('should return a list of all assets owned by the user', async () => {
mocks.asset.getAllForUserFullSync.mockResolvedValue([assetStub.external, assetStub.hasEncodedVideo]); const [asset1, asset2] = [
AssetFactory.from({ libraryId: 'library-id', isExternal: true }).owner(authStub.user1.user).build(),
AssetFactory.from().owner(authStub.user1.user).build(),
];
mocks.asset.getAllForUserFullSync.mockResolvedValue([asset1, asset2]);
await expect(sut.getFullSync(authStub.user1, { limit: 2, updatedUntil: untilDate })).resolves.toEqual([ await expect(sut.getFullSync(authStub.user1, { limit: 2, updatedUntil: untilDate })).resolves.toEqual([
mapAsset(assetStub.external, mapAssetOpts), mapAsset(asset1, mapAssetOpts),
mapAsset(assetStub.hasEncodedVideo, mapAssetOpts), mapAsset(asset2, mapAssetOpts),
]); ]);
expect(mocks.asset.getAllForUserFullSync).toHaveBeenCalledWith({ expect(mocks.asset.getAllForUserFullSync).toHaveBeenCalledWith({
ownerId: authStub.user1.user.id, ownerId: authStub.user1.user.id,
@ -73,15 +76,16 @@ describe(SyncService.name, () => {
it('should return a response with changes and deletions', async () => { it('should return a response with changes and deletions', async () => {
const asset = AssetFactory.create({ ownerId: authStub.user1.user.id }); const asset = AssetFactory.create({ ownerId: authStub.user1.user.id });
const deletedAsset = AssetFactory.create({ libraryId: 'library-id', isExternal: true });
mocks.partner.getAll.mockResolvedValue([]); mocks.partner.getAll.mockResolvedValue([]);
mocks.asset.getChangedDeltaSync.mockResolvedValue([asset]); mocks.asset.getChangedDeltaSync.mockResolvedValue([asset]);
mocks.audit.getAfter.mockResolvedValue([assetStub.external.id]); mocks.audit.getAfter.mockResolvedValue([deletedAsset.id]);
await expect( await expect(
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }), sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),
).resolves.toEqual({ ).resolves.toEqual({
needsFullSync: false, needsFullSync: false,
upserted: [mapAsset(asset, mapAssetOpts)], upserted: [mapAsset(asset, mapAssetOpts)],
deleted: [assetStub.external.id], deleted: [deletedAsset.id],
}); });
expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1); expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1);
expect(mocks.audit.getAfter).toHaveBeenCalledTimes(1); expect(mocks.audit.getAfter).toHaveBeenCalledTimes(1);

View file

@ -29,7 +29,7 @@ export class AssetFactory {
static from(dto: AssetLike = {}) { static from(dto: AssetLike = {}) {
const id = dto.id ?? newUuid(); const id = dto.id ?? newUuid();
const originalFileName = dto.originalFileName ?? `IMG_${id}.jpg`; const originalFileName = dto.originalFileName ?? (dto.type === AssetType.Video ? `MOV_${id}.mp4` : `IMG_${id}.jpg`);
return new AssetFactory({ return new AssetFactory({
id, id,

View file

@ -1,10 +1,8 @@
import { AssetFace, AssetFile, Exif } from 'src/database'; import { Exif } from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto'; import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetEditAction, AssetEditActionItem } from 'src/dtos/editing.dto'; import { AssetEditAction, AssetEditActionItem } from 'src/dtos/editing.dto';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum'; import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { StorageAsset } from 'src/types'; import { StorageAsset } from 'src/types';
import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { userStub } from 'test/fixtures/user.stub'; import { userStub } from 'test/fixtures/user.stub';
import { factory } from 'test/small.factory'; import { factory } from 'test/small.factory';
@ -105,300 +103,6 @@ export const assetStub = {
isEdited: false, isEdited: false,
}), }),
trashed: Object.freeze({
id: 'asset-id',
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',
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'),
deletedAt: new Date('2023-02-24T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: false,
duration: null,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
exifImageWidth: 2160,
} as Exif,
duplicateId: null,
isOffline: false,
status: AssetStatus.Trashed,
libraryId: null,
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
trashedOffline: 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',
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'),
deletedAt: new Date('2023-02-24T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: false,
duration: null,
libraryId: 'library-id',
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
exifImageWidth: 2160,
} as Exif,
duplicateId: null,
isOffline: true,
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
archived: 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',
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.jpg',
faces: [],
deletedAt: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
exifImageWidth: 2160,
} as Exif,
duplicateId: null,
isOffline: false,
libraryId: null,
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
external: 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('path 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: 'asset-id.jpg',
faces: [],
deletedAt: null,
exifInfo: {
fileSizeInByte: 5000,
} as Exif,
duplicateId: null,
isOffline: false,
updateId: '42',
stackId: null,
stack: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
video: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
originalFileName: 'asset-id.ext',
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',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Video,
files: [previewFile],
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,
isExternal: false,
duration: null,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
faces: [],
exifInfo: {
fileSizeInByte: 100_000,
exifImageHeight: 2160,
exifImageWidth: 3840,
} as Exif,
deletedAt: null,
duplicateId: null,
isOffline: false,
updateId: '42',
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
livePhotoMotionAsset: Object.freeze({
status: AssetStatus.Active,
id: fileStub.livePhotoMotion.uuid,
originalPath: fileStub.livePhotoMotion.originalPath,
ownerId: authStub.user1.user.id,
type: AssetType.Video,
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
exifInfo: {
fileSizeInByte: 100_000,
timeZone: `America/New_York`,
},
files: [],
libraryId: null,
visibility: AssetVisibility.Hidden,
width: null,
height: null,
edits: [] as AssetEditActionItem[],
isEdited: false,
} as unknown as MapAsset & {
faces: AssetFace[];
files: (AssetFile & { isProgressive: boolean })[];
exifInfo: Exif;
edits: AssetEditActionItem[];
}),
livePhotoStillAsset: Object.freeze({
id: 'live-photo-still-asset',
status: AssetStatus.Active,
originalPath: fileStub.livePhotoStill.originalPath,
ownerId: authStub.user1.user.id,
type: AssetType.Image,
livePhotoVideoId: 'live-photo-motion-asset',
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
exifInfo: {
fileSizeInByte: 25_000,
timeZone: `America/New_York`,
},
files,
faces: [] as AssetFace[],
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [] as AssetEditActionItem[],
isEdited: false,
} as unknown as MapAsset & {
faces: AssetFace[];
files: (AssetFile & { isProgressive: boolean })[];
edits: AssetEditActionItem[];
}),
livePhotoWithOriginalFileName: Object.freeze({
id: 'live-photo-still-asset',
status: AssetStatus.Active,
originalPath: fileStub.livePhotoStill.originalPath,
originalFileName: fileStub.livePhotoStill.originalName,
ownerId: authStub.user1.user.id,
type: AssetType.Image,
livePhotoVideoId: 'live-photo-motion-asset',
fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'),
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
exifInfo: {
fileSizeInByte: 25_000,
timeZone: `America/New_York`,
},
files: [] as AssetFile[],
libraryId: null,
faces: [] as AssetFace[],
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [] as AssetEditActionItem[],
isEdited: false,
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }),
withLocation: Object.freeze({ withLocation: Object.freeze({
id: 'asset-with-favorite-id', id: 'asset-with-favorite-id',
status: AssetStatus.Active, status: AssetStatus.Active,