mirror of
https://github.com/immich-app/immich.git
synced 2026-03-03 02:37:02 +00:00
Compare commits
3 Commits
d94d9600a7
...
push-lvytu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bc1033647 | ||
|
|
55e625a2ac | ||
|
|
ca6c486a80 |
@@ -1074,6 +1074,7 @@
|
||||
"failed_to_update_notification_status": "Failed to update notification status",
|
||||
"incorrect_email_or_password": "Incorrect email or password",
|
||||
"library_folder_already_exists": "This import path already exists.",
|
||||
"page_not_found": "Page not found :/",
|
||||
"paths_validation_failed": "{paths, plural, one {# path} other {# paths}} failed validation",
|
||||
"profile_picture_transparent_pixels": "Profile pictures cannot have transparent pixels. Please zoom in and/or move the image.",
|
||||
"quota_higher_than_disk_size": "You set a quota higher than the disk size",
|
||||
|
||||
@@ -23,10 +23,11 @@ import { MediaService } from 'src/services/media.service';
|
||||
import { JobCounts, RawImageInfo } from 'src/types';
|
||||
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { PersonFactory } from 'test/factories/person.factory';
|
||||
import { probeStub } from 'test/fixtures/media.stub';
|
||||
import { personStub, personThumbnailStub } from 'test/fixtures/person.stub';
|
||||
import { personThumbnailStub } from 'test/fixtures/person.stub';
|
||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { factory, newUuid } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const fullsizeBuffer = Buffer.from('embedded image data');
|
||||
@@ -50,9 +51,10 @@ describe(MediaService.name, () => {
|
||||
describe('handleQueueGenerateThumbnails', () => {
|
||||
it('should queue all assets', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
const person = PersonFactory.create({ faceAssetId: newUuid() });
|
||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([asset]));
|
||||
|
||||
mocks.person.getAll.mockReturnValue(makeStream([personStub.newThumbnail]));
|
||||
mocks.person.getAll.mockReturnValue(makeStream([person]));
|
||||
|
||||
await sut.handleQueueGenerateThumbnails({ force: true });
|
||||
|
||||
@@ -68,7 +70,7 @@ describe(MediaService.name, () => {
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.PersonGenerateThumbnail,
|
||||
data: { id: personStub.newThumbnail.id },
|
||||
data: { id: person.id },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -106,8 +108,13 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should queue all people with missing thumbnail path', async () => {
|
||||
const [person1, person2] = [
|
||||
PersonFactory.create({ thumbnailPath: undefined }),
|
||||
PersonFactory.create({ thumbnailPath: undefined }),
|
||||
];
|
||||
|
||||
mocks.assetJob.streamForThumbnailJob.mockReturnValue(makeStream([AssetFactory.create()]));
|
||||
mocks.person.getAll.mockReturnValue(makeStream([personStub.noThumbnail, personStub.noThumbnail]));
|
||||
mocks.person.getAll.mockReturnValue(makeStream([person1, person2]));
|
||||
mocks.person.getRandomFace.mockResolvedValueOnce(AssetFaceFactory.create());
|
||||
|
||||
await sut.handleQueueGenerateThumbnails({ force: false });
|
||||
@@ -120,7 +127,7 @@ describe(MediaService.name, () => {
|
||||
{
|
||||
name: JobName.PersonGenerateThumbnail,
|
||||
data: {
|
||||
id: personStub.newThumbnail.id,
|
||||
id: person1.id,
|
||||
},
|
||||
},
|
||||
]);
|
||||
@@ -276,17 +283,17 @@ describe(MediaService.name, () => {
|
||||
describe('handleQueueMigration', () => {
|
||||
it('should remove empty directories and queue jobs', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.assetJob.streamForMigrationJob.mockReturnValue(makeStream([asset]));
|
||||
mocks.job.getJobCounts.mockResolvedValue({ active: 1, waiting: 0 } as JobCounts);
|
||||
mocks.person.getAll.mockReturnValue(makeStream([personStub.withName]));
|
||||
mocks.person.getAll.mockReturnValue(makeStream([person]));
|
||||
|
||||
await expect(sut.handleQueueMigration()).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.storage.removeEmptyDirs).toHaveBeenCalledTimes(2);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.AssetFileMigration, data: { id: asset.id } }]);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{ name: JobName.PersonFileMigration, data: { id: personStub.withName.id } },
|
||||
]);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([{ name: JobName.PersonFileMigration, data: { id: person.id } }]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1479,8 +1486,9 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should skip a person without a face asset id', async () => {
|
||||
mocks.person.getById.mockResolvedValue(personStub.noThumbnail);
|
||||
await sut.handleGeneratePersonThumbnail({ id: 'person-1' });
|
||||
const person = PersonFactory.create({ faceAssetId: null });
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await sut.handleGeneratePersonThumbnail({ id: person.id });
|
||||
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -1490,17 +1498,17 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should generate a thumbnail', async () => {
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
|
||||
mocks.media.generateThumbnail.mockResolvedValue();
|
||||
const data = Buffer.from('');
|
||||
const info = { width: 1000, height: 1000 } as OutputInfo;
|
||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
||||
JobStatus.Success,
|
||||
);
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id);
|
||||
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(person.id);
|
||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailMiddle.originalPath, {
|
||||
colorspace: Colorspace.P3,
|
||||
@@ -1531,21 +1539,21 @@ describe(MediaService.name, () => {
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', thumbnailPath: expect.any(String) });
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, thumbnailPath: expect.any(String) });
|
||||
});
|
||||
|
||||
it('should use preview path if video', async () => {
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.videoThumbnail);
|
||||
mocks.media.generateThumbnail.mockResolvedValue();
|
||||
const data = Buffer.from('');
|
||||
const info = { width: 1000, height: 1000 } as OutputInfo;
|
||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
||||
JobStatus.Success,
|
||||
);
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(personStub.primaryPerson.id);
|
||||
expect(mocks.person.getDataForThumbnailGenerationJob).toHaveBeenCalledWith(person.id);
|
||||
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(expect.any(String), {
|
||||
colorspace: Colorspace.P3,
|
||||
@@ -1576,19 +1584,19 @@ describe(MediaService.name, () => {
|
||||
},
|
||||
expect.any(String),
|
||||
);
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', thumbnailPath: expect.any(String) });
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, thumbnailPath: expect.any(String) });
|
||||
});
|
||||
|
||||
it('should generate a thumbnail without going negative', async () => {
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailStart);
|
||||
mocks.media.generateThumbnail.mockResolvedValue();
|
||||
const data = Buffer.from('');
|
||||
const info = { width: 2160, height: 3840 } as OutputInfo;
|
||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
||||
JobStatus.Success,
|
||||
);
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailStart.originalPath, {
|
||||
colorspace: Colorspace.P3,
|
||||
@@ -1622,16 +1630,16 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should generate a thumbnail without overflowing', async () => {
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailEnd);
|
||||
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
mocks.media.generateThumbnail.mockResolvedValue();
|
||||
const data = Buffer.from('');
|
||||
const info = { width: 1000, height: 1000 } as OutputInfo;
|
||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
||||
JobStatus.Success,
|
||||
);
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.newThumbnailEnd.originalPath, {
|
||||
colorspace: Colorspace.P3,
|
||||
@@ -1665,16 +1673,16 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle negative coordinates', async () => {
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.negativeCoordinate);
|
||||
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
mocks.media.generateThumbnail.mockResolvedValue();
|
||||
const data = Buffer.from('');
|
||||
const info = { width: 4624, height: 3080 } as OutputInfo;
|
||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
||||
JobStatus.Success,
|
||||
);
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.negativeCoordinate.originalPath, {
|
||||
colorspace: Colorspace.P3,
|
||||
@@ -1708,16 +1716,16 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle overflowing coordinate', async () => {
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.overflowingCoordinate);
|
||||
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
mocks.media.generateThumbnail.mockResolvedValue();
|
||||
const data = Buffer.from('');
|
||||
const info = { width: 4624, height: 3080 } as OutputInfo;
|
||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
||||
JobStatus.Success,
|
||||
);
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.overflowingCoordinate.originalPath, {
|
||||
colorspace: Colorspace.P3,
|
||||
@@ -1751,9 +1759,11 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should use embedded preview if enabled and raw image', async () => {
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
|
||||
mocks.person.update.mockResolvedValue(personStub.primaryPerson);
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
mocks.media.generateThumbnail.mockResolvedValue();
|
||||
const extracted = Buffer.from('');
|
||||
const data = Buffer.from('');
|
||||
@@ -1762,9 +1772,7 @@ describe(MediaService.name, () => {
|
||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 2160, height: 3840, isTransparent: false });
|
||||
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
||||
JobStatus.Success,
|
||||
);
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(extracted, {
|
||||
@@ -1799,21 +1807,23 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should not use embedded preview if enabled and not raw image', async () => {
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.newThumbnailMiddle);
|
||||
mocks.media.generateThumbnail.mockResolvedValue();
|
||||
const data = Buffer.from('');
|
||||
const info = { width: 2160, height: 3840 } as OutputInfo;
|
||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
||||
JobStatus.Success,
|
||||
);
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.media.extract).not.toHaveBeenCalled();
|
||||
expect(mocks.media.generateThumbnail).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not use embedded preview if enabled and raw image if not exists', async () => {
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
|
||||
mocks.media.generateThumbnail.mockResolvedValue();
|
||||
@@ -1821,9 +1831,7 @@ describe(MediaService.name, () => {
|
||||
const info = { width: 2160, height: 3840 } as OutputInfo;
|
||||
mocks.media.decodeImage.mockResolvedValue({ data, info });
|
||||
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
||||
JobStatus.Success,
|
||||
);
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, {
|
||||
@@ -1835,6 +1843,8 @@ describe(MediaService.name, () => {
|
||||
});
|
||||
|
||||
it('should not use embedded preview if enabled and raw image if low resolution', async () => {
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
|
||||
mocks.person.getDataForThumbnailGenerationJob.mockResolvedValue(personThumbnailStub.rawEmbeddedThumbnail);
|
||||
mocks.media.generateThumbnail.mockResolvedValue();
|
||||
@@ -1845,9 +1855,7 @@ describe(MediaService.name, () => {
|
||||
mocks.media.extract.mockResolvedValue({ buffer: extracted, format: RawExtractedFormat.Jpeg });
|
||||
mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false });
|
||||
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: personStub.primaryPerson.id })).resolves.toBe(
|
||||
JobStatus.Success,
|
||||
);
|
||||
await expect(sut.handleGeneratePersonThumbnail({ id: person.id })).resolves.toBe(JobStatus.Success);
|
||||
|
||||
expect(mocks.media.extract).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath);
|
||||
expect(mocks.media.decodeImage).toHaveBeenCalledWith(personThumbnailStub.rawEmbeddedThumbnail.originalPath, {
|
||||
|
||||
@@ -16,8 +16,8 @@ import {
|
||||
import { ImmichTags } from 'src/repositories/metadata.repository';
|
||||
import { firstDateTime, MetadataService } from 'src/services/metadata.service';
|
||||
import { AssetFactory } from 'test/factories/asset.factory';
|
||||
import { PersonFactory } from 'test/factories/person.factory';
|
||||
import { probeStub } from 'test/fixtures/media.stub';
|
||||
import { personStub } from 'test/fixtures/person.stub';
|
||||
import { tagStub } from 'test/fixtures/tag.stub';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
@@ -1208,18 +1208,18 @@ describe(MetadataService.name, () => {
|
||||
|
||||
it('should apply metadata face tags creating new people', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(makeFaceTags({ Name: personStub.withName.name }));
|
||||
mockReadTags(makeFaceTags({ Name: person.name }));
|
||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
|
||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||
mocks.person.createAll.mockResolvedValue([person.id]);
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
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 }),
|
||||
]);
|
||||
expect(mocks.person.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: person.name })]);
|
||||
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
@@ -1243,19 +1243,21 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.PersonGenerateThumbnail,
|
||||
data: { id: personStub.withName.id },
|
||||
data: { id: person.id },
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should assign metadata face tags to existing persons', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
const person = PersonFactory.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 }]);
|
||||
mockReadTags(makeFaceTags({ Name: person.name }));
|
||||
mocks.person.getDistinctNames.mockResolvedValue([{ id: person.id, name: person.name }]);
|
||||
mocks.person.createAll.mockResolvedValue([]);
|
||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(asset.id);
|
||||
expect(mocks.person.getDistinctNames).toHaveBeenCalledWith(asset.ownerId, { withHidden: true });
|
||||
@@ -1265,7 +1267,7 @@ describe(MetadataService.name, () => {
|
||||
{
|
||||
id: 'random-uuid',
|
||||
assetId: asset.id,
|
||||
personId: personStub.withName.id,
|
||||
personId: person.id,
|
||||
imageHeight: 100,
|
||||
imageWidth: 1000,
|
||||
boundingBoxX1: 0,
|
||||
@@ -1335,21 +1337,20 @@ describe(MetadataService.name, () => {
|
||||
async ({ orientation, expected }) => {
|
||||
const { imgW, imgH, x1, x2, y1, y2 } = expected;
|
||||
const asset = AssetFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
|
||||
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
|
||||
mockReadTags(makeFaceTags({ Name: personStub.withName.name }, orientation));
|
||||
mockReadTags(makeFaceTags({ Name: person.name }, orientation));
|
||||
mocks.person.getDistinctNames.mockResolvedValue([]);
|
||||
mocks.person.createAll.mockResolvedValue([personStub.withName.id]);
|
||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||
mocks.person.createAll.mockResolvedValue([person.id]);
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
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 }),
|
||||
]);
|
||||
expect(mocks.person.createAll).toHaveBeenCalledWith([expect.objectContaining({ name: person.name })]);
|
||||
expect(mocks.person.refreshFaces).toHaveBeenCalledWith(
|
||||
[
|
||||
{
|
||||
@@ -1373,7 +1374,7 @@ describe(MetadataService.name, () => {
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.PersonGenerateThumbnail,
|
||||
data: { id: personStub.withName.id },
|
||||
data: { id: person.id },
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
|
||||
import { mapFaces, mapPerson, PersonResponseDto } from 'src/dtos/person.dto';
|
||||
import { mapFaces, mapPerson } from 'src/dtos/person.dto';
|
||||
import { AssetFileType, CacheControl, JobName, JobStatus, SourceType, SystemMetadataKey } from 'src/enum';
|
||||
import { FaceSearchResult } from 'src/repositories/search.repository';
|
||||
import { PersonService } from 'src/services/person.service';
|
||||
@@ -11,25 +11,11 @@ import { AuthFactory } from 'test/factories/auth.factory';
|
||||
import { PersonFactory } from 'test/factories/person.factory';
|
||||
import { UserFactory } from 'test/factories/user.factory';
|
||||
import { authStub } from 'test/fixtures/auth.stub';
|
||||
import { personStub } from 'test/fixtures/person.stub';
|
||||
import { systemConfigStub } from 'test/fixtures/system-config.stub';
|
||||
import { getAsDetectedFace, getForFacialRecognitionJob } from 'test/mappers';
|
||||
import { newDate, newUuid } from 'test/small.factory';
|
||||
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
|
||||
|
||||
const responseDto: PersonResponseDto = {
|
||||
id: 'person-1',
|
||||
name: 'Person 1',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
isHidden: false,
|
||||
updatedAt: expect.any(Date),
|
||||
isFavorite: false,
|
||||
color: expect.any(String),
|
||||
};
|
||||
|
||||
const statistics = { assets: 3 };
|
||||
|
||||
describe(PersonService.name, () => {
|
||||
let sut: PersonService;
|
||||
let mocks: ServiceMocks;
|
||||
@@ -44,60 +30,54 @@ describe(PersonService.name, () => {
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should get all hidden and visible people with thumbnails', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const [person, hiddenPerson] = [PersonFactory.create(), PersonFactory.create({ isHidden: true })];
|
||||
|
||||
mocks.person.getAllForUser.mockResolvedValue({
|
||||
items: [personStub.withName, personStub.hidden],
|
||||
items: [person, hiddenPerson],
|
||||
hasNextPage: false,
|
||||
});
|
||||
mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
|
||||
await expect(sut.getAll(authStub.admin, { withHidden: true, page: 1, size: 10 })).resolves.toEqual({
|
||||
await expect(sut.getAll(auth, { withHidden: true, page: 1, size: 10 })).resolves.toEqual({
|
||||
hasNextPage: false,
|
||||
total: 2,
|
||||
hidden: 1,
|
||||
people: [
|
||||
responseDto,
|
||||
{
|
||||
id: 'person-1',
|
||||
name: '',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
expect.objectContaining({ id: person.id, isHidden: false }),
|
||||
expect.objectContaining({
|
||||
id: hiddenPerson.id,
|
||||
isHidden: true,
|
||||
isFavorite: false,
|
||||
updatedAt: expect.any(Date),
|
||||
color: expect.any(String),
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, {
|
||||
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
|
||||
minimumFaceCount: 3,
|
||||
withHidden: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('should get all visible people and favorites should be first in the array', async () => {
|
||||
const auth = AuthFactory.create();
|
||||
const [isFavorite, person] = [PersonFactory.create({ isFavorite: true }), PersonFactory.create()];
|
||||
|
||||
mocks.person.getAllForUser.mockResolvedValue({
|
||||
items: [personStub.isFavorite, personStub.withName],
|
||||
items: [isFavorite, person],
|
||||
hasNextPage: false,
|
||||
});
|
||||
mocks.person.getNumberOfPeople.mockResolvedValue({ total: 2, hidden: 1 });
|
||||
await expect(sut.getAll(authStub.admin, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({
|
||||
await expect(sut.getAll(auth, { withHidden: false, page: 1, size: 10 })).resolves.toEqual({
|
||||
hasNextPage: false,
|
||||
total: 2,
|
||||
hidden: 1,
|
||||
people: [
|
||||
{
|
||||
id: 'person-4',
|
||||
name: personStub.isFavorite.name,
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
isHidden: false,
|
||||
expect.objectContaining({
|
||||
id: isFavorite.id,
|
||||
isFavorite: true,
|
||||
updatedAt: expect.any(Date),
|
||||
color: personStub.isFavorite.color,
|
||||
},
|
||||
responseDto,
|
||||
}),
|
||||
expect.objectContaining({ id: person.id, isFavorite: false }),
|
||||
],
|
||||
});
|
||||
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, authStub.admin.user.id, {
|
||||
expect(mocks.person.getAllForUser).toHaveBeenCalledWith({ skip: 0, take: 10 }, auth.user.id, {
|
||||
minimumFaceCount: 3,
|
||||
withHidden: false,
|
||||
});
|
||||
@@ -106,71 +86,89 @@ describe(PersonService.name, () => {
|
||||
|
||||
describe('getById', () => {
|
||||
it('should require person.read permission', async () => {
|
||||
mocks.person.getById.mockResolvedValue(personStub.withName);
|
||||
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await expect(sut.getById(auth, person.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
|
||||
it('should throw a bad request when person is not found', async () => {
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
await expect(sut.getById(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
const auth = AuthFactory.create();
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['unknown']));
|
||||
await expect(sut.getById(auth, 'unknown')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['unknown']));
|
||||
});
|
||||
|
||||
it('should get a person by id', async () => {
|
||||
mocks.person.getById.mockResolvedValue(personStub.withName);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
await expect(sut.getById(authStub.admin, 'person-1')).resolves.toEqual(responseDto);
|
||||
expect(mocks.person.getById).toHaveBeenCalledWith('person-1');
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
await expect(sut.getById(auth, person.id)).resolves.toEqual(expect.objectContaining({ id: person.id }));
|
||||
expect(mocks.person.getById).toHaveBeenCalledWith(person.id);
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getThumbnail', () => {
|
||||
it('should require person.read permission', async () => {
|
||||
mocks.person.getById.mockResolvedValue(personStub.noName);
|
||||
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
|
||||
it('should throw an error when personId is invalid', async () => {
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['unknown']));
|
||||
await expect(sut.getThumbnail(auth, 'unknown')).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['unknown']));
|
||||
});
|
||||
|
||||
it('should throw an error when person has no thumbnail', async () => {
|
||||
mocks.person.getById.mockResolvedValue(personStub.noThumbnail);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
await expect(sut.getThumbnail(authStub.admin, 'person-1')).rejects.toBeInstanceOf(NotFoundException);
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create({ thumbnailPath: '' });
|
||||
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
await expect(sut.getThumbnail(auth, person.id)).rejects.toBeInstanceOf(NotFoundException);
|
||||
expect(mocks.storage.createReadStream).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
|
||||
it('should serve the thumbnail', async () => {
|
||||
mocks.person.getById.mockResolvedValue(personStub.noName);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
await expect(sut.getThumbnail(authStub.admin, 'person-1')).resolves.toEqual(
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
await expect(sut.getThumbnail(auth, person.id)).resolves.toEqual(
|
||||
new ImmichFileResponse({
|
||||
path: '/path/to/thumbnail.jpg',
|
||||
path: person.thumbnailPath,
|
||||
contentType: 'image/jpeg',
|
||||
cacheControl: CacheControl.PrivateWithoutCache,
|
||||
}),
|
||||
);
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should require person.write permission', async () => {
|
||||
mocks.person.getById.mockResolvedValue(personStub.noName);
|
||||
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await expect(sut.update(auth, person.id, { name: 'Person 1' })).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
|
||||
it('should throw an error when personId is invalid', async () => {
|
||||
@@ -183,86 +181,108 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
|
||||
it("should update a person's name", async () => {
|
||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create({ name: 'Person 1' });
|
||||
|
||||
await expect(sut.update(authStub.admin, 'person-1', { name: 'Person 1' })).resolves.toEqual(responseDto);
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', name: 'Person 1' });
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
await expect(sut.update(auth, person.id, { name: 'Person 1' })).resolves.toEqual(
|
||||
expect.objectContaining({ id: person.id, name: 'Person 1' }),
|
||||
);
|
||||
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, name: 'Person 1' });
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
|
||||
it("should update a person's date of birth", async () => {
|
||||
mocks.person.update.mockResolvedValue(personStub.withBirthDate);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create({ birthDate: new Date('1976-06-30') });
|
||||
|
||||
await expect(sut.update(authStub.admin, 'person-1', { birthDate: new Date('1976-06-30') })).resolves.toEqual({
|
||||
id: 'person-1',
|
||||
name: 'Person 1',
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
|
||||
await expect(sut.update(auth, person.id, { birthDate: new Date('1976-06-30') })).resolves.toEqual({
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
birthDate: '1976-06-30',
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
thumbnailPath: person.thumbnailPath,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
updatedAt: expect.any(Date),
|
||||
color: expect.any(String),
|
||||
});
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', birthDate: new Date('1976-06-30') });
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, birthDate: new Date('1976-06-30') });
|
||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||
expect(mocks.job.queueAll).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
|
||||
it('should update a person visibility', async () => {
|
||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create({ isHidden: true });
|
||||
|
||||
await expect(sut.update(authStub.admin, 'person-1', { isHidden: false })).resolves.toEqual(responseDto);
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', isHidden: false });
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
await expect(sut.update(auth, person.id, { isHidden: true })).resolves.toEqual(
|
||||
expect.objectContaining({ isHidden: true }),
|
||||
);
|
||||
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, isHidden: true });
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
|
||||
it('should update a person favorite status', async () => {
|
||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create({ isFavorite: true });
|
||||
|
||||
await expect(sut.update(authStub.admin, 'person-1', { isFavorite: true })).resolves.toEqual(responseDto);
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', isFavorite: true });
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
await expect(sut.update(auth, person.id, { isFavorite: true })).resolves.toEqual(
|
||||
expect.objectContaining({ isFavorite: true }),
|
||||
);
|
||||
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, isFavorite: true });
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
|
||||
it("should update a person's thumbnailPath", async () => {
|
||||
const face = AssetFaceFactory.create();
|
||||
const auth = AuthFactory.create();
|
||||
mocks.person.update.mockResolvedValue(personStub.withName);
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
mocks.person.getForFeatureFaceUpdate.mockResolvedValue(face);
|
||||
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([face.assetId]));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
|
||||
await expect(sut.update(auth, 'person-1', { featureFaceAssetId: face.assetId })).resolves.toEqual(responseDto);
|
||||
await expect(sut.update(auth, person.id, { featureFaceAssetId: face.assetId })).resolves.toEqual(
|
||||
expect.objectContaining({ id: person.id }),
|
||||
);
|
||||
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: 'person-1', faceAssetId: face.id });
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({ id: person.id, faceAssetId: face.id });
|
||||
expect(mocks.person.getForFeatureFaceUpdate).toHaveBeenCalledWith({
|
||||
assetId: face.assetId,
|
||||
personId: 'person-1',
|
||||
personId: person.id,
|
||||
});
|
||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||
name: JobName.PersonGenerateThumbnail,
|
||||
data: { id: 'person-1' },
|
||||
data: { id: person.id },
|
||||
});
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set(['person-1']));
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
|
||||
it('should throw an error when the face feature assetId is invalid', async () => {
|
||||
mocks.person.getById.mockResolvedValue(personStub.withName);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
await expect(sut.update(authStub.admin, 'person-1', { featureFaceAssetId: '-1' })).rejects.toThrow(
|
||||
BadRequestException,
|
||||
);
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
|
||||
await expect(sut.update(auth, person.id, { featureFaceAssetId: '-1' })).rejects.toThrow(BadRequestException);
|
||||
expect(mocks.person.update).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
});
|
||||
|
||||
@@ -283,36 +303,39 @@ describe(PersonService.name, () => {
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set());
|
||||
|
||||
await expect(
|
||||
sut.reassignFaces(authStub.admin, personStub.noName.id, {
|
||||
sut.reassignFaces(AuthFactory.create(), 'person-id', {
|
||||
data: [{ personId: 'asset-face-1', assetId: '' }],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.job.queue).not.toHaveBeenCalledWith();
|
||||
expect(mocks.job.queueAll).not.toHaveBeenCalledWith();
|
||||
});
|
||||
|
||||
it('should reassign a face', async () => {
|
||||
const face = AssetFaceFactory.create();
|
||||
const auth = AuthFactory.create();
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.withName.id]));
|
||||
mocks.person.getById.mockResolvedValue(personStub.noName);
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
|
||||
mocks.person.getFacesByIds.mockResolvedValue([face]);
|
||||
mocks.person.reassignFace.mockResolvedValue(1);
|
||||
mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create());
|
||||
mocks.person.refreshFaces.mockResolvedValue();
|
||||
mocks.person.reassignFace.mockResolvedValue(5);
|
||||
mocks.person.update.mockResolvedValue(personStub.noName);
|
||||
mocks.person.update.mockResolvedValue(person);
|
||||
|
||||
await expect(
|
||||
sut.reassignFaces(auth, personStub.noName.id, {
|
||||
data: [{ personId: personStub.withName.id, assetId: face.assetId }],
|
||||
sut.reassignFaces(auth, person.id, {
|
||||
data: [{ personId: person.id, assetId: face.assetId }],
|
||||
}),
|
||||
).resolves.toBeDefined();
|
||||
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.PersonGenerateThumbnail,
|
||||
data: { id: personStub.newThumbnail.id },
|
||||
data: { id: person.id },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -320,7 +343,7 @@ describe(PersonService.name, () => {
|
||||
|
||||
describe('handlePersonMigration', () => {
|
||||
it('should not move person files', async () => {
|
||||
await expect(sut.handlePersonMigration(personStub.noName)).resolves.toBe(JobStatus.Failed);
|
||||
await expect(sut.handlePersonMigration(PersonFactory.create())).resolves.toBe(JobStatus.Failed);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -347,12 +370,14 @@ describe(PersonService.name, () => {
|
||||
|
||||
describe('createNewFeaturePhoto', () => {
|
||||
it('should change person feature photo', async () => {
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create());
|
||||
await sut.createNewFeaturePhoto([personStub.newThumbnail.id]);
|
||||
await sut.createNewFeaturePhoto([person.id]);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
name: JobName.PersonGenerateThumbnail,
|
||||
data: { id: personStub.newThumbnail.id },
|
||||
data: { id: person.id },
|
||||
},
|
||||
]);
|
||||
});
|
||||
@@ -361,23 +386,22 @@ describe(PersonService.name, () => {
|
||||
describe('reassignFacesById', () => {
|
||||
it('should create a new person', async () => {
|
||||
const face = AssetFaceFactory.create();
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
mocks.access.person.checkFaceOwnerAccess.mockResolvedValue(new Set([face.id]));
|
||||
mocks.person.getFaceById.mockResolvedValue(face);
|
||||
mocks.person.reassignFace.mockResolvedValue(1);
|
||||
mocks.person.getById.mockResolvedValue(personStub.noName);
|
||||
await expect(sut.reassignFacesById(AuthFactory.create(), personStub.noName.id, { id: face.id })).resolves.toEqual(
|
||||
{
|
||||
birthDate: personStub.noName.birthDate,
|
||||
isHidden: personStub.noName.isHidden,
|
||||
isFavorite: personStub.noName.isFavorite,
|
||||
id: personStub.noName.id,
|
||||
name: personStub.noName.name,
|
||||
thumbnailPath: personStub.noName.thumbnailPath,
|
||||
updatedAt: expect.any(Date),
|
||||
color: personStub.noName.color,
|
||||
},
|
||||
);
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await expect(sut.reassignFacesById(AuthFactory.create(), person.id, { id: face.id })).resolves.toEqual({
|
||||
birthDate: person.birthDate,
|
||||
isHidden: person.isHidden,
|
||||
isFavorite: person.isFavorite,
|
||||
id: person.id,
|
||||
name: person.name,
|
||||
thumbnailPath: person.thumbnailPath,
|
||||
updatedAt: expect.any(Date),
|
||||
});
|
||||
|
||||
expect(mocks.job.queue).not.toHaveBeenCalledWith();
|
||||
expect(mocks.job.queueAll).not.toHaveBeenCalledWith();
|
||||
@@ -385,12 +409,14 @@ describe(PersonService.name, () => {
|
||||
|
||||
it('should fail if user has not the correct permissions on the asset', async () => {
|
||||
const face = AssetFaceFactory.create();
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([personStub.noName.id]));
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
mocks.person.getFaceById.mockResolvedValue(face);
|
||||
mocks.person.reassignFace.mockResolvedValue(1);
|
||||
mocks.person.getById.mockResolvedValue(personStub.noName);
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await expect(
|
||||
sut.reassignFacesById(AuthFactory.create(), personStub.noName.id, {
|
||||
sut.reassignFacesById(AuthFactory.create(), person.id, {
|
||||
id: face.id,
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
@@ -402,22 +428,25 @@ describe(PersonService.name, () => {
|
||||
|
||||
describe('createPerson', () => {
|
||||
it('should create a new person', async () => {
|
||||
mocks.person.create.mockResolvedValue(personStub.primaryPerson);
|
||||
const auth = AuthFactory.create();
|
||||
|
||||
await expect(sut.create(authStub.admin, {})).resolves.toBeDefined();
|
||||
mocks.person.create.mockResolvedValue(PersonFactory.create());
|
||||
await expect(sut.create(auth, {})).resolves.toBeDefined();
|
||||
|
||||
expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: authStub.admin.user.id });
|
||||
expect(mocks.person.create).toHaveBeenCalledWith({ ownerId: auth.user.id });
|
||||
});
|
||||
});
|
||||
|
||||
describe('handlePersonCleanup', () => {
|
||||
it('should delete people without faces', async () => {
|
||||
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.noName]);
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
||||
|
||||
await sut.handlePersonCleanup();
|
||||
|
||||
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.noName.id]);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.noName.thumbnailPath);
|
||||
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -449,15 +478,17 @@ describe(PersonService.name, () => {
|
||||
|
||||
it('should queue all assets', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset]));
|
||||
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.withName]);
|
||||
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
||||
|
||||
await sut.handleQueueDetectFaces({ force: true });
|
||||
|
||||
expect(mocks.person.deleteFaces).toHaveBeenCalledWith({ sourceType: SourceType.MachineLearning });
|
||||
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.withName.id]);
|
||||
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
|
||||
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true });
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.withName.thumbnailPath);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
||||
expect(mocks.assetJob.streamForDetectFacesJob).toHaveBeenCalledWith(true);
|
||||
expect(mocks.job.queueAll).toHaveBeenCalledWith([
|
||||
{
|
||||
@@ -490,10 +521,12 @@ describe(PersonService.name, () => {
|
||||
it('should delete existing people and faces if forced', async () => {
|
||||
const asset = AssetFactory.create();
|
||||
const face = AssetFaceFactory.from().person().build();
|
||||
mocks.person.getAll.mockReturnValue(makeStream([face.person!, personStub.randomPerson]));
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getAll.mockReturnValue(makeStream([face.person!, person]));
|
||||
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
|
||||
mocks.assetJob.streamForDetectFacesJob.mockReturnValue(makeStream([asset]));
|
||||
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]);
|
||||
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
||||
mocks.person.deleteFaces.mockResolvedValue();
|
||||
|
||||
await sut.handleQueueDetectFaces({ force: true });
|
||||
@@ -505,8 +538,8 @@ describe(PersonService.name, () => {
|
||||
data: { id: asset.id },
|
||||
},
|
||||
]);
|
||||
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath);
|
||||
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
||||
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: true });
|
||||
});
|
||||
});
|
||||
@@ -661,6 +694,8 @@ describe(PersonService.name, () => {
|
||||
|
||||
it('should delete existing people if forced', async () => {
|
||||
const face = AssetFaceFactory.from().person().build();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.job.getJobCounts.mockResolvedValue({
|
||||
active: 1,
|
||||
waiting: 0,
|
||||
@@ -669,9 +704,9 @@ describe(PersonService.name, () => {
|
||||
failed: 0,
|
||||
delayed: 0,
|
||||
});
|
||||
mocks.person.getAll.mockReturnValue(makeStream([face.person!, personStub.randomPerson]));
|
||||
mocks.person.getAll.mockReturnValue(makeStream([face.person!, person]));
|
||||
mocks.person.getAllFaces.mockReturnValue(makeStream([face]));
|
||||
mocks.person.getAllWithoutFaces.mockResolvedValue([personStub.randomPerson]);
|
||||
mocks.person.getAllWithoutFaces.mockResolvedValue([person]);
|
||||
mocks.person.unassignFaces.mockResolvedValue();
|
||||
|
||||
await sut.handleQueueRecognizeFaces({ force: true });
|
||||
@@ -684,8 +719,8 @@ describe(PersonService.name, () => {
|
||||
data: { id: face.id, deferred: false },
|
||||
},
|
||||
]);
|
||||
expect(mocks.person.delete).toHaveBeenCalledWith([personStub.randomPerson.id]);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(personStub.randomPerson.thumbnailPath);
|
||||
expect(mocks.person.delete).toHaveBeenCalledWith([person.id]);
|
||||
expect(mocks.storage.unlink).toHaveBeenCalledWith(person.thumbnailPath);
|
||||
expect(mocks.person.vacuum).toHaveBeenCalledWith({ reindexVectors: false });
|
||||
});
|
||||
});
|
||||
@@ -1059,59 +1094,71 @@ describe(PersonService.name, () => {
|
||||
|
||||
describe('mergePerson', () => {
|
||||
it('should require person.write and person.merge permission', async () => {
|
||||
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
||||
mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson);
|
||||
const auth = AuthFactory.create();
|
||||
const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()];
|
||||
|
||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).rejects.toBeInstanceOf(
|
||||
mocks.person.getById.mockResolvedValueOnce(person);
|
||||
mocks.person.getById.mockResolvedValueOnce(mergePerson);
|
||||
|
||||
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
|
||||
expect(mocks.person.reassignFaces).not.toHaveBeenCalled();
|
||||
|
||||
expect(mocks.person.delete).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
|
||||
it('should merge two people without smart merge', async () => {
|
||||
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
||||
mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
|
||||
const auth = AuthFactory.create();
|
||||
const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()];
|
||||
|
||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
||||
{ id: 'person-2', success: true },
|
||||
mocks.person.getById.mockResolvedValueOnce(person);
|
||||
mocks.person.getById.mockResolvedValueOnce(mergePerson);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id]));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id]));
|
||||
|
||||
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([
|
||||
{ id: mergePerson.id, success: true },
|
||||
]);
|
||||
|
||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||
newPersonId: personStub.primaryPerson.id,
|
||||
oldPersonId: personStub.mergePerson.id,
|
||||
newPersonId: person.id,
|
||||
oldPersonId: mergePerson.id,
|
||||
});
|
||||
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
|
||||
it('should merge two people with smart merge', async () => {
|
||||
mocks.person.getById.mockResolvedValueOnce(personStub.randomPerson);
|
||||
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
||||
mocks.person.update.mockResolvedValue({ ...personStub.randomPerson, name: personStub.primaryPerson.name });
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-3']));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
||||
const auth = AuthFactory.create();
|
||||
const [person, mergePerson] = [
|
||||
PersonFactory.create({ name: undefined }),
|
||||
PersonFactory.create({ name: 'Merge person' }),
|
||||
];
|
||||
|
||||
await expect(sut.mergePerson(authStub.admin, 'person-3', { ids: ['person-1'] })).resolves.toEqual([
|
||||
{ id: 'person-1', success: true },
|
||||
mocks.person.getById.mockResolvedValueOnce(person);
|
||||
mocks.person.getById.mockResolvedValueOnce(mergePerson);
|
||||
mocks.person.update.mockResolvedValue({ ...person, name: mergePerson.name });
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id]));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id]));
|
||||
|
||||
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([
|
||||
{ id: mergePerson.id, success: true },
|
||||
]);
|
||||
|
||||
expect(mocks.person.reassignFaces).toHaveBeenCalledWith({
|
||||
newPersonId: personStub.randomPerson.id,
|
||||
oldPersonId: personStub.primaryPerson.id,
|
||||
newPersonId: person.id,
|
||||
oldPersonId: mergePerson.id,
|
||||
});
|
||||
|
||||
expect(mocks.person.update).toHaveBeenCalledWith({
|
||||
id: personStub.randomPerson.id,
|
||||
name: personStub.primaryPerson.name,
|
||||
id: person.id,
|
||||
name: mergePerson.name,
|
||||
});
|
||||
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
|
||||
it('should throw an error when the primary person is not found', async () => {
|
||||
@@ -1126,48 +1173,60 @@ describe(PersonService.name, () => {
|
||||
});
|
||||
|
||||
it('should handle invalid merge ids', async () => {
|
||||
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
||||
{ id: 'person-2', success: false, error: BulkIdErrorReason.NOT_FOUND },
|
||||
mocks.person.getById.mockResolvedValueOnce(person);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id]));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['unknown']));
|
||||
|
||||
await expect(sut.mergePerson(auth, person.id, { ids: ['unknown'] })).resolves.toEqual([
|
||||
{ id: 'unknown', success: false, error: BulkIdErrorReason.NOT_FOUND },
|
||||
]);
|
||||
|
||||
expect(mocks.person.reassignFaces).not.toHaveBeenCalled();
|
||||
expect(mocks.person.delete).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
|
||||
it('should handle an error reassigning faces', async () => {
|
||||
mocks.person.getById.mockResolvedValueOnce(personStub.primaryPerson);
|
||||
mocks.person.getById.mockResolvedValueOnce(personStub.mergePerson);
|
||||
mocks.person.reassignFaces.mockRejectedValue(new Error('update failed'));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-1']));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set(['person-2']));
|
||||
const auth = AuthFactory.create();
|
||||
const [person, mergePerson] = [PersonFactory.create(), PersonFactory.create()];
|
||||
|
||||
await expect(sut.mergePerson(authStub.admin, 'person-1', { ids: ['person-2'] })).resolves.toEqual([
|
||||
{ id: 'person-2', success: false, error: BulkIdErrorReason.UNKNOWN },
|
||||
mocks.person.getById.mockResolvedValueOnce(person);
|
||||
mocks.person.getById.mockResolvedValueOnce(mergePerson);
|
||||
mocks.person.reassignFaces.mockRejectedValue(new Error('update failed'));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([person.id]));
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValueOnce(new Set([mergePerson.id]));
|
||||
|
||||
await expect(sut.mergePerson(auth, person.id, { ids: [mergePerson.id] })).resolves.toEqual([
|
||||
{ id: mergePerson.id, success: false, error: BulkIdErrorReason.UNKNOWN },
|
||||
]);
|
||||
|
||||
expect(mocks.person.delete).not.toHaveBeenCalled();
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getStatistics', () => {
|
||||
it('should get correct number of person', async () => {
|
||||
mocks.person.getById.mockResolvedValue(personStub.primaryPerson);
|
||||
mocks.person.getStatistics.mockResolvedValue(statistics);
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set(['person-1']));
|
||||
await expect(sut.getStatistics(authStub.admin, 'person-1')).resolves.toEqual({ assets: 3 });
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
mocks.person.getStatistics.mockResolvedValue({ assets: 3 });
|
||||
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
|
||||
await expect(sut.getStatistics(auth, person.id)).resolves.toEqual({ assets: 3 });
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
|
||||
it('should require person.read permission', async () => {
|
||||
mocks.person.getById.mockResolvedValue(personStub.primaryPerson);
|
||||
await expect(sut.getStatistics(authStub.admin, 'person-1')).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(authStub.admin.user.id, new Set(['person-1']));
|
||||
const auth = AuthFactory.create();
|
||||
const person = PersonFactory.create();
|
||||
|
||||
mocks.person.getById.mockResolvedValue(person);
|
||||
await expect(sut.getStatistics(auth, person.id)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(mocks.access.person.checkOwnerAccess).toHaveBeenCalledWith(auth.user.id, new Set([person.id]));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -595,7 +595,7 @@ export class PersonService extends BaseService {
|
||||
update.birthDate = mergePerson.birthDate;
|
||||
}
|
||||
|
||||
if (Object.keys(update).length > 0) {
|
||||
if (Object.keys(update).length > 1) {
|
||||
primaryPerson = await this.personRepository.update(update);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ import { SearchService } from 'src/services/search.service';
|
||||
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';
|
||||
import { beforeEach, vitest } from 'vitest';
|
||||
|
||||
@@ -26,17 +25,18 @@ describe(SearchService.name, () => {
|
||||
|
||||
describe('searchPerson', () => {
|
||||
it('should pass options to search', async () => {
|
||||
const { name } = personStub.withName;
|
||||
const auth = AuthFactory.create();
|
||||
const name = 'foo';
|
||||
|
||||
mocks.person.getByName.mockResolvedValue([]);
|
||||
|
||||
await sut.searchPerson(authStub.user1, { name, withHidden: false });
|
||||
await sut.searchPerson(auth, { name, withHidden: false });
|
||||
|
||||
expect(mocks.person.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: false });
|
||||
expect(mocks.person.getByName).toHaveBeenCalledWith(auth.user.id, name, { withHidden: false });
|
||||
|
||||
await sut.searchPerson(authStub.user1, { name, withHidden: true });
|
||||
await sut.searchPerson(auth, { name, withHidden: true });
|
||||
|
||||
expect(mocks.person.getByName).toHaveBeenCalledWith(authStub.user1.user.id, name, { withHidden: true });
|
||||
expect(mocks.person.getByName).toHaveBeenCalledWith(auth.user.id, name, { withHidden: true });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
165
server/test/fixtures/person.stub.ts
vendored
165
server/test/fixtures/person.stub.ts
vendored
@@ -2,171 +2,6 @@ import { AssetFileType, AssetType } from 'src/enum';
|
||||
import { AssetFileFactory } from 'test/factories/asset-file.factory';
|
||||
import { userStub } from 'test/fixtures/user.stub';
|
||||
|
||||
const updateId = '0d1173e3-4d80-4d76-b41e-57d56de21125';
|
||||
|
||||
export const personStub = {
|
||||
noName: Object.freeze({
|
||||
id: 'person-1',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
name: '',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
faces: [],
|
||||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
color: 'red',
|
||||
}),
|
||||
hidden: Object.freeze({
|
||||
id: 'person-1',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
name: '',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
faces: [],
|
||||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: true,
|
||||
isFavorite: false,
|
||||
color: 'red',
|
||||
}),
|
||||
withName: Object.freeze({
|
||||
id: 'person-1',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
name: 'Person 1',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
faces: [],
|
||||
faceAssetId: 'assetFaceId',
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
color: 'red',
|
||||
}),
|
||||
withBirthDate: Object.freeze({
|
||||
id: 'person-1',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
name: 'Person 1',
|
||||
birthDate: new Date('1976-06-30'),
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
faces: [],
|
||||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
color: 'red',
|
||||
}),
|
||||
noThumbnail: Object.freeze({
|
||||
id: 'person-1',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
name: '',
|
||||
birthDate: null,
|
||||
thumbnailPath: '',
|
||||
faces: [],
|
||||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
color: 'red',
|
||||
}),
|
||||
newThumbnail: Object.freeze({
|
||||
id: 'person-1',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
name: '',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/new/path/to/thumbnail.jpg',
|
||||
faces: [],
|
||||
faceAssetId: 'asset-id',
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
color: 'red',
|
||||
}),
|
||||
primaryPerson: Object.freeze({
|
||||
id: 'person-1',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
name: 'Person 1',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail',
|
||||
faces: [],
|
||||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
color: 'red',
|
||||
}),
|
||||
mergePerson: Object.freeze({
|
||||
id: 'person-2',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
name: 'Person 2',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail',
|
||||
faces: [],
|
||||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
color: 'red',
|
||||
}),
|
||||
randomPerson: Object.freeze({
|
||||
id: 'person-3',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
name: '',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail',
|
||||
faces: [],
|
||||
faceAssetId: null,
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: false,
|
||||
color: 'red',
|
||||
}),
|
||||
isFavorite: Object.freeze({
|
||||
id: 'person-4',
|
||||
createdAt: new Date('2021-01-01'),
|
||||
updatedAt: new Date('2021-01-01'),
|
||||
updateId,
|
||||
ownerId: userStub.admin.id,
|
||||
name: 'Person 1',
|
||||
birthDate: null,
|
||||
thumbnailPath: '/path/to/thumbnail.jpg',
|
||||
faces: [],
|
||||
faceAssetId: 'assetFaceId',
|
||||
faceAsset: null,
|
||||
isHidden: false,
|
||||
isFavorite: true,
|
||||
color: 'red',
|
||||
}),
|
||||
};
|
||||
|
||||
export const personThumbnailStub = {
|
||||
newThumbnailStart: Object.freeze({
|
||||
ownerId: userStub.admin.id,
|
||||
|
||||
63
web/src/lib/actions/image-loader.svelte.ts
Normal file
63
web/src/lib/actions/image-loader.svelte.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { cancelImageUrl } from '$lib/utils/sw-messaging';
|
||||
|
||||
const destroyImageElement = (
|
||||
imgElement: HTMLImageElement,
|
||||
currentSrc: string | undefined,
|
||||
handleLoad: () => void,
|
||||
handleError: () => void,
|
||||
) => {
|
||||
imgElement.removeEventListener('load', handleLoad);
|
||||
imgElement.removeEventListener('error', handleError);
|
||||
cancelImageUrl(currentSrc);
|
||||
imgElement.remove();
|
||||
};
|
||||
|
||||
const createImageElement = (
|
||||
src: string | undefined,
|
||||
|
||||
onLoad: () => void,
|
||||
onError: () => void,
|
||||
onStart?: () => void,
|
||||
) => {
|
||||
if (!src) {
|
||||
return undefined;
|
||||
}
|
||||
const img = document.createElement('img');
|
||||
|
||||
img.addEventListener('load', onLoad);
|
||||
img.addEventListener('error', onError);
|
||||
|
||||
onStart?.();
|
||||
img.src = src;
|
||||
|
||||
return img;
|
||||
};
|
||||
|
||||
export function loadImage(
|
||||
src: string,
|
||||
|
||||
onLoad: () => void,
|
||||
onError: () => void,
|
||||
onStart?: () => void,
|
||||
) {
|
||||
let destroyed = false;
|
||||
const wrapper = (fn: (() => void) | undefined) => () => {
|
||||
if (destroyed) {
|
||||
return;
|
||||
}
|
||||
fn?.();
|
||||
};
|
||||
const wrappedOnLoad = wrapper(onLoad);
|
||||
const wrappedOnError = wrapper(onError);
|
||||
const wrappedOnStart = wrapper(onStart);
|
||||
const img = createImageElement(src, wrappedOnLoad, wrappedOnError, wrappedOnStart);
|
||||
if (!img) {
|
||||
return () => {};
|
||||
}
|
||||
return () => {
|
||||
destroyed = true;
|
||||
destroyImageElement(img, src, wrappedOnLoad, wrappedOnError);
|
||||
};
|
||||
}
|
||||
|
||||
export type LoadImageFunction = typeof loadImage;
|
||||
11
web/src/lib/components/AlphaBackground.svelte
Normal file
11
web/src/lib/components/AlphaBackground.svelte
Normal file
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
import type { ClassValue } from 'svelte/elements';
|
||||
|
||||
interface Props {
|
||||
class?: ClassValue;
|
||||
}
|
||||
|
||||
let { class: className = '' }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="absolute h-full w-full bg-gray-300 dark:bg-gray-700 {className}"></div>
|
||||
@@ -1,13 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { t } from 'svelte-i18n';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Oops! Error - Immich</title>
|
||||
<title>{$t('error')} - Immich</title>
|
||||
</svelte:head>
|
||||
|
||||
<section class="flex flex-col px-4 h-dvh w-dvw place-content-center place-items-center">
|
||||
<h1 class="py-10 text-4xl text-primary">Page not found :/</h1>
|
||||
<h1 class="py-10 text-4xl text-primary">{$t('errors.page_not_found')}</h1>
|
||||
{#if page.error?.message}
|
||||
<h2 class="text-xl text-immich-fg dark:text-immich-dark-fg">{page.error.message}</h2>
|
||||
{/if}
|
||||
|
||||
@@ -4,7 +4,38 @@ import { AssetMediaSize, type AssetResponseDto } from '@immich/sdk';
|
||||
|
||||
type AllAssetMediaSize = AssetMediaSize | 'all';
|
||||
|
||||
type AssetLoadState = 'loading' | 'cancelled';
|
||||
|
||||
class ImageManager {
|
||||
// track recently canceled assets, so know if an load "error" is due to
|
||||
// cancelation
|
||||
private assetStates = new Map<string, AssetLoadState>();
|
||||
private readonly MAX_TRACKED_ASSETS = 10;
|
||||
|
||||
private trackAction(asset: AssetResponseDto, action: AssetLoadState) {
|
||||
// Remove if exists to reset insertion order
|
||||
this.assetStates.delete(asset.id);
|
||||
this.assetStates.set(asset.id, action);
|
||||
|
||||
// Only keep recent assets (Map maintains insertion order)
|
||||
if (this.assetStates.size > this.MAX_TRACKED_ASSETS) {
|
||||
const firstKey = this.assetStates.keys().next().value!;
|
||||
this.assetStates.delete(firstKey);
|
||||
}
|
||||
}
|
||||
|
||||
isCanceled(asset: AssetResponseDto) {
|
||||
return 'cancelled' === this.assetStates.get(asset.id);
|
||||
}
|
||||
|
||||
trackLoad(asset: AssetResponseDto) {
|
||||
this.trackAction(asset, 'loading');
|
||||
}
|
||||
|
||||
trackCancelled(asset: AssetResponseDto) {
|
||||
this.trackAction(asset, 'cancelled');
|
||||
}
|
||||
|
||||
preload(asset: AssetResponseDto | undefined, size: AssetMediaSize = AssetMediaSize.Preview) {
|
||||
if (!asset) {
|
||||
return;
|
||||
@@ -15,6 +46,8 @@ class ImageManager {
|
||||
return;
|
||||
}
|
||||
|
||||
this.trackLoad(asset);
|
||||
|
||||
const img = new Image();
|
||||
img.src = url;
|
||||
}
|
||||
@@ -24,6 +57,8 @@ class ImageManager {
|
||||
return;
|
||||
}
|
||||
|
||||
this.trackCancelled(asset);
|
||||
|
||||
const sizes = size === 'all' ? Object.values(AssetMediaSize) : [size];
|
||||
for (const size of sizes) {
|
||||
const url = getAssetMediaUrl({ id: asset.id, size, cacheKey: asset.thumbhash });
|
||||
|
||||
@@ -198,13 +198,10 @@ export const getAssetUrl = ({
|
||||
sharedLink,
|
||||
forceOriginal = false,
|
||||
}: {
|
||||
asset: AssetResponseDto | undefined;
|
||||
asset: AssetResponseDto;
|
||||
sharedLink?: SharedLinkResponseDto;
|
||||
forceOriginal?: boolean;
|
||||
}) => {
|
||||
if (!asset) {
|
||||
return;
|
||||
}
|
||||
const id = asset.id;
|
||||
const cacheKey = asset.thumbhash;
|
||||
if (sharedLink && (!sharedLink.allowDownload || !sharedLink.showMetadata)) {
|
||||
|
||||
54
web/src/lib/utils/layout-utils.spec.ts
Normal file
54
web/src/lib/utils/layout-utils.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { scaleToFit } from '$lib/utils/layout-utils';
|
||||
|
||||
describe('scaleToFit', () => {
|
||||
const tests = [
|
||||
{
|
||||
name: 'landscape image in square container',
|
||||
dimensions: { width: 2000, height: 1000 },
|
||||
container: { width: 500, height: 500 },
|
||||
expected: { width: 500, height: 250 },
|
||||
},
|
||||
{
|
||||
name: 'portrait image in square container',
|
||||
dimensions: { width: 1000, height: 2000 },
|
||||
container: { width: 500, height: 500 },
|
||||
expected: { width: 250, height: 500 },
|
||||
},
|
||||
{
|
||||
name: 'square image in square container',
|
||||
dimensions: { width: 1000, height: 1000 },
|
||||
container: { width: 500, height: 500 },
|
||||
expected: { width: 500, height: 500 },
|
||||
},
|
||||
{
|
||||
name: 'landscape image in landscape container',
|
||||
dimensions: { width: 1600, height: 900 },
|
||||
container: { width: 800, height: 600 },
|
||||
expected: { width: 800, height: 450 },
|
||||
},
|
||||
{
|
||||
name: 'portrait image in portrait container',
|
||||
dimensions: { width: 900, height: 1600 },
|
||||
container: { width: 600, height: 800 },
|
||||
expected: { width: 450, height: 800 },
|
||||
},
|
||||
{
|
||||
name: 'image matches container exactly',
|
||||
dimensions: { width: 500, height: 300 },
|
||||
container: { width: 500, height: 300 },
|
||||
expected: { width: 500, height: 300 },
|
||||
},
|
||||
{
|
||||
name: 'image smaller than container scales up',
|
||||
dimensions: { width: 100, height: 50 },
|
||||
container: { width: 400, height: 400 },
|
||||
expected: { width: 400, height: 200 },
|
||||
},
|
||||
];
|
||||
|
||||
for (const { name, dimensions, container, expected } of tests) {
|
||||
it(`should handle ${name}`, () => {
|
||||
expect(scaleToFit(dimensions, container)).toEqual(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -129,3 +129,19 @@ export type CommonPosition = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
// Scales dimensions to fit within a container (like object-fit: contain)
|
||||
export const scaleToFit = (
|
||||
dimensions: { width: number; height: number },
|
||||
container: { width: number; height: number },
|
||||
) => {
|
||||
const scaleX = container.width / dimensions.width;
|
||||
const scaleY = container.height / dimensions.height;
|
||||
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
return {
|
||||
width: dimensions.width * scale,
|
||||
height: dimensions.height * scale,
|
||||
};
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user