From ca6c486a804dd6082f94bdfe28ceabded270864e Mon Sep 17 00:00:00 2001
From: Daniel Dietzler <36593685+danieldietzler@users.noreply.github.com>
Date: Wed, 25 Feb 2026 14:56:00 +0100
Subject: [PATCH 1/5] refactor: person stubs (#26512)
---
server/src/services/media.service.spec.ts | 108 +++--
server/src/services/metadata.service.spec.ts | 39 +-
server/src/services/person.service.spec.ts | 459 +++++++++++--------
server/src/services/person.service.ts | 2 +-
server/src/services/search.service.spec.ts | 12 +-
server/test/fixtures/person.stub.ts | 165 -------
6 files changed, 344 insertions(+), 441 deletions(-)
diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts
index 5317989739..fc825fb273 100644
--- a/server/src/services/media.service.spec.ts
+++ b/server/src/services/media.service.spec.ts
@@ -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, {
diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts
index 1080407922..feaba36b1d 100644
--- a/server/src/services/metadata.service.spec.ts
+++ b/server/src/services/metadata.service.spec.ts
@@ -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 },
},
]);
},
diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts
index d7c9fa9f59..c22fd65a1a 100644
--- a/server/src/services/person.service.spec.ts
+++ b/server/src/services/person.service.spec.ts
@@ -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]));
});
});
diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts
index 090b358223..8a902590e3 100644
--- a/server/src/services/person.service.ts
+++ b/server/src/services/person.service.ts
@@ -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);
}
diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts
index 5f1125eaed..62575d0f07 100644
--- a/server/src/services/search.service.spec.ts
+++ b/server/src/services/search.service.spec.ts
@@ -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 });
});
});
diff --git a/server/test/fixtures/person.stub.ts b/server/test/fixtures/person.stub.ts
index 9d48fcc8f8..6ab32e1f02 100644
--- a/server/test/fixtures/person.stub.ts
+++ b/server/test/fixtures/person.stub.ts
@@ -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,
From 55e625a2accc9a06749b49c872887364cec804ae Mon Sep 17 00:00:00 2001
From: Mees Frensel <33722705+meesfrensel@users.noreply.github.com>
Date: Wed, 25 Feb 2026 18:35:25 +0100
Subject: [PATCH 2/5] fix(web): error page i18n (#26517)
---
i18n/en.json | 1 +
web/src/lib/components/pages/SharedLinkErrorPage.svelte | 5 +++--
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/i18n/en.json b/i18n/en.json
index 2d77cc3f2f..b99dac5609 100644
--- a/i18n/en.json
+++ b/i18n/en.json
@@ -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",
diff --git a/web/src/lib/components/pages/SharedLinkErrorPage.svelte b/web/src/lib/components/pages/SharedLinkErrorPage.svelte
index 590f4cb270..be03417d85 100644
--- a/web/src/lib/components/pages/SharedLinkErrorPage.svelte
+++ b/web/src/lib/components/pages/SharedLinkErrorPage.svelte
@@ -1,13 +1,14 @@
- Oops! Error - Immich
+ {$t('error')} - Immich
- Page not found :/
+ {$t('errors.page_not_found')}
{#if page.error?.message}
{page.error.message}
{/if}
From 3c9fb651d0a6ba2fb90f6dc6c2e4015e590795a4 Mon Sep 17 00:00:00 2001
From: Brandon Wees
Date: Wed, 25 Feb 2026 12:12:41 -0600
Subject: [PATCH 3/5] feat(server): SyncAssetEditV1 (#26446)
* feat: SyncAssetEditV1
* fix: audit table import
* fix: sql tools table fetch
* fix: medium tests (wip)
* fix: circ dependency
* chore: finalize tests
* chore: codegen/lint
* fix: code review
---
mobile/openapi/README.md | 2 +
mobile/openapi/lib/api.dart | 2 +
mobile/openapi/lib/api_client.dart | 4 +
.../lib/model/sync_asset_edit_delete_v1.dart | 99 ++++++
.../openapi/lib/model/sync_asset_edit_v1.dart | 131 ++++++++
.../openapi/lib/model/sync_entity_type.dart | 6 +
.../openapi/lib/model/sync_request_type.dart | 3 +
open-api/immich-openapi-specs.json | 45 +++
open-api/typescript-sdk/src/fetch-client.ts | 13 +
server/src/database.ts | 7 +
server/src/dtos/editing.dto.ts | 7 +-
server/src/dtos/sync.dto.ts | 21 ++
server/src/enum.ts | 3 +
server/src/queries/asset.edit.repository.sql | 14 +
server/src/queries/sync.repository.sql | 32 ++
.../src/repositories/asset-edit.repository.ts | 19 +-
server/src/repositories/sync.repository.ts | 28 +-
.../src/repositories/websocket.repository.ts | 4 +-
server/src/schema/functions.ts | 13 +
server/src/schema/index.ts | 3 +
.../migrations/1771873813973-AssetEditSync.ts | 53 ++++
.../schema/tables/asset-edit-audit.table.ts | 17 +
server/src/schema/tables/asset-edit.table.ts | 18 +-
server/src/services/asset.service.ts | 1 +
server/src/services/job.service.ts | 2 +
server/src/services/sync.service.ts | 18 ++
server/test/factories/asset-edit.factory.ts | 5 +-
.../specs/services/audit.database.spec.ts | 23 ++
.../specs/services/sync.service.spec.ts | 19 +-
.../medium/specs/sync/sync-asset-edit.spec.ts | 300 ++++++++++++++++++
web/src/lib/stores/websocket.ts | 3 +-
31 files changed, 897 insertions(+), 18 deletions(-)
create mode 100644 mobile/openapi/lib/model/sync_asset_edit_delete_v1.dart
create mode 100644 mobile/openapi/lib/model/sync_asset_edit_v1.dart
create mode 100644 server/src/schema/migrations/1771873813973-AssetEditSync.ts
create mode 100644 server/src/schema/tables/asset-edit-audit.table.ts
create mode 100644 server/test/medium/specs/sync/sync-asset-edit.spec.ts
diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md
index 02daa8543d..bb437787cb 100644
--- a/mobile/openapi/README.md
+++ b/mobile/openapi/README.md
@@ -576,6 +576,8 @@ Class | Method | HTTP request | Description
- [SyncAlbumUserV1](doc//SyncAlbumUserV1.md)
- [SyncAlbumV1](doc//SyncAlbumV1.md)
- [SyncAssetDeleteV1](doc//SyncAssetDeleteV1.md)
+ - [SyncAssetEditDeleteV1](doc//SyncAssetEditDeleteV1.md)
+ - [SyncAssetEditV1](doc//SyncAssetEditV1.md)
- [SyncAssetExifV1](doc//SyncAssetExifV1.md)
- [SyncAssetFaceDeleteV1](doc//SyncAssetFaceDeleteV1.md)
- [SyncAssetFaceV1](doc//SyncAssetFaceV1.md)
diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart
index bfbe829d8d..253e8a6811 100644
--- a/mobile/openapi/lib/api.dart
+++ b/mobile/openapi/lib/api.dart
@@ -315,6 +315,8 @@ part 'model/sync_album_user_delete_v1.dart';
part 'model/sync_album_user_v1.dart';
part 'model/sync_album_v1.dart';
part 'model/sync_asset_delete_v1.dart';
+part 'model/sync_asset_edit_delete_v1.dart';
+part 'model/sync_asset_edit_v1.dart';
part 'model/sync_asset_exif_v1.dart';
part 'model/sync_asset_face_delete_v1.dart';
part 'model/sync_asset_face_v1.dart';
diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart
index e703542b52..bfe469e7c0 100644
--- a/mobile/openapi/lib/api_client.dart
+++ b/mobile/openapi/lib/api_client.dart
@@ -676,6 +676,10 @@ class ApiClient {
return SyncAlbumV1.fromJson(value);
case 'SyncAssetDeleteV1':
return SyncAssetDeleteV1.fromJson(value);
+ case 'SyncAssetEditDeleteV1':
+ return SyncAssetEditDeleteV1.fromJson(value);
+ case 'SyncAssetEditV1':
+ return SyncAssetEditV1.fromJson(value);
case 'SyncAssetExifV1':
return SyncAssetExifV1.fromJson(value);
case 'SyncAssetFaceDeleteV1':
diff --git a/mobile/openapi/lib/model/sync_asset_edit_delete_v1.dart b/mobile/openapi/lib/model/sync_asset_edit_delete_v1.dart
new file mode 100644
index 0000000000..68af280290
--- /dev/null
+++ b/mobile/openapi/lib/model/sync_asset_edit_delete_v1.dart
@@ -0,0 +1,99 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class SyncAssetEditDeleteV1 {
+ /// Returns a new [SyncAssetEditDeleteV1] instance.
+ SyncAssetEditDeleteV1({
+ required this.editId,
+ });
+
+ String editId;
+
+ @override
+ bool operator ==(Object other) => identical(this, other) || other is SyncAssetEditDeleteV1 &&
+ other.editId == editId;
+
+ @override
+ int get hashCode =>
+ // ignore: unnecessary_parenthesis
+ (editId.hashCode);
+
+ @override
+ String toString() => 'SyncAssetEditDeleteV1[editId=$editId]';
+
+ Map toJson() {
+ final json = {};
+ json[r'editId'] = this.editId;
+ return json;
+ }
+
+ /// Returns a new [SyncAssetEditDeleteV1] instance and imports its values from
+ /// [value] if it's a [Map], null otherwise.
+ // ignore: prefer_constructors_over_static_methods
+ static SyncAssetEditDeleteV1? fromJson(dynamic value) {
+ upgradeDto(value, "SyncAssetEditDeleteV1");
+ if (value is Map) {
+ final json = value.cast();
+
+ return SyncAssetEditDeleteV1(
+ editId: mapValueOfType(json, r'editId')!,
+ );
+ }
+ return null;
+ }
+
+ static List listFromJson(dynamic json, {bool growable = false,}) {
+ final result = [];
+ if (json is List && json.isNotEmpty) {
+ for (final row in json) {
+ final value = SyncAssetEditDeleteV1.fromJson(row);
+ if (value != null) {
+ result.add(value);
+ }
+ }
+ }
+ return result.toList(growable: growable);
+ }
+
+ static Map mapFromJson(dynamic json) {
+ final map = {};
+ if (json is Map && json.isNotEmpty) {
+ json = json.cast(); // ignore: parameter_assignments
+ for (final entry in json.entries) {
+ final value = SyncAssetEditDeleteV1.fromJson(entry.value);
+ if (value != null) {
+ map[entry.key] = value;
+ }
+ }
+ }
+ return map;
+ }
+
+ // maps a json object with a list of SyncAssetEditDeleteV1-objects as value to a dart map
+ static Map> mapListFromJson(dynamic json, {bool growable = false,}) {
+ final map = >{};
+ if (json is Map && json.isNotEmpty) {
+ // ignore: parameter_assignments
+ json = json.cast();
+ for (final entry in json.entries) {
+ map[entry.key] = SyncAssetEditDeleteV1.listFromJson(entry.value, growable: growable,);
+ }
+ }
+ return map;
+ }
+
+ /// The list of required keys that must be present in a JSON.
+ static const requiredKeys = {
+ 'editId',
+ };
+}
+
diff --git a/mobile/openapi/lib/model/sync_asset_edit_v1.dart b/mobile/openapi/lib/model/sync_asset_edit_v1.dart
new file mode 100644
index 0000000000..3cc2673bfc
--- /dev/null
+++ b/mobile/openapi/lib/model/sync_asset_edit_v1.dart
@@ -0,0 +1,131 @@
+//
+// AUTO-GENERATED FILE, DO NOT MODIFY!
+//
+// @dart=2.18
+
+// ignore_for_file: unused_element, unused_import
+// ignore_for_file: always_put_required_named_parameters_first
+// ignore_for_file: constant_identifier_names
+// ignore_for_file: lines_longer_than_80_chars
+
+part of openapi.api;
+
+class SyncAssetEditV1 {
+ /// Returns a new [SyncAssetEditV1] instance.
+ SyncAssetEditV1({
+ required this.action,
+ required this.assetId,
+ required this.id,
+ required this.parameters,
+ required this.sequence,
+ });
+
+ AssetEditAction action;
+
+ String assetId;
+
+ String id;
+
+ Object parameters;
+
+ int sequence;
+
+ @override
+ bool operator ==(Object other) => identical(this, other) || other is SyncAssetEditV1 &&
+ other.action == action &&
+ other.assetId == assetId &&
+ other.id == id &&
+ other.parameters == parameters &&
+ other.sequence == sequence;
+
+ @override
+ int get hashCode =>
+ // ignore: unnecessary_parenthesis
+ (action.hashCode) +
+ (assetId.hashCode) +
+ (id.hashCode) +
+ (parameters.hashCode) +
+ (sequence.hashCode);
+
+ @override
+ String toString() => 'SyncAssetEditV1[action=$action, assetId=$assetId, id=$id, parameters=$parameters, sequence=$sequence]';
+
+ Map toJson() {
+ final json = {};
+ json[r'action'] = this.action;
+ json[r'assetId'] = this.assetId;
+ json[r'id'] = this.id;
+ json[r'parameters'] = this.parameters;
+ json[r'sequence'] = this.sequence;
+ return json;
+ }
+
+ /// Returns a new [SyncAssetEditV1] instance and imports its values from
+ /// [value] if it's a [Map], null otherwise.
+ // ignore: prefer_constructors_over_static_methods
+ static SyncAssetEditV1? fromJson(dynamic value) {
+ upgradeDto(value, "SyncAssetEditV1");
+ if (value is Map) {
+ final json = value.cast();
+
+ return SyncAssetEditV1(
+ action: AssetEditAction.fromJson(json[r'action'])!,
+ assetId: mapValueOfType(json, r'assetId')!,
+ id: mapValueOfType(json, r'id')!,
+ parameters: mapValueOfType