chore: remove asset stubs (#26187)

This commit is contained in:
Daniel Dietzler
2026-02-13 17:00:31 +01:00
committed by GitHub
parent ecb09501a5
commit 7cb355279e
18 changed files with 624 additions and 655 deletions

View File

@@ -18,7 +18,7 @@ import { ASSET_CHECKSUM_CONSTRAINT } from 'src/utils/database';
import { ImmichFileResponse } from 'src/utils/file';
import { AssetFileFactory } from 'test/factories/asset-file.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub';
import { AuthFactory } from 'test/factories/auth.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { userStub } from 'test/fixtures/user.stub';
@@ -515,28 +515,19 @@ describe(AssetMediaService.name, () => {
});
it('should download edited file by default when edits exist', async () => {
const editedAsset = {
...assetStub.withCropEdit,
files: [
...assetStub.withCropEdit.files,
{
id: 'edited-file',
type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/edited.jpg',
isEdited: true,
},
],
};
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
mocks.asset.getForOriginal.mockResolvedValue({
...editedAsset,
editedPath: '/uploads/user-id/fullsize/edited.jpg',
});
const editedAsset = AssetFactory.from()
.edit()
.files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail])
.file({ type: AssetFileType.FullSize, isEdited: true })
.build();
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual(
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([editedAsset.id]));
mocks.asset.getForOriginal.mockResolvedValue({ ...editedAsset, editedPath: editedAsset.files[3].path });
await expect(sut.downloadOriginal(AuthFactory.create(), editedAsset.id, {})).resolves.toEqual(
new ImmichFileResponse({
path: '/uploads/user-id/fullsize/edited.jpg',
fileName: 'asset-id.jpg',
path: editedAsset.files[3].path,
fileName: editedAsset.originalFileName,
contentType: 'image/jpeg',
cacheControl: CacheControl.PrivateWithCache,
}),
@@ -544,28 +535,19 @@ describe(AssetMediaService.name, () => {
});
it('should download edited file when edited=true', async () => {
const editedAsset = {
...assetStub.withCropEdit,
files: [
...assetStub.withCropEdit.files,
{
id: 'edited-file',
type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/edited.jpg',
isEdited: true,
},
],
};
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
mocks.asset.getForOriginal.mockResolvedValue({
...editedAsset,
editedPath: '/uploads/user-id/fullsize/edited.jpg',
});
const editedAsset = AssetFactory.from()
.edit()
.files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail])
.file({ type: AssetFileType.FullSize, isEdited: true })
.build();
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: true })).resolves.toEqual(
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([editedAsset.id]));
mocks.asset.getForOriginal.mockResolvedValue({ ...editedAsset, editedPath: editedAsset.files[3].path });
await expect(sut.downloadOriginal(AuthFactory.create(), editedAsset.id, { edited: true })).resolves.toEqual(
new ImmichFileResponse({
path: '/uploads/user-id/fullsize/edited.jpg',
fileName: 'asset-id.jpg',
path: editedAsset.files[3].path,
fileName: editedAsset.originalFileName,
contentType: 'image/jpeg',
cacheControl: CacheControl.PrivateWithCache,
}),
@@ -579,7 +561,9 @@ describe(AssetMediaService.name, () => {
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([editedAsset.id]));
mocks.asset.getForOriginal.mockResolvedValue({ ...editedAsset, editedPath: fullsizeEdited.path });
await expect(sut.downloadOriginal(authStub.adminSharedLink, editedAsset.id, { edited: false })).resolves.toEqual(
await expect(
sut.downloadOriginal(AuthFactory.from().sharedLink().build(), editedAsset.id, { edited: false }),
).resolves.toEqual(
new ImmichFileResponse({
path: fullsizeEdited.path,
fileName: editedAsset.originalFileName,
@@ -590,25 +574,19 @@ describe(AssetMediaService.name, () => {
});
it('should download original file when edited=false', async () => {
const editedAsset = {
...assetStub.withCropEdit,
files: [
...assetStub.withCropEdit.files,
{
id: 'edited-file',
type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/edited.jpg',
isEdited: true,
},
],
};
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1']));
const editedAsset = AssetFactory.from()
.edit()
.files([AssetFileType.FullSize, AssetFileType.Preview, AssetFileType.Thumbnail])
.file({ type: AssetFileType.FullSize, isEdited: true })
.build();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([editedAsset.id]));
mocks.asset.getForOriginal.mockResolvedValue(editedAsset);
await expect(sut.downloadOriginal(authStub.admin, 'asset-1', { edited: false })).resolves.toEqual(
await expect(sut.downloadOriginal(AuthFactory.create(), editedAsset.id, { edited: false })).resolves.toEqual(
new ImmichFileResponse({
path: '/original/path.jpg',
fileName: 'asset-id.jpg',
path: editedAsset.originalPath,
fileName: editedAsset.originalFileName,
contentType: 'image/jpeg',
cacheControl: CacheControl.PrivateWithCache,
}),

View File

@@ -7,7 +7,6 @@ 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 { factory, newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
@@ -586,19 +585,19 @@ describe(AssetService.name, () => {
});
it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => {
const asset = AssetFactory.from()
.stack({}, (builder) => builder.asset())
.build();
mocks.stack.delete.mockResolvedValue();
mocks.assetJob.getForAssetDeletion.mockResolvedValue({
...assetStub.primaryImage,
stack: {
id: 'stack-id',
primaryAssetId: assetStub.primaryImage.id,
assets: [{ id: 'one-asset' }],
},
...asset,
// TODO the specific query filters out the primary asset from `stack.assets`. This should be in a mapper eventually
stack: { ...asset.stack!, assets: asset.stack!.assets.filter(({ id }) => id !== asset.stack!.primaryAssetId) },
});
await sut.handleAssetDeletion({ id: assetStub.primaryImage.id, deleteOnDisk: true });
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
expect(mocks.stack.delete).toHaveBeenCalledWith('stack-id');
expect(mocks.stack.delete).toHaveBeenCalledWith(asset.stackId);
});
it('should delete a live photo', async () => {

View File

@@ -2,7 +2,6 @@ import { AssetType, AssetVisibility, JobName, JobStatus } from 'src/enum';
import { DuplicateService } from 'src/services/duplicate.service';
import { SearchService } from 'src/services/search.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
@@ -184,13 +183,13 @@ describe(SearchService.name, () => {
});
it('should skip if asset is part of stack', async () => {
const id = assetStub.primaryImage.id;
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, stackId: 'stack-id' });
const asset = AssetFactory.from().stack().build();
mocks.assetJob.getForSearchDuplicatesJob.mockResolvedValue({ ...hasEmbedding, stackId: asset.stackId });
const result = await sut.handleSearchDuplicates({ id });
const result = await sut.handleSearchDuplicates({ id: asset.id });
expect(result).toBe(JobStatus.Skipped);
expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${id} is part of a stack, skipping`);
expect(mocks.logger.debug).toHaveBeenCalledWith(`Asset ${asset.id} is part of a stack, skipping`);
});
it('should skip if asset is not visible', async () => {

View File

@@ -1,7 +1,7 @@
import { MapService } from 'src/services/map.service';
import { AlbumFactory } from 'test/factories/album.factory';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { userStub } from 'test/fixtures/user.stub';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@@ -16,36 +16,41 @@ describe(MapService.name, () => {
describe('getMapMarkers', () => {
it('should get geo information of assets', async () => {
const asset = assetStub.withLocation;
const auth = AuthFactory.create();
const asset = AssetFactory.from()
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
.build();
const marker = {
id: asset.id,
lat: asset.exifInfo!.latitude!,
lon: asset.exifInfo!.longitude!,
city: asset.exifInfo!.city,
state: asset.exifInfo!.state,
country: asset.exifInfo!.country,
lat: asset.exifInfo.latitude!,
lon: asset.exifInfo.longitude!,
city: asset.exifInfo.city,
state: asset.exifInfo.state,
country: asset.exifInfo.country,
};
mocks.partner.getAll.mockResolvedValue([]);
mocks.map.getMapMarkers.mockResolvedValue([marker]);
const markers = await sut.getMapMarkers(authStub.user1, {});
const markers = await sut.getMapMarkers(auth, {});
expect(markers).toHaveLength(1);
expect(markers[0]).toEqual(marker);
});
it('should include partner assets', async () => {
const partner = factory.partner();
const auth = factory.auth({ user: { id: partner.sharedWithId } });
const auth = AuthFactory.create();
const partner = factory.partner({ sharedWithId: auth.user.id });
const asset = assetStub.withLocation;
const asset = AssetFactory.from()
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
.build();
const marker = {
id: asset.id,
lat: asset.exifInfo!.latitude!,
lon: asset.exifInfo!.longitude!,
city: asset.exifInfo!.city,
state: asset.exifInfo!.state,
country: asset.exifInfo!.country,
lat: asset.exifInfo.latitude!,
lon: asset.exifInfo.longitude!,
city: asset.exifInfo.city,
state: asset.exifInfo.state,
country: asset.exifInfo.country,
};
mocks.partner.getAll.mockResolvedValue([partner]);
mocks.map.getMapMarkers.mockResolvedValue([marker]);
@@ -62,21 +67,24 @@ describe(MapService.name, () => {
});
it('should include assets from shared albums', async () => {
const asset = assetStub.withLocation;
const auth = AuthFactory.create(userStub.user1);
const asset = AssetFactory.from()
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
.build();
const marker = {
id: asset.id,
lat: asset.exifInfo!.latitude!,
lon: asset.exifInfo!.longitude!,
city: asset.exifInfo!.city,
state: asset.exifInfo!.state,
country: asset.exifInfo!.country,
lat: asset.exifInfo.latitude!,
lon: asset.exifInfo.longitude!,
city: asset.exifInfo.city,
state: asset.exifInfo.state,
country: asset.exifInfo.country,
};
mocks.partner.getAll.mockResolvedValue([]);
mocks.map.getMapMarkers.mockResolvedValue([marker]);
mocks.album.getOwned.mockResolvedValue([AlbumFactory.create()]);
mocks.album.getShared.mockResolvedValue([AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()]);
const markers = await sut.getMapMarkers(authStub.user1, { withSharedAlbums: true });
const markers = await sut.getMapMarkers(auth, { withSharedAlbums: true });
expect(markers).toHaveLength(1);
expect(markers[0]).toEqual(marker);

View File

@@ -22,7 +22,6 @@ import {
import { MediaService } from 'src/services/media.service';
import { JobCounts, RawImageInfo } from 'src/types';
import { AssetFactory } from 'test/factories/asset.factory';
import { assetStub } from 'test/fixtures/asset.stub';
import { faceStub } from 'test/fixtures/face.stub';
import { probeStub } from 'test/fixtures/media.stub';
import { personStub, personThumbnailStub } from 'test/fixtures/person.stub';
@@ -205,7 +204,8 @@ describe(MediaService.name, () => {
});
it('should queue assets with edits but missing edited thumbnails', async () => {
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit]));
const asset = AssetFactory.from().edit().build();
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
@@ -213,7 +213,7 @@ describe(MediaService.name, () => {
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.AssetEditThumbnailGeneration,
data: { id: assetStub.withCropEdit.id },
data: { id: asset.id },
},
]);
@@ -221,8 +221,9 @@ describe(MediaService.name, () => {
});
it('should not queue assets with missing edited fullsize when feature is disabled', async () => {
const asset = AssetFactory.from().edit().build();
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: false } } });
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit]));
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: false });
@@ -251,7 +252,8 @@ describe(MediaService.name, () => {
});
it('should queue both regular and edited thumbnails for assets with edits when force is true', async () => {
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([assetStub.withCropEdit]));
const asset = AssetFactory.from().edit().build();
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
mocks.person.getAll.mockReturnValue(makeStream());
await sut.handleQueueGenerateThumbnails({ force: true });
@@ -259,11 +261,11 @@ describe(MediaService.name, () => {
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
name: JobName.AssetGenerateThumbnails,
data: { id: assetStub.withCropEdit.id },
data: { id: asset.id },
},
{
name: JobName.AssetEditThumbnailGeneration,
data: { id: assetStub.withCropEdit.id },
data: { id: asset.id },
},
]);
@@ -1504,7 +1506,7 @@ describe(MediaService.name, () => {
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id);
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.previewPath, {
expect(mocks.media.decodeImage).toHaveBeenCalledWith(expect.any(String), {
colorspace: Colorspace.P3,
orientation: undefined,
processInvalidImages: false,
@@ -2193,7 +2195,7 @@ describe(MediaService.name, () => {
});
it('should delete existing transcode if current policy does not require transcoding', async () => {
const asset = assetStub.hasEncodedVideo;
const asset = AssetFactory.create({ type: AssetType.Video, encodedVideoPath: '/encoded/video/path.mp4' });
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { transcode: TranscodePolicy.Disabled } });
mocks.assetJob.getForVideoConversion.mockResolvedValue(asset);

View File

@@ -16,7 +16,6 @@ import {
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 { probeStub } from 'test/fixtures/media.stub';
import { personStub } from 'test/fixtures/person.stub';
import { tagStub } from 'test/fixtures/tag.stub';
@@ -1227,16 +1226,17 @@ describe(MetadataService.name, () => {
expect(mocks.person.updateAll).not.toHaveBeenCalled();
});
it('should apply metadata face tags creating new persons', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage);
it('should apply metadata face tags creating new people', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
mocks.person.getDistinctNames.mockResolvedValue([]);
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
mocks.person.update.mockResolvedValue(personStub.withName);
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id);
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true });
expect(mocks.person.createAll).toHaveBeenCalledWith([
expect.objectContaining({ name: personStub.withName.name }),
]);
@@ -1244,7 +1244,7 @@ describe(MetadataService.name, () => {
[
{
id: 'random-uuid',
assetId: assetStub.primaryImage.id,
assetId: asset.id,
personId: 'random-uuid',
imageHeight: 100,
imageWidth: 1000,
@@ -1258,7 +1258,7 @@ describe(MetadataService.name, () => {
[],
);
expect(mocks.person.updateAll).toHaveBeenCalledWith([
{ id: 'random-uuid', ownerId: 'admin-id', faceAssetId: 'random-uuid' },
{ id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' },
]);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{
@@ -1269,21 +1269,22 @@ describe(MetadataService.name, () => {
});
it('should assign metadata face tags to existing persons', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage);
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
mocks.person.getDistinctNames.mockResolvedValue([{ id: personStub.withName.id, name: personStub.withName.name }]);
mocks.person.createAll.mockResolvedValue([]);
mocks.person.update.mockResolvedValue(personStub.withName);
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id);
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, { withHidden: true });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true });
expect(mocks.person.createAll).not.toHaveBeenCalled();
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
[
{
id: 'random-uuid',
assetId: assetStub.primaryImage.id,
assetId: asset.id,
personId: personStub.withName.id,
imageHeight: 100,
imageWidth: 1000,
@@ -1353,16 +1354,17 @@ describe(MetadataService.name, () => {
'should transform RegionInfo geometry according to exif orientation $description',
async ({ orientation, expected }) => {
const { imgW, imgH, x1, x2, y1, y2 } = expected;
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.primaryImage);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(makeFaceTags({ Name: personStub.withName.name }, orientation));
mocks.person.getDistinctNames.mockResolvedValue([]);
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
mocks.person.update.mockResolvedValue(personStub.withName);
await sut.handleMetadataExtraction({ id: assetStub.primaryImage.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.primaryImage.id);
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(assetStub.primaryImage.ownerId, {
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, {
withHidden: true,
});
expect(mocks.person.createAll).toHaveBeenCalledWith([
@@ -1372,7 +1374,7 @@ describe(MetadataService.name, () => {
[
{
id: 'random-uuid',
assetId: assetStub.primaryImage.id,
assetId: asset.id,
personId: 'random-uuid',
imageWidth: imgW,
imageHeight: imgH,
@@ -1386,7 +1388,7 @@ describe(MetadataService.name, () => {
[],
);
expect(mocks.person.updateAll).toHaveBeenCalledWith([
{ id: 'random-uuid', ownerId: 'admin-id', faceAssetId: 'random-uuid' },
{ id: 'random-uuid', ownerId: asset.ownerId, faceAssetId: 'random-uuid' },
]);
expect(mocks.job.queueAll).toHaveBeenCalledWith([
{

View File

@@ -2,7 +2,8 @@ import { BadRequestException } from '@nestjs/common';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { SearchSuggestionType } from 'src/dtos/search.dto';
import { SearchService } from 'src/services/search.service';
import { assetStub } from 'test/fixtures/asset.stub';
import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { personStub } from 'test/fixtures/person.stub';
import { newTestService, ServiceMocks } from 'test/utils';
@@ -64,16 +65,18 @@ describe(SearchService.name, () => {
describe('getExploreData', () => {
it('should get assets by city and tag', async () => {
const auth = AuthFactory.create();
const asset = AssetFactory.from()
.exif({ latitude: 42, longitude: 69, city: 'city', state: 'state', country: 'country' })
.build();
mocks.asset.getAssetIdByCity.mockResolvedValue({
fieldName: 'exifInfo.city',
items: [{ value: 'test-city', data: assetStub.withLocation.id }],
items: [{ value: 'city', data: asset.id }],
});
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([assetStub.withLocation]);
const expectedResponse = [
{ fieldName: 'exifInfo.city', items: [{ value: 'test-city', data: mapAsset(assetStub.withLocation) }] },
];
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([asset as never]);
const expectedResponse = [{ fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(asset) }] }];
const result = await sut.getExploreData(authStub.user1);
const result = await sut.getExploreData(auth);
expect(result).toEqual(expectedResponse);
});

View File

@@ -1,7 +1,8 @@
import { BadRequestException } from '@nestjs/common';
import { StackService } from 'src/services/stack.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { stackStub } from 'test/fixtures/asset.stub';
import { AuthFactory } from 'test/factories/auth.factory';
import { StackFactory } from 'test/factories/stack.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@@ -20,12 +21,14 @@ describe(StackService.name, () => {
describe('search', () => {
it('should search stacks', async () => {
const auth = AuthFactory.create();
const asset = AssetFactory.create();
mocks.stack.search.mockResolvedValue([stackStub('stack-id', [asset])]);
const stack = StackFactory.from().primaryAsset(asset).build();
mocks.stack.search.mockResolvedValue([stack]);
await sut.search(authStub.admin, { primaryAssetId: asset.id });
await sut.search(auth, { primaryAssetId: asset.id });
expect(mocks.stack.search).toHaveBeenCalledWith({
ownerId: authStub.admin.user.id,
ownerId: auth.user.id,
primaryAssetId: asset.id,
});
});
@@ -33,8 +36,10 @@ describe(StackService.name, () => {
describe('create', () => {
it('should require asset.update permissions', async () => {
const auth = AuthFactory.create();
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
await expect(sut.create(authStub.admin, { assetIds: [primaryAsset.id, asset.id] })).rejects.toBeInstanceOf(
await expect(sut.create(auth, { assetIds: [primaryAsset.id, asset.id] })).rejects.toBeInstanceOf(
BadRequestException,
);
@@ -43,18 +48,22 @@ describe(StackService.name, () => {
});
it('should create a stack', async () => {
const auth = AuthFactory.create();
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([primaryAsset.id, asset.id]));
mocks.stack.create.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
await expect(sut.create(authStub.admin, { assetIds: [primaryAsset.id, asset.id] })).resolves.toEqual({
id: 'stack-id',
mocks.stack.create.mockResolvedValue(stack);
await expect(sut.create(auth, { assetIds: [primaryAsset.id, asset.id] })).resolves.toEqual({
id: stack.id,
primaryAssetId: primaryAsset.id,
assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })],
});
expect(mocks.event.emit).toHaveBeenCalledWith('StackCreate', {
stackId: 'stack-id',
userId: authStub.admin.user.id,
stackId: stack.id,
userId: auth.user.id,
});
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalled();
});
@@ -78,23 +87,26 @@ describe(StackService.name, () => {
});
it('should get stack', async () => {
const auth = AuthFactory.create();
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build();
await expect(sut.get(authStub.admin, 'stack-id')).resolves.toEqual({
id: 'stack-id',
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id]));
mocks.stack.getById.mockResolvedValue(stack);
await expect(sut.get(auth, stack.id)).resolves.toEqual({
id: stack.id,
primaryAssetId: primaryAsset.id,
assets: [expect.objectContaining({ id: primaryAsset.id }), expect.objectContaining({ id: asset.id })],
});
expect(mocks.access.stack.checkOwnerAccess).toHaveBeenCalled();
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id');
expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id);
});
});
describe('update', () => {
it('should require stack.update permissions', async () => {
await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.update(AuthFactory.create(), 'stack-id', {})).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.stack.getById).not.toHaveBeenCalled();
expect(mocks.stack.update).not.toHaveBeenCalled();
@@ -104,7 +116,7 @@ describe(StackService.name, () => {
it('should fail if stack could not be found', async () => {
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
await expect(sut.update(authStub.admin, 'stack-id', {})).rejects.toBeInstanceOf(Error);
await expect(sut.update(AuthFactory.create(), 'stack-id', {})).rejects.toBeInstanceOf(Error);
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id');
expect(mocks.stack.update).not.toHaveBeenCalled();
@@ -112,57 +124,64 @@ describe(StackService.name, () => {
});
it('should fail if the provided primary asset id is not in the stack', async () => {
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
const auth = AuthFactory.create();
const stack = StackFactory.from().primaryAsset().asset().build();
await expect(sut.update(authStub.admin, 'stack-id', { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf(
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id]));
mocks.stack.getById.mockResolvedValue(stack);
await expect(sut.update(auth, stack.id, { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf(
BadRequestException,
);
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id');
expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id);
expect(mocks.stack.update).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled();
});
it('should update stack', async () => {
const auth = AuthFactory.create();
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
mocks.stack.getById.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
mocks.stack.update.mockResolvedValue(stackStub('stack-id', [primaryAsset, asset]));
const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build();
await sut.update(authStub.admin, 'stack-id', { primaryAssetId: asset.id });
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id]));
mocks.stack.getById.mockResolvedValue(stack);
mocks.stack.update.mockResolvedValue(stack);
expect(mocks.stack.getById).toHaveBeenCalledWith('stack-id');
expect(mocks.stack.update).toHaveBeenCalledWith('stack-id', {
id: 'stack-id',
await sut.update(auth, stack.id, { primaryAssetId: asset.id });
expect(mocks.stack.getById).toHaveBeenCalledWith(stack.id);
expect(mocks.stack.update).toHaveBeenCalledWith(stack.id, {
id: stack.id,
primaryAssetId: asset.id,
});
expect(mocks.event.emit).toHaveBeenCalledWith('StackUpdate', {
stackId: 'stack-id',
userId: authStub.admin.user.id,
stackId: stack.id,
userId: auth.user.id,
});
});
});
describe('delete', () => {
it('should require stack.delete permissions', async () => {
await expect(sut.delete(authStub.admin, 'stack-id')).rejects.toBeInstanceOf(BadRequestException);
await expect(sut.delete(AuthFactory.create(), 'stack-id')).rejects.toBeInstanceOf(BadRequestException);
expect(mocks.stack.delete).not.toHaveBeenCalled();
expect(mocks.event.emit).not.toHaveBeenCalled();
});
it('should delete stack', async () => {
const auth = AuthFactory.create();
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set(['stack-id']));
mocks.stack.delete.mockResolvedValue();
await sut.delete(authStub.admin, 'stack-id');
await sut.delete(auth, 'stack-id');
expect(mocks.stack.delete).toHaveBeenCalledWith('stack-id');
expect(mocks.event.emit).toHaveBeenCalledWith('StackDelete', {
stackId: 'stack-id',
userId: authStub.admin.user.id,
userId: auth.user.id,
});
});
});

View File

@@ -1,16 +1,14 @@
import { Stats } from 'node:fs';
import { defaults, SystemConfig } from 'src/config';
import { AssetPathType, JobStatus } from 'src/enum';
import { AssetPathType, AssetType, JobStatus } from 'src/enum';
import { StorageTemplateService } from 'src/services/storage-template.service';
import { AlbumFactory } from 'test/factories/album.factory';
import { assetStub } from 'test/fixtures/asset.stub';
import { AssetFactory } from 'test/factories/asset.factory';
import { UserFactory } from 'test/factories/user.factory';
import { userStub } from 'test/fixtures/user.stub';
import { factory } from 'test/small.factory';
import { getForStorageTemplate } from 'test/mappers';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
const motionAsset = assetStub.storageAsset({});
const stillAsset = assetStub.storageAsset({ livePhotoVideoId: motionAsset.id });
describe(StorageTemplateService.name, () => {
let sut: StorageTemplateService;
let mocks: ServiceMocks;
@@ -110,12 +108,27 @@ describe(StorageTemplateService.name, () => {
});
it('should migrate single moving picture', async () => {
const motionAsset = AssetFactory.from({
type: AssetType.Video,
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.exif()
.build();
const stillAsset = AssetFactory.from({
livePhotoVideoId: motionAsset.id,
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.exif()
.build();
mocks.user.get.mockResolvedValue(userStub.user1);
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`;
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`;
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(stillAsset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(stillAsset));
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
mocks.move.create.mockResolvedValueOnce({
id: '123',
@@ -141,8 +154,8 @@ describe(StorageTemplateService.name, () => {
});
it('should use handlebar if condition for album', async () => {
const asset = assetStub.storageAsset();
const user = userStub.user1;
const user = UserFactory.create();
const asset = AssetFactory.from().owner(user).exif().build();
const album = AlbumFactory.from().asset().build();
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
@@ -150,7 +163,7 @@ describe(StorageTemplateService.name, () => {
sut.onConfigInit({ newConfig: config });
mocks.user.get.mockResolvedValue(user);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset));
mocks.album.getByAssetId.mockResolvedValueOnce([album]);
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success);
@@ -166,14 +179,14 @@ describe(StorageTemplateService.name, () => {
});
it('should use handlebar else condition for album', async () => {
const asset = assetStub.storageAsset();
const user = userStub.user1;
const user = UserFactory.create();
const asset = AssetFactory.from().owner(user).exif().build();
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other//{{MM}}{{/if}}/{{filename}}';
sut.onConfigInit({ newConfig: config });
mocks.user.get.mockResolvedValue(user);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset));
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success);
@@ -189,8 +202,8 @@ describe(StorageTemplateService.name, () => {
});
it('should handle album startDate', async () => {
const asset = assetStub.storageAsset();
const user = userStub.user1;
const user = UserFactory.create();
const asset = AssetFactory.from().owner(user).exif().build();
const album = AlbumFactory.from().asset().build();
const config = structuredClone(defaults);
config.storageTemplate.template =
@@ -199,7 +212,7 @@ describe(StorageTemplateService.name, () => {
sut.onConfigInit({ newConfig: config });
mocks.user.get.mockResolvedValue(user);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset));
mocks.album.getByAssetId.mockResolvedValueOnce([album]);
mocks.album.getMetadataForIds.mockResolvedValueOnce([
{
@@ -225,8 +238,8 @@ describe(StorageTemplateService.name, () => {
});
it('should handle else condition from album startDate', async () => {
const asset = assetStub.storageAsset();
const user = userStub.user1;
const user = UserFactory.create();
const asset = AssetFactory.from().owner(user).exif().build();
const config = structuredClone(defaults);
config.storageTemplate.template =
'{{#if album}}{{album-startDate-y}}/{{album-startDate-MM}} - {{album}}{{else}}{{y}}/{{MM}}/{{/if}}/{{filename}}';
@@ -234,7 +247,7 @@ describe(StorageTemplateService.name, () => {
sut.onConfigInit({ newConfig: config });
mocks.user.get.mockResolvedValue(user);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(asset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset));
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success);
@@ -248,11 +261,18 @@ describe(StorageTemplateService.name, () => {
});
it('should migrate previously failed move from original path when it still exists', async () => {
mocks.user.get.mockResolvedValue(userStub.user1);
const user = UserFactory.create();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.owner(user)
.exif()
.build();
const asset = assetStub.storageAsset();
const previousFailedNewPath = `/data/library/${userStub.user1.id}/2023/Feb/${asset.originalFileName}`;
const newPath = `/data/library/${userStub.user1.id}/2022/2022-06-19/${asset.originalFileName}`;
mocks.user.get.mockResolvedValue(user);
const previousFailedNewPath = `/data/library/${user.id}/2023/Feb/${asset.originalFileName}`;
const newPath = `/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`;
mocks.storage.checkFileExists.mockImplementation((path) => Promise.resolve(path === asset.originalPath));
mocks.move.getByEntity.mockResolvedValue({
@@ -262,7 +282,7 @@ describe(StorageTemplateService.name, () => {
oldPath: asset.originalPath,
newPath: previousFailedNewPath,
});
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(asset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(asset));
mocks.move.update.mockResolvedValue({
id: '123',
entityId: asset.id,
@@ -288,9 +308,16 @@ describe(StorageTemplateService.name, () => {
});
it('should migrate previously failed move from previous new path when old path no longer exists, should validate file size still matches before moving', async () => {
mocks.user.get.mockResolvedValue(userStub.user1);
const user = UserFactory.create();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.owner(user)
.exif({ fileSizeInByte: 5000 })
.build();
mocks.user.get.mockResolvedValue(user);
const asset = assetStub.storageAsset({ fileSizeInByte: 5000 });
const previousFailedNewPath = `/data/library/${asset.ownerId}/2022/June/${asset.originalFileName}`;
const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
@@ -304,7 +331,7 @@ describe(StorageTemplateService.name, () => {
oldPath: asset.originalPath,
newPath: previousFailedNewPath,
});
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(asset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(asset));
mocks.move.update.mockResolvedValue({
id: '123',
entityId: asset.id,
@@ -325,45 +352,53 @@ describe(StorageTemplateService.name, () => {
});
it('should fail move if copying and hash of asset and the new file do not match', async () => {
mocks.user.get.mockResolvedValue(userStub.user1);
const newPath = `/data/library/${userStub.user1.id}/2022/2022-06-19/${testAsset.originalFileName}`;
const user = UserFactory.create();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.owner(user)
.exif()
.build();
mocks.user.get.mockResolvedValue(user);
const newPath = `/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`;
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' });
mocks.storage.stat.mockResolvedValue({ size: 5000 } as Stats);
mocks.crypto.hashFile.mockResolvedValue(Buffer.from('different-hash', 'utf8'));
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(testAsset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(asset));
mocks.move.create.mockResolvedValue({
id: '123',
entityId: testAsset.id,
entityId: asset.id,
pathType: AssetPathType.Original,
oldPath: testAsset.originalPath,
oldPath: asset.originalPath,
newPath,
});
await expect(sut.handleMigrationSingle({ id: testAsset.id })).resolves.toBe(JobStatus.Success);
await expect(sut.handleMigrationSingle({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(testAsset.id);
expect(mocks.assetJob.getForStorageTemplateJob).toHaveBeenCalledWith(asset.id);
expect(mocks.storage.checkFileExists).toHaveBeenCalledTimes(1);
expect(mocks.storage.stat).toHaveBeenCalledWith(newPath);
expect(mocks.move.create).toHaveBeenCalledWith({
entityId: testAsset.id,
entityId: asset.id,
pathType: AssetPathType.Original,
oldPath: testAsset.originalPath,
oldPath: asset.originalPath,
newPath,
});
expect(mocks.storage.rename).toHaveBeenCalledWith(testAsset.originalPath, newPath);
expect(mocks.storage.copyFile).toHaveBeenCalledWith(testAsset.originalPath, newPath);
expect(mocks.storage.rename).toHaveBeenCalledWith(asset.originalPath, newPath);
expect(mocks.storage.copyFile).toHaveBeenCalledWith(asset.originalPath, newPath);
expect(mocks.storage.unlink).toHaveBeenCalledWith(newPath);
expect(mocks.storage.unlink).toHaveBeenCalledTimes(1);
expect(mocks.asset.update).not.toHaveBeenCalled();
});
const testAsset = assetStub.storageAsset();
const testAsset = AssetFactory.from().exif({ fileSizeInByte: 12_345 }).build();
it.each`
failedPathChecksum | failedPathSize | reason
${testAsset.checksum} | ${500} | ${'file size'}
${Buffer.from('bad checksum', 'utf8')} | ${testAsset.fileSizeInByte} | ${'checksum'}
failedPathChecksum | failedPathSize | reason
${testAsset.checksum} | ${500} | ${'file size'}
${Buffer.from('bad checksum', 'utf8')} | ${testAsset.exifInfo.fileSizeInByte} | ${'checksum'}
`(
'should fail to migrate previously failed move from previous new path when old path no longer exists if $reason validation fails',
async ({ failedPathChecksum, failedPathSize }) => {
@@ -381,7 +416,7 @@ describe(StorageTemplateService.name, () => {
oldPath: testAsset.originalPath,
newPath: previousFailedNewPath,
});
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(testAsset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValue(getForStorageTemplate(testAsset));
mocks.move.update.mockResolvedValue({
id: '123',
entityId: testAsset.id,
@@ -414,12 +449,17 @@ describe(StorageTemplateService.name, () => {
});
it('should handle an asset with a duplicate destination', async () => {
const asset = assetStub.storageAsset();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.exif()
.build();
const oldPath = asset.originalPath;
const newPath = `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
const newPath2 = newPath.replace('.jpg', '+1.jpg');
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.move.create.mockResolvedValue({
id: '123',
@@ -441,9 +481,13 @@ describe(StorageTemplateService.name, () => {
});
it('should skip when an asset already matches the template', async () => {
const asset = assetStub.storageAsset({ originalPath: '/data/library/user-id/2023/2023-02-23/asset-id.jpg' });
const asset = AssetFactory.from({
originalPath: '/data/library/user-id/2023/2023-02-23/asset-id.jpg',
})
.exif()
.build();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.user.getList.mockResolvedValue([userStub.user1]);
await sut.handleMigration();
@@ -456,9 +500,13 @@ describe(StorageTemplateService.name, () => {
});
it('should skip when an asset is probably a duplicate', async () => {
const asset = assetStub.storageAsset({ originalPath: '/data/library/user-id/2023/2023-02-23/asset-id+1.jpg' });
const asset = AssetFactory.from({
originalPath: '/data/library/user-id/2023/2023-02-23/asset-id+1.jpg',
})
.exif()
.build();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.user.getList.mockResolvedValue([userStub.user1]);
await sut.handleMigration();
@@ -471,10 +519,15 @@ describe(StorageTemplateService.name, () => {
});
it('should move an asset', async () => {
const asset = assetStub.storageAsset();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.exif()
.build();
const oldPath = asset.originalPath;
const newPath = `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.move.create.mockResolvedValue({
id: '123',
@@ -492,9 +545,15 @@ describe(StorageTemplateService.name, () => {
});
it('should use the user storage label', async () => {
const user = factory.userAdmin({ storageLabel: 'label-1' });
const asset = assetStub.storageAsset({ ownerId: user.id });
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
const user = UserFactory.create({ storageLabel: 'label-1' });
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.owner(user)
.exif()
.build();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.user.getList.mockResolvedValue([user]);
mocks.move.create.mockResolvedValue({
id: '123',
@@ -508,7 +567,7 @@ describe(StorageTemplateService.name, () => {
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
'/original/path.jpg',
asset.originalPath,
expect.stringContaining(`/data/library/${user.storageLabel}/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.asset.update).toHaveBeenCalledWith({
@@ -520,10 +579,16 @@ describe(StorageTemplateService.name, () => {
});
it('should copy the file if rename fails due to EXDEV (rename across filesystems)', async () => {
const asset = assetStub.storageAsset({ originalPath: '/path/to/original.jpg', fileSizeInByte: 5000 });
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
originalPath: '/path/to/original.jpg',
})
.exif({ fileSizeInByte: 5000 })
.build();
const oldPath = asset.originalPath;
const newPath = `/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`;
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
const newPath = `/data/library/${asset.ownerId}/2022/2022-06-19/${asset.originalFileName}`;
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' });
mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.move.create.mockResolvedValue({
@@ -561,10 +626,17 @@ describe(StorageTemplateService.name, () => {
});
it('should not update the database if the move fails due to incorrect newPath filesize', async () => {
const asset = assetStub.storageAsset();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
const user = UserFactory.create();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.owner(user)
.exif()
.build();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.storage.rename.mockRejectedValue({ code: 'EXDEV' });
mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.user.getList.mockResolvedValue([user]);
mocks.move.create.mockResolvedValue({
id: '123',
entityId: asset.id,
@@ -580,22 +652,29 @@ describe(StorageTemplateService.name, () => {
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
'/original/path.jpg',
expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
asset.originalPath,
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.storage.copyFile).toHaveBeenCalledWith(
'/original/path.jpg',
expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
asset.originalPath,
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.storage.stat).toHaveBeenCalledWith(
expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.asset.update).not.toHaveBeenCalled();
});
it('should not update the database if the move fails', async () => {
const asset = assetStub.storageAsset();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
const user = UserFactory.create();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.owner(user)
.exif()
.build();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.storage.rename.mockRejectedValue(new Error('Read only system'));
mocks.storage.copyFile.mockRejectedValue(new Error('Read only system'));
mocks.move.create.mockResolvedValue({
@@ -605,25 +684,37 @@ describe(StorageTemplateService.name, () => {
oldPath: asset.originalPath,
newPath: '',
});
mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.user.getList.mockResolvedValue([user]);
await sut.handleMigration();
expect(mocks.assetJob.streamForStorageTemplateJob).toHaveBeenCalled();
expect(mocks.storage.rename).toHaveBeenCalledWith(
'/original/path.jpg',
expect.stringContaining(`/data/library/user-id/2022/2022-06-19/${asset.originalFileName}`),
asset.originalPath,
expect.stringContaining(`/data/library/${user.id}/2022/2022-06-19/${asset.originalFileName}`),
);
expect(mocks.asset.update).not.toHaveBeenCalled();
});
it('should migrate live photo motion video alongside the still image', async () => {
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${motionAsset.originalFileName}`;
const motionAsset = AssetFactory.from({
type: AssetType.Video,
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.exif()
.build();
const stillAsset = AssetFactory.from({
livePhotoVideoId: motionAsset.id,
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
})
.exif()
.build();
const newMotionPicturePath = `/data/library/${motionAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName.slice(0, -4)}.mp4`;
const newStillPicturePath = `/data/library/${stillAsset.ownerId}/2022/2022-06-19/${stillAsset.originalFileName}`;
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([stillAsset]));
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)]));
mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(motionAsset);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
mocks.move.create.mockResolvedValueOnce({
id: '123',
@@ -653,13 +744,17 @@ describe(StorageTemplateService.name, () => {
describe('file rename correctness', () => {
it('should not create double extensions when filename has lower extension', async () => {
const user = factory.userAdmin({ storageLabel: 'label-1' });
const asset = assetStub.storageAsset({
ownerId: user.id,
const user = UserFactory.create({ storageLabel: 'label-1' });
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.heic`,
originalFileName: 'IMG_7065.HEIC',
});
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
})
.owner(user)
.exif()
.build();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.user.getList.mockResolvedValue([user]);
mocks.move.create.mockResolvedValue({
id: '123',
@@ -679,13 +774,17 @@ describe(StorageTemplateService.name, () => {
});
it('should not create double extensions when filename has uppercase extension', async () => {
const user = factory.userAdmin();
const asset = assetStub.storageAsset({
ownerId: user.id,
const user = UserFactory.create();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.HEIC`,
originalFileName: 'IMG_7065.HEIC',
});
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
})
.owner(user)
.exif({ fileSizeInByte: 12_345 })
.build();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.user.getList.mockResolvedValue([user]);
mocks.move.create.mockResolvedValue({
id: '123',
@@ -705,13 +804,17 @@ describe(StorageTemplateService.name, () => {
});
it('should normalize the filename to lowercase (JPEG > jpg)', async () => {
const user = factory.userAdmin();
const asset = assetStub.storageAsset({
ownerId: user.id,
const user = UserFactory.create();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
originalPath: `/data/library/${user.id}/2022/2022-06-19/IMG_7065.JPEG`,
originalFileName: 'IMG_7065.JPEG',
});
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
})
.owner(user)
.exif()
.build();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.user.getList.mockResolvedValue([user]);
mocks.move.create.mockResolvedValue({
id: '123',
@@ -731,13 +834,17 @@ describe(StorageTemplateService.name, () => {
});
it('should normalize the filename to lowercase (JPG > jpg)', async () => {
const user = factory.userAdmin();
const asset = assetStub.storageAsset({
ownerId: user.id,
const user = UserFactory.create();
const asset = AssetFactory.from({
fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'),
originalPath: '/data/library/user-id/2022/2022-06-19/IMG_7065.JPG',
originalFileName: 'IMG_7065.JPG',
});
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([asset]));
})
.owner(user)
.exif()
.build();
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(asset)]));
mocks.user.getList.mockResolvedValue([user]);
mocks.move.create.mockResolvedValue({
id: '123',