Skip to content

Commit

Permalink
chore: some more unit tests :) (immich-app#13159)
Browse files Browse the repository at this point in the history
  • Loading branch information
danieldietzler authored Oct 3, 2024
1 parent db1623f commit 2c8c365
Show file tree
Hide file tree
Showing 3 changed files with 304 additions and 9 deletions.
24 changes: 24 additions & 0 deletions server/src/services/album.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,17 @@ describe(AlbumService.name, () => {
expect(albumMock.update).not.toHaveBeenCalled();
});

it('should throw an error if the userId is the ownerId', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
await expect(
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, {
albumUsers: [{ userId: userStub.user1.id }],
}),
).rejects.toBeInstanceOf(BadRequestException);
expect(albumMock.update).not.toHaveBeenCalled();
});

it('should add valid shared users', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
albumMock.getById.mockResolvedValue(_.cloneDeep(albumStub.sharedWithAdmin));
Expand Down Expand Up @@ -415,6 +426,19 @@ describe(AlbumService.name, () => {
});
});

describe('updateUser', () => {
it('should update user role', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
await sut.updateUser(authStub.user1, albumStub.sharedWithAdmin.id, userStub.admin.id, {
role: AlbumUserRole.EDITOR,
});
expect(albumUserMock.update).toHaveBeenCalledWith(
{ albumId: albumStub.sharedWithAdmin.id, userId: userStub.admin.id },
{ role: AlbumUserRole.EDITOR },
);
});
});

describe('getAlbumInfo', () => {
it('should get a shared album', async () => {
albumMock.getById.mockResolvedValue(albumStub.oneAsset);
Expand Down
283 changes: 280 additions & 3 deletions server/src/services/asset-media.service.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { BadRequestException, NotFoundException, UnauthorizedException } from '@nestjs/common';
import {
BadRequestException,
InternalServerErrorException,
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { Stats } from 'node:fs';
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
import { AssetMediaCreateDto, AssetMediaReplaceDto, UploadFieldName } from 'src/dtos/asset-media.dto';
import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto';
import { AssetFileEntity } from 'src/entities/asset-files.entity';
import { ASSET_CHECKSUM_CONSTRAINT, AssetEntity } from 'src/entities/asset.entity';
import { AssetStatus, AssetType, CacheControl } from 'src/enum';
import { AssetFileType, AssetStatus, AssetType, CacheControl } from 'src/enum';
import { IAssetRepository } from 'src/interfaces/asset.interface';
import { IJobRepository, JobName } from 'src/interfaces/job.interface';
import { IStorageRepository } from 'src/interfaces/storage.interface';
Expand All @@ -14,6 +19,7 @@ import { ImmichFileResponse } from 'src/utils/file';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { userStub } from 'test/fixtures/user.stub';
import { IAccessRepositoryMock } from 'test/repositories/access.repository.mock';
import { newTestService } from 'test/utils';
import { QueryFailedError } from 'typeorm';
Expand Down Expand Up @@ -194,6 +200,10 @@ describe(AssetMediaService.name, () => {
});

describe('getUploadAssetIdByChecksum', () => {
it('should return if checksum is undefined', async () => {
await expect(sut.getUploadAssetIdByChecksum(authStub.admin)).resolves.toBe(undefined);
});

it('should handle a non-existent asset', async () => {
await expect(sut.getUploadAssetIdByChecksum(authStub.admin, file1.toString('hex'))).resolves.toBeUndefined();
expect(assetMock.getUploadAssetIdByChecksum).toHaveBeenCalledWith(authStub.admin.user.id, file1);
Expand Down Expand Up @@ -295,6 +305,35 @@ describe(AssetMediaService.name, () => {
});

describe('uploadAsset', () => {
it('should throw an error if the quota is exceeded', async () => {
const file = {
uuid: 'random-uuid',
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
size: 42,
};

assetMock.create.mockResolvedValue(assetEntity);

await expect(
sut.uploadAsset(
{ ...authStub.admin, user: { ...authStub.admin.user, quotaSizeInBytes: 42, quotaUsageInBytes: 1 } },
createDto,
file,
),
).rejects.toBeInstanceOf(BadRequestException);

expect(assetMock.create).not.toHaveBeenCalled();
expect(userMock.updateUsage).not.toHaveBeenCalledWith(authStub.user1.user.id, file.size);
expect(storageMock.utimes).not.toHaveBeenCalledWith(
file.originalPath,
expect.any(Date),
new Date(createDto.fileModifiedAt),
);
});

it('should handle a file upload', async () => {
const file = {
uuid: 'random-uuid',
Expand Down Expand Up @@ -348,6 +387,31 @@ describe(AssetMediaService.name, () => {
expect(userMock.updateUsage).not.toHaveBeenCalled();
});

it('should throw an error if the duplicate could not be found by checksum', async () => {
const file = {
uuid: 'random-uuid',
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
size: 0,
};
const error = new QueryFailedError('', [], new Error('unique key violation'));
(error as any).constraint = ASSET_CHECKSUM_CONSTRAINT;

assetMock.create.mockRejectedValue(error);

await expect(sut.uploadAsset(authStub.user1, createDto, file)).rejects.toBeInstanceOf(
InternalServerErrorException,
);

expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.DELETE_FILES,
data: { files: ['fake_path/asset_1.jpeg', undefined] },
});
expect(userMock.updateUsage).not.toHaveBeenCalled();
});

it('should handle a live photo', async () => {
assetMock.getById.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
assetMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
Expand Down Expand Up @@ -385,6 +449,23 @@ describe(AssetMediaService.name, () => {
expect(assetMock.getById).toHaveBeenCalledWith('live-photo-motion-asset');
expect(assetMock.update).toHaveBeenCalledWith({ id: 'live-photo-motion-asset', isVisible: false });
});

it('should handle a sidecar file', async () => {
assetMock.getById.mockResolvedValueOnce(assetStub.image);
assetMock.create.mockResolvedValueOnce(assetStub.image);

await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({
status: AssetMediaStatus.CREATED,
id: assetStub.image.id,
});

expect(storageMock.utimes).toHaveBeenCalledWith(
fileStub.photoSidecar.originalPath,
expect.any(Date),
new Date(createDto.fileModifiedAt),
);
expect(assetMock.update).not.toHaveBeenCalled();
});
});

describe('downloadOriginal', () => {
Expand Down Expand Up @@ -419,6 +500,170 @@ describe(AssetMediaService.name, () => {
});
});

describe('viewThumbnail', () => {
it('should require asset.view permissions', async () => {
await expect(sut.viewThumbnail(authStub.admin, 'id', {})).rejects.toBeInstanceOf(BadRequestException);

expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
});

it('should throw an error if the asset does not exist', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
assetMock.getById.mockResolvedValue(null);

await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
).rejects.toBeInstanceOf(NotFoundException);
});

it('should throw an error if the requested thumbnail file does not exist', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
assetMock.getById.mockResolvedValue({ ...assetStub.image, files: [] });

await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
).rejects.toBeInstanceOf(NotFoundException);
});

it('should throw an error if the requested preview file does not exist', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
assetMock.getById.mockResolvedValue({
...assetStub.image,
files: [
{
assetId: assetStub.image.id,
createdAt: assetStub.image.fileCreatedAt,
id: '42',
path: '/path/to/preview',
type: AssetFileType.THUMBNAIL,
updatedAt: new Date(),
},
],
});
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
).rejects.toBeInstanceOf(NotFoundException);
});

it('should fall back to preview if the requested thumbnail file does not exist', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
assetMock.getById.mockResolvedValue({
...assetStub.image,
files: [
{
assetId: assetStub.image.id,
createdAt: assetStub.image.fileCreatedAt,
id: '42',
path: '/path/to/preview.jpg',
type: AssetFileType.PREVIEW,
updatedAt: new Date(),
},
],
});

await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
).resolves.toEqual(
new ImmichFileResponse({
path: '/path/to/preview.jpg',
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
contentType: 'image/jpeg',
}),
);
});

it('should get preview file', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
assetMock.getById.mockResolvedValue({ ...assetStub.image });
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.PREVIEW }),
).resolves.toEqual(
new ImmichFileResponse({
path: assetStub.image.files[0].path,
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
contentType: 'image/jpeg',
}),
);
});

it('should get thumbnail file', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
assetMock.getById.mockResolvedValue({ ...assetStub.image });
await expect(
sut.viewThumbnail(authStub.admin, assetStub.image.id, { size: AssetMediaSize.THUMBNAIL }),
).resolves.toEqual(
new ImmichFileResponse({
path: assetStub.image.files[1].path,
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
contentType: 'application/octet-stream',
}),
);
});
});

describe('playbackVideo', () => {
it('should require asset.view permissions', async () => {
await expect(sut.playbackVideo(authStub.admin, 'id')).rejects.toBeInstanceOf(BadRequestException);

expect(accessMock.asset.checkOwnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
expect(accessMock.asset.checkAlbumAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
expect(accessMock.asset.checkPartnerAccess).toHaveBeenCalledWith(userStub.admin.id, new Set(['id']));
});

it('should throw an error if the asset does not exist', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
assetMock.getById.mockResolvedValue(null);

await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(NotFoundException);
});

it('should throw an error if the asset is not a video', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.image.id]));
assetMock.getById.mockResolvedValue(assetStub.image);

await expect(sut.playbackVideo(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException);
});

it('should return the encoded video path if available', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.hasEncodedVideo.id]));
assetMock.getById.mockResolvedValue(assetStub.hasEncodedVideo);

await expect(sut.playbackVideo(authStub.admin, assetStub.hasEncodedVideo.id)).resolves.toEqual(
new ImmichFileResponse({
path: assetStub.hasEncodedVideo.encodedVideoPath!,
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
contentType: 'video/mp4',
}),
);
});

it('should fall back to the original path', async () => {
accessMock.asset.checkOwnerAccess.mockResolvedValue(new Set([assetStub.video.id]));
assetMock.getById.mockResolvedValue(assetStub.video);

await expect(sut.playbackVideo(authStub.admin, assetStub.video.id)).resolves.toEqual(
new ImmichFileResponse({
path: assetStub.video.originalPath,
cacheControl: CacheControl.PRIVATE_WITH_CACHE,
contentType: 'application/octet-stream',
}),
);
});
});

describe('checkExistingAssets', () => {
it('should get existing asset ids', async () => {
assetMock.getByDeviceIds.mockResolvedValue(['42']);
await expect(
sut.checkExistingAssets(authStub.admin, { deviceId: '420', deviceAssetIds: ['69'] }),
).resolves.toEqual({ existingIds: ['42'] });

expect(assetMock.getByDeviceIds).toHaveBeenCalledWith(userStub.admin.id, '420', ['69']);
});
});

describe('replaceAsset', () => {
it('should error when update photo does not exist', async () => {
assetMock.getById.mockResolvedValueOnce(null);
Expand Down Expand Up @@ -601,5 +846,37 @@ describe(AssetMediaService.name, () => {

expect(assetMock.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]);
});

it('should return non-duplicates as well', async () => {
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex');

assetMock.getByChecksums.mockResolvedValue([{ id: 'asset-1', checksum: file1 } as AssetEntity]);

await expect(
sut.bulkUploadCheck(authStub.admin, {
assets: [
{ id: '1', checksum: file1.toString('hex') },
{ id: '2', checksum: file2.toString('base64') },
],
}),
).resolves.toEqual({
results: [
{
id: '1',
assetId: 'asset-1',
action: AssetUploadAction.REJECT,
reason: AssetRejectReason.DUPLICATE,
isTrashed: false,
},
{
id: '2',
action: AssetUploadAction.ACCEPT,
},
],
});

expect(assetMock.getByChecksums).toHaveBeenCalledWith(authStub.admin.user.id, [file1, file2]);
});
});
});
6 changes: 0 additions & 6 deletions server/src/services/asset-media.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,6 @@ export class AssetMediaService extends BaseService {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_DOWNLOAD, ids: [id] });

const asset = await this.findOrFail(id);
if (!asset) {
throw new NotFoundException('Asset does not exist');
}

return new ImmichFileResponse({
path: asset.originalPath,
Expand Down Expand Up @@ -223,9 +220,6 @@ export class AssetMediaService extends BaseService {
await requireAccess(this.accessRepository, { auth, permission: Permission.ASSET_VIEW, ids: [id] });

const asset = await this.findOrFail(id);
if (!asset) {
throw new NotFoundException('Asset does not exist');
}

if (asset.type !== AssetType.VIDEO) {
throw new BadRequestException('Asset is not a video');
Expand Down

0 comments on commit 2c8c365

Please sign in to comment.