1
0
forked from Cutlery/immich
immich-quadlet/server/src/immich/api-v1/asset/asset.service.spec.ts
Jason Rasmussen c6abef186c
refactor(server,web): time buckets for main timeline, archived, and favorites (1) (#3537)
* refactor: time buckets

* feat(web): use new time bucket api

* feat(web): use asset grid in archive/favorites

* chore: open api

* chore: clean up uuid validation

* refactor(web): move memory lane to photos page

* Update web/src/routes/(user)/archive/+page.svelte

Co-authored-by: Sergey Kondrikov <sergey.kondrikov@gmail.com>

* fix: hide archived photos on main timeline

* fix: select exif info

---------

Co-authored-by: Sergey Kondrikov <sergey.kondrikov@gmail.com>
2023-08-04 17:07:15 -04:00

434 lines
15 KiB
TypeScript

import { ICryptoRepository, IJobRepository, IStorageRepository, JobName } from '@app/domain';
import { AssetEntity, AssetType, ExifEntity } from '@app/infra/entities';
import { BadRequestException } from '@nestjs/common';
import {
assetStub,
authStub,
fileStub,
IAccessRepositoryMock,
newAccessRepositoryMock,
newCryptoRepositoryMock,
newJobRepositoryMock,
newStorageRepositoryMock,
} from '@test';
import { when } from 'jest-when';
import { QueryFailedError, Repository } from 'typeorm';
import { IAssetRepository } from './asset-repository';
import { AssetService } from './asset.service';
import { CreateAssetDto } from './dto/create-asset.dto';
import { AssetRejectReason, AssetUploadAction } from './response-dto/asset-check-response.dto';
const _getCreateAssetDto = (): CreateAssetDto => {
const createAssetDto = new CreateAssetDto();
createAssetDto.deviceAssetId = 'deviceAssetId';
createAssetDto.deviceId = 'deviceId';
createAssetDto.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z');
createAssetDto.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z');
createAssetDto.isFavorite = false;
createAssetDto.isArchived = false;
createAssetDto.duration = '0:00:00.000000';
return createAssetDto;
};
const _getAsset_1 = () => {
const asset_1 = new AssetEntity();
asset_1.id = 'id_1';
asset_1.ownerId = 'user_id_1';
asset_1.deviceAssetId = 'device_asset_id_1';
asset_1.deviceId = 'device_id_1';
asset_1.type = AssetType.VIDEO;
asset_1.originalPath = 'fake_path/asset_1.jpeg';
asset_1.resizePath = '';
asset_1.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z');
asset_1.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z');
asset_1.updatedAt = new Date('2022-06-19T23:41:36.910Z');
asset_1.isFavorite = false;
asset_1.isArchived = false;
asset_1.webpPath = '';
asset_1.encodedVideoPath = '';
asset_1.duration = '0:00:00.000000';
asset_1.exifInfo = new ExifEntity();
asset_1.exifInfo.latitude = 49.533547;
asset_1.exifInfo.longitude = 10.703075;
return asset_1;
};
const _getAsset_2 = () => {
const asset_2 = new AssetEntity();
asset_2.id = 'id_2';
asset_2.ownerId = 'user_id_1';
asset_2.deviceAssetId = 'device_asset_id_2';
asset_2.deviceId = 'device_id_1';
asset_2.type = AssetType.VIDEO;
asset_2.originalPath = 'fake_path/asset_2.jpeg';
asset_2.resizePath = '';
asset_2.fileModifiedAt = new Date('2022-06-19T23:41:36.910Z');
asset_2.fileCreatedAt = new Date('2022-06-19T23:41:36.910Z');
asset_2.updatedAt = new Date('2022-06-19T23:41:36.910Z');
asset_2.isFavorite = false;
asset_2.isArchived = false;
asset_2.webpPath = '';
asset_2.encodedVideoPath = '';
asset_2.duration = '0:00:00.000000';
return asset_2;
};
const _getAssets = () => {
return [_getAsset_1(), _getAsset_2()];
};
describe('AssetService', () => {
let sut: AssetService;
let a: Repository<AssetEntity>; // TO BE DELETED AFTER FINISHED REFACTORING
let accessMock: IAccessRepositoryMock;
let assetRepositoryMock: jest.Mocked<IAssetRepository>;
let cryptoMock: jest.Mocked<ICryptoRepository>;
let jobMock: jest.Mocked<IJobRepository>;
let storageMock: jest.Mocked<IStorageRepository>;
beforeEach(() => {
assetRepositoryMock = {
get: jest.fn(),
create: jest.fn(),
remove: jest.fn(),
update: jest.fn(),
getAllByUserId: jest.fn(),
getAllByDeviceId: jest.fn(),
getById: jest.fn(),
getDetectedObjectsByUserId: jest.fn(),
getLocationsByUserId: jest.fn(),
getSearchPropertiesByUserId: jest.fn(),
getAssetsByChecksums: jest.fn(),
getExistingAssets: jest.fn(),
getByOriginalPath: jest.fn(),
};
accessMock = newAccessRepositoryMock();
cryptoMock = newCryptoRepositoryMock();
jobMock = newJobRepositoryMock();
storageMock = newStorageRepositoryMock();
sut = new AssetService(accessMock, assetRepositoryMock, a, cryptoMock, jobMock, storageMock);
when(assetRepositoryMock.get)
.calledWith(assetStub.livePhotoStillAsset.id)
.mockResolvedValue(assetStub.livePhotoStillAsset);
when(assetRepositoryMock.get)
.calledWith(assetStub.livePhotoMotionAsset.id)
.mockResolvedValue(assetStub.livePhotoMotionAsset);
});
describe('uploadFile', () => {
it('should handle a file upload', async () => {
const assetEntity = _getAsset_1();
const file = {
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
};
const dto = _getCreateAssetDto();
assetRepositoryMock.create.mockResolvedValue(assetEntity);
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: false, id: 'id_1' });
expect(assetRepositoryMock.create).toHaveBeenCalled();
});
it('should handle a duplicate', async () => {
const file = {
originalPath: 'fake_path/asset_1.jpeg',
mimeType: 'image/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
originalName: 'asset_1.jpeg',
};
const dto = _getCreateAssetDto();
const error = new QueryFailedError('', [], '');
(error as any).constraint = 'UQ_userid_checksum';
assetRepositoryMock.create.mockRejectedValue(error);
assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([_getAsset_1()]);
await expect(sut.uploadFile(authStub.user1, dto, file)).resolves.toEqual({ duplicate: true, id: 'id_1' });
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.DELETE_FILES,
data: { files: ['fake_path/asset_1.jpeg', undefined, undefined] },
});
expect(storageMock.moveFile).not.toHaveBeenCalled();
});
it('should handle a live photo', async () => {
const dto = _getCreateAssetDto();
const error = new QueryFailedError('', [], '');
(error as any).constraint = 'UQ_userid_checksum';
assetRepositoryMock.create.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
assetRepositoryMock.create.mockResolvedValueOnce(assetStub.livePhotoStillAsset);
await expect(
sut.uploadFile(authStub.user1, dto, fileStub.livePhotoStill, fileStub.livePhotoMotion),
).resolves.toEqual({
duplicate: false,
id: 'live-photo-still-asset',
});
expect(jobMock.queue.mock.calls).toEqual([
[
{
name: JobName.METADATA_EXTRACTION,
data: { id: assetStub.livePhotoMotionAsset.id, source: 'upload' },
},
],
[{ name: JobName.METADATA_EXTRACTION, data: { id: assetStub.livePhotoStillAsset.id, source: 'upload' } }],
]);
});
});
it('get assets by device id', async () => {
const assets = _getAssets();
assetRepositoryMock.getAllByDeviceId.mockImplementation(() =>
Promise.resolve<string[]>(Array.from(assets.map((asset) => asset.deviceAssetId))),
);
const deviceId = 'device_id_1';
const result = await sut.getUserAssetsByDeviceId(authStub.user1, deviceId);
expect(result.length).toEqual(2);
expect(result).toEqual(assets.map((asset) => asset.deviceAssetId));
});
describe('deleteAll', () => {
it('should return failed status when an asset is missing', async () => {
assetRepositoryMock.get.mockResolvedValue(null);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', status: 'FAILED' },
]);
expect(jobMock.queue).not.toHaveBeenCalled();
});
it('should return failed status a delete fails', async () => {
assetRepositoryMock.get.mockResolvedValue({ id: 'asset1' } as AssetEntity);
assetRepositoryMock.remove.mockRejectedValue('delete failed');
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1'] })).resolves.toEqual([
{ id: 'asset1', status: 'FAILED' },
]);
expect(jobMock.queue).not.toHaveBeenCalled();
});
it('should delete a live photo', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.deleteAll(authStub.user1, { ids: [assetStub.livePhotoStillAsset.id] })).resolves.toEqual([
{ id: assetStub.livePhotoStillAsset.id, status: 'SUCCESS' },
{ id: assetStub.livePhotoMotionAsset.id, status: 'SUCCESS' },
]);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.DELETE_FILES,
data: {
files: [
'fake_path/asset_1.jpeg',
undefined,
undefined,
undefined,
undefined,
'fake_path/asset_1.mp4',
undefined,
undefined,
undefined,
undefined,
],
},
});
});
it('should delete a batch of assets', async () => {
const asset1 = {
id: 'asset1',
originalPath: 'original-path-1',
resizePath: 'resize-path-1',
webpPath: 'web-path-1',
};
const asset2 = {
id: 'asset2',
originalPath: 'original-path-2',
resizePath: 'resize-path-2',
webpPath: 'web-path-2',
encodedVideoPath: 'encoded-video-path-2',
};
when(assetRepositoryMock.get)
.calledWith(asset1.id)
.mockResolvedValue(asset1 as AssetEntity);
when(assetRepositoryMock.get)
.calledWith(asset2.id)
.mockResolvedValue(asset2 as AssetEntity);
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
await expect(sut.deleteAll(authStub.user1, { ids: ['asset1', 'asset2'] })).resolves.toEqual([
{ id: 'asset1', status: 'SUCCESS' },
{ id: 'asset2', status: 'SUCCESS' },
]);
expect(jobMock.queue.mock.calls).toEqual([
[{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: ['asset1'] } }],
[{ name: JobName.SEARCH_REMOVE_ASSET, data: { ids: ['asset2'] } }],
[
{
name: JobName.DELETE_FILES,
data: {
files: [
'original-path-1',
'web-path-1',
'resize-path-1',
undefined,
undefined,
'original-path-2',
'web-path-2',
'resize-path-2',
'encoded-video-path-2',
undefined,
],
},
},
],
]);
});
});
describe('bulkUploadCheck', () => {
it('should accept hex and base64 checksums', async () => {
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
const file2 = Buffer.from('53be335e99f18a66ff12e9a901c7a6171dd76573', 'hex');
assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([
{ id: 'asset-1', checksum: file1 },
{ id: 'asset-2', checksum: file2 },
]);
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 },
{ id: '2', assetId: 'asset-2', action: AssetUploadAction.REJECT, reason: AssetRejectReason.DUPLICATE },
],
});
expect(assetRepositoryMock.getAssetsByChecksums).toHaveBeenCalledWith(authStub.admin.id, [file1, file2]);
});
});
describe('importFile', () => {
it('should handle a file import', async () => {
assetRepositoryMock.create.mockResolvedValue(assetStub.image);
storageMock.checkFileExists.mockResolvedValue(true);
await expect(
sut.importFile(authStub.external1, {
..._getCreateAssetDto(),
assetPath: '/data/user1/fake_path/asset_1.jpeg',
isReadOnly: true,
}),
).resolves.toEqual({ duplicate: false, id: 'asset-id' });
expect(assetRepositoryMock.create).toHaveBeenCalled();
});
it('should handle a duplicate if originalPath already exists', async () => {
const error = new QueryFailedError('', [], '');
(error as any).constraint = 'UQ_userid_checksum';
assetRepositoryMock.create.mockRejectedValue(error);
assetRepositoryMock.getAssetsByChecksums.mockResolvedValue([assetStub.image]);
storageMock.checkFileExists.mockResolvedValue(true);
cryptoMock.hashFile.mockResolvedValue(Buffer.from('file hash', 'utf8'));
await expect(
sut.importFile(authStub.external1, {
..._getCreateAssetDto(),
assetPath: '/data/user1/fake_path/asset_1.jpeg',
isReadOnly: true,
}),
).resolves.toEqual({ duplicate: true, id: 'asset-id' });
expect(assetRepositoryMock.create).toHaveBeenCalled();
});
});
describe('getAssetById', () => {
it('should allow owner access', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(true);
assetRepositoryMock.getById.mockResolvedValue(assetStub.image);
await sut.getAssetById(authStub.admin, assetStub.image.id);
expect(accessMock.asset.hasOwnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetStub.image.id);
});
it('should allow shared link access', async () => {
accessMock.asset.hasSharedLinkAccess.mockResolvedValue(true);
assetRepositoryMock.getById.mockResolvedValue(assetStub.image);
await sut.getAssetById(authStub.adminSharedLink, assetStub.image.id);
expect(accessMock.asset.hasSharedLinkAccess).toHaveBeenCalledWith(
authStub.adminSharedLink.sharedLinkId,
assetStub.image.id,
);
});
it('should allow partner sharing access', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(true);
assetRepositoryMock.getById.mockResolvedValue(assetStub.image);
await sut.getAssetById(authStub.admin, assetStub.image.id);
expect(accessMock.asset.hasPartnerAccess).toHaveBeenCalledWith(authStub.admin.id, assetStub.image.id);
});
it('should allow shared album access', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
accessMock.asset.hasAlbumAccess.mockResolvedValue(true);
assetRepositoryMock.getById.mockResolvedValue(assetStub.image);
await sut.getAssetById(authStub.admin, assetStub.image.id);
expect(accessMock.asset.hasAlbumAccess).toHaveBeenCalledWith(authStub.admin.id, assetStub.image.id);
});
it('should throw an error for no access', async () => {
accessMock.asset.hasOwnerAccess.mockResolvedValue(false);
accessMock.asset.hasPartnerAccess.mockResolvedValue(false);
accessMock.asset.hasSharedLinkAccess.mockResolvedValue(false);
accessMock.asset.hasAlbumAccess.mockResolvedValue(false);
await expect(sut.getAssetById(authStub.admin, assetStub.image.id)).rejects.toBeInstanceOf(BadRequestException);
expect(assetRepositoryMock.getById).not.toHaveBeenCalled();
});
it('should throw an error for an invalid shared link', async () => {
accessMock.asset.hasSharedLinkAccess.mockResolvedValue(false);
await expect(sut.getAssetById(authStub.adminSharedLink, assetStub.image.id)).rejects.toBeInstanceOf(
BadRequestException,
);
expect(accessMock.asset.hasOwnerAccess).not.toHaveBeenCalled();
expect(assetRepositoryMock.getById).not.toHaveBeenCalled();
});
});
});