Compare commits

...

6 Commits

Author SHA1 Message Date
midzelis
21d2ce859a refactor: create DisplayMetrics so overlays work with object-fit or explicit positions 2026-02-25 18:56:31 +00:00
Mees Frensel
55e625a2ac fix(web): error page i18n (#26517) 2026-02-25 18:35:25 +01:00
Daniel Dietzler
ca6c486a80 refactor: person stubs (#26512) 2026-02-25 08:56:00 -05:00
socksprox
d94d9600a7 fix(mobile): birthday picker shows limited months when no date exists (#26407)
* ScrollDatePicker defaults maximumDate to DateTime.now(). When no birthday exists, the picker starts at today (Feb 2026) with max also Feb 2026 — so only Jan–Feb are available for the current year.

Fix applied: Added maximumDate: DateTime(DateTime.now().year, 12, 31) at person_edit_birthday_modal.widget.dart:93, allowing all 12 months to be selected while still preventing future-year birthdays.

* fix(mobile): initialize birthday picker to past date to prevent future birthdays

When no birthday exists, initialize to 30 years ago instead of today.
This allows all 12 months to be selectable while keeping maximumDate
as DateTime.now() to prevent future birthday selection.

Fixes issue where only current months were available due to maxDate constraint.

---------

Co-authored-by: socksprox <info@shadowfly.net>
2026-02-25 07:58:02 +05:30
Mees Frensel
11e5c42bc9 fix(web): toast warning when trying to upload unsupported file type (#26492) 2026-02-24 15:58:40 -05:00
shenlong
33c6cf8325 test: backup repository (#26494)
test: backup repository tests

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
2026-02-24 15:50:02 -05:00
16 changed files with 705 additions and 625 deletions

View File

@@ -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",
@@ -2307,6 +2308,7 @@
"unstack_action_prompt": "{count} unstacked",
"unstacked_assets_count": "Un-stacked {count, plural, one {# asset} other {# assets}}",
"unsupported_field_type": "Unsupported field type",
"unsupported_file_type": "File {file} can't be uploaded because its file type {type} is not supported.",
"untagged": "Untagged",
"untitled_workflow": "Untitled workflow",
"up_next": "Up next",

View File

@@ -25,7 +25,7 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
@override
void initState() {
super.initState();
_selectedDate = widget.person.birthDate ?? DateTime.now();
_selectedDate = widget.person.birthDate ?? DateTime(DateTime.now().year - 30, 1, 1);
}
void saveBirthday() async {
@@ -90,6 +90,7 @@ class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEdi
selectedDate: _selectedDate,
locale: context.locale,
minimumDate: DateTime(1800, 1, 1),
maximumDate: DateTime.now(),
onDateTimeChanged: (DateTime value) {
setState(() {
_selectedDate = value;

View File

@@ -0,0 +1,244 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/infrastructure/repositories/backup.repository.dart';
import 'package:immich_mobile/utils/option.dart';
import '../../medium/repository_context.dart';
void main() {
late MediumRepositoryContext ctx;
late DriftBackupRepository sut;
setUp(() {
ctx = MediumRepositoryContext();
sut = DriftBackupRepository(ctx.db);
});
tearDown(() async {
await ctx.dispose();
});
group('getAllCounts', () {
late String userId;
setUp(() async {
final user = await ctx.newUser();
userId = user.id;
});
test('returns zeros when no albums exist', () async {
final result = await sut.getAllCounts(userId);
expect(result.total, 0);
expect(result.remainder, 0);
expect(result.processing, 0);
});
test('returns zeros when no selected albums exist', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.none);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 0);
expect(result.remainder, 0);
expect(result.processing, 0);
});
test('counts asset in selected album as total and remainder', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
expect(result.remainder, 1);
expect(result.processing, 0);
});
test('backed up asset reduces remainder', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final remote = await ctx.newRemoteAsset(ownerId: userId);
final local = await ctx.newLocalAsset(checksum: remote.checksum);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
expect(result.remainder, 0);
expect(result.processing, 0);
});
test('asset with null checksum is counted as processing', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset(checksumOption: const Option.none());
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
expect(result.remainder, 1);
expect(result.processing, 1);
});
test('asset in excluded album is not counted even if also in selected album', () async {
final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final excludedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.excluded);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: asset.id);
await ctx.newLocalAlbumAsset(albumId: excludedAlbum.id, assetId: asset.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 0);
expect(result.remainder, 0);
});
test('counts assets across multiple selected albums without duplicates', () async {
final album1 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final album2 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset();
// Same asset in two selected albums
await ctx.newLocalAlbumAsset(albumId: album1.id, assetId: asset.id);
await ctx.newLocalAlbumAsset(albumId: album2.id, assetId: asset.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
});
test('backed up asset for different user is still counted as remainder', () async {
final otherUser = await ctx.newUser();
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final remote = await ctx.newRemoteAsset(ownerId: otherUser.id);
final local = await ctx.newLocalAsset(checksum: remote.checksum);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 1);
expect(result.remainder, 1);
});
test('mixed assets produce correct combined counts', () async {
final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
// backed up
final remote1 = await ctx.newRemoteAsset(ownerId: userId);
final local1 = await ctx.newLocalAsset(checksum: remote1.checksum);
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: local1.id);
// not backed up, has checksum
final local2 = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: local2.id);
// processing (null checksum)
final local3 = await ctx.newLocalAsset(checksumOption: const Option.none());
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: local3.id);
final result = await sut.getAllCounts(userId);
expect(result.total, 3);
expect(result.remainder, 2); // local2 + local3
expect(result.processing, 1); // local3
});
});
group('getCandidates', () {
late String userId;
setUp(() async {
final user = await ctx.newUser();
userId = user.id;
});
test('returns empty list when no selected albums exist', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.none);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getCandidates(userId);
expect(result, isEmpty);
});
test('returns asset in selected album that is not backed up', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getCandidates(userId);
expect(result.length, 1);
expect(result.first.id, asset.id);
});
test('excludes asset already backed up for the same user', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final remote = await ctx.newRemoteAsset(ownerId: userId);
final local = await ctx.newLocalAsset(checksum: remote.checksum);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getCandidates(userId);
expect(result, isEmpty);
});
test('includes asset backed up for a different user', () async {
final otherUser = await ctx.newUser();
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final remote = await ctx.newRemoteAsset(ownerId: otherUser.id);
final local = await ctx.newLocalAsset(checksum: remote.checksum);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: local.id);
final result = await sut.getCandidates(userId);
expect(result.length, 1);
expect(result.first.id, local.id);
});
test('excludes asset in excluded album even if also in selected album', () async {
final selectedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final excludedAlbum = await ctx.newLocalAlbum(backupSelection: BackupSelection.excluded);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: selectedAlbum.id, assetId: asset.id);
await ctx.newLocalAlbumAsset(albumId: excludedAlbum.id, assetId: asset.id);
final result = await sut.getCandidates(userId);
expect(result, isEmpty);
});
test('excludes asset with null checksum when onlyHashed is true', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset(checksumOption: const Option.none());
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getCandidates(userId);
expect(result, isEmpty);
});
test('includes asset with null checksum when onlyHashed is false', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset(checksumOption: const Option.none());
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset.id);
final result = await sut.getCandidates(userId, onlyHashed: false);
expect(result.length, 1);
expect(result.first.id, asset.id);
});
test('returns assets ordered by createdAt descending', () async {
final album = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset1 = await ctx.newLocalAsset(createdAt: DateTime(2024, 1, 1));
final asset2 = await ctx.newLocalAsset(createdAt: DateTime(2024, 3, 1));
final asset3 = await ctx.newLocalAsset(createdAt: DateTime(2024, 2, 1));
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset1.id);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset2.id);
await ctx.newLocalAlbumAsset(albumId: album.id, assetId: asset3.id);
final result = await sut.getCandidates(userId);
expect(result.map((a) => a.id).toList(), [asset2.id, asset3.id, asset1.id]);
});
test('does not return duplicate when asset is in multiple selected albums', () async {
final album1 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final album2 = await ctx.newLocalAlbum(backupSelection: BackupSelection.selected);
final asset = await ctx.newLocalAsset();
await ctx.newLocalAlbumAsset(albumId: album1.id, assetId: asset.id);
await ctx.newLocalAlbumAsset(albumId: album2.id, assetId: asset.id);
final result = await sut.getCandidates(userId);
expect(result.length, 1);
expect(result.first.id, asset.id);
});
});
}

View File

@@ -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, {

View File

@@ -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 },
},
]);
},

View File

@@ -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]));
});
});

View File

@@ -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);
}

View File

@@ -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 });
});
});

View File

@@ -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,

View File

@@ -3,6 +3,7 @@
import { assetViewingStore } from '$lib/stores/asset-viewing.store';
import { isFaceEditMode } from '$lib/stores/face-edit.svelte';
import { getPeopleThumbnailUrl } from '$lib/utils';
import { getDisplayMetrics, getNaturalSize } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { createFace, getAllPeople, type PersonResponseDto } from '@immich/sdk';
import { Button, Input, modalManager, toastManager } from '@immich/ui';
@@ -78,17 +79,13 @@
});
$effect(() => {
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
const offsetArea = {
width: (containerWidth - actualWidth) / 2,
height: (containerHeight - actualHeight) / 2,
};
const metrics = getDisplayMetrics(htmlElement);
const imageBoundingBox = {
top: offsetArea.height,
left: offsetArea.width,
width: containerWidth - offsetArea.width * 2,
height: containerHeight - offsetArea.height * 2,
top: metrics.offsetY,
left: metrics.offsetX,
width: metrics.displayWidth,
height: metrics.displayHeight,
};
if (!canvas) {
@@ -113,32 +110,6 @@
positionFaceSelector();
});
const getContainedSize = (
img: HTMLImageElement | HTMLVideoElement,
): { actualWidth: number; actualHeight: number } => {
if (img instanceof HTMLImageElement) {
const ratio = img.naturalWidth / img.naturalHeight;
let actualWidth = img.height * ratio;
let actualHeight = img.height;
if (actualWidth > img.width) {
actualWidth = img.width;
actualHeight = img.width / ratio;
}
return { actualWidth, actualHeight };
} else if (img instanceof HTMLVideoElement) {
const ratio = img.videoWidth / img.videoHeight;
let actualWidth = img.clientHeight * ratio;
let actualHeight = img.clientHeight;
if (actualWidth > img.clientWidth) {
actualWidth = img.clientWidth;
actualHeight = img.clientWidth / ratio;
}
return { actualWidth, actualHeight };
}
return { actualWidth: 0, actualHeight: 0 };
};
const cancel = () => {
isFaceEditMode.value = false;
};
@@ -229,48 +200,22 @@
}
const { left, top, width, height } = faceRect.getBoundingRect();
const { actualWidth, actualHeight } = getContainedSize(htmlElement);
const metrics = getDisplayMetrics(htmlElement);
const natural = getNaturalSize(htmlElement);
const offsetArea = {
width: (containerWidth - actualWidth) / 2,
height: (containerHeight - actualHeight) / 2,
const x1Coeff = (left - metrics.offsetX) / metrics.displayWidth;
const y1Coeff = (top - metrics.offsetY) / metrics.displayHeight;
const x2Coeff = (left + width - metrics.offsetX) / metrics.displayWidth;
const y2Coeff = (top + height - metrics.offsetY) / metrics.displayHeight;
return {
imageWidth: natural.width,
imageHeight: natural.height,
x: Math.floor(x1Coeff * natural.width),
y: Math.floor(y1Coeff * natural.height),
width: Math.floor((x2Coeff - x1Coeff) * natural.width),
height: Math.floor((y2Coeff - y1Coeff) * natural.height),
};
const x1Coeff = (left - offsetArea.width) / actualWidth;
const y1Coeff = (top - offsetArea.height) / actualHeight;
const x2Coeff = (left + width - offsetArea.width) / actualWidth;
const y2Coeff = (top + height - offsetArea.height) / actualHeight;
// transpose to the natural image location
if (htmlElement instanceof HTMLImageElement) {
const x1 = x1Coeff * htmlElement.naturalWidth;
const y1 = y1Coeff * htmlElement.naturalHeight;
const x2 = x2Coeff * htmlElement.naturalWidth;
const y2 = y2Coeff * htmlElement.naturalHeight;
return {
imageWidth: htmlElement.naturalWidth,
imageHeight: htmlElement.naturalHeight,
x: Math.floor(x1),
y: Math.floor(y1),
width: Math.floor(x2 - x1),
height: Math.floor(y2 - y1),
};
} else if (htmlElement instanceof HTMLVideoElement) {
const x1 = x1Coeff * htmlElement.videoWidth;
const y1 = y1Coeff * htmlElement.videoHeight;
const x2 = x2Coeff * htmlElement.videoWidth;
const y2 = y2Coeff * htmlElement.videoHeight;
return {
imageWidth: htmlElement.videoWidth,
imageHeight: htmlElement.videoHeight,
x: Math.floor(x1),
y: Math.floor(y1),
width: Math.floor(x2 - x1),
height: Math.floor(y2 - y1),
};
}
};
const tagFace = async (person: PersonResponseDto) => {

View File

@@ -15,6 +15,7 @@
import { SlideshowLook, SlideshowState, slideshowLookCssMapping, slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetUrl, targetImageSize as getTargetImageSize, handlePromiseError } from '$lib/utils';
import { canCopyImageToClipboard, copyImageToClipboard } from '$lib/utils/asset-utils';
import { type DisplayMetrics, getDisplayMetrics } from '$lib/utils/container-utils';
import { handleError } from '$lib/utils/handle-error';
import { getOcrBoundingBoxes } from '$lib/utils/ocr-utils';
import { getBoundingBox } from '$lib/utils/people-utils';
@@ -52,6 +53,7 @@
let imageLoaded: boolean = $state(false);
let originalImageLoaded: boolean = $state(false);
let imageError: boolean = $state(false);
let visibleImageReady: boolean = $state(false);
let loader = $state<HTMLImageElement>();
@@ -67,11 +69,23 @@
$boundingBoxesArray = [];
});
let ocrBoxes = $derived(
ocrManager.showOverlay && assetViewerManager.imgRef
? getOcrBoundingBoxes(ocrManager.data, assetViewerManager.zoomState, assetViewerManager.imgRef)
: [],
);
const overlayMetrics = $derived.by((): DisplayMetrics => {
if (!assetViewerManager.imgRef || !visibleImageReady) {
return { displayWidth: 0, displayHeight: 0, offsetX: 0, offsetY: 0 };
}
const baseMetrics = getDisplayMetrics(assetViewerManager.imgRef);
const zoom = assetViewerManager.zoomState;
return {
displayWidth: baseMetrics.displayWidth * zoom.currentZoom,
displayHeight: baseMetrics.displayHeight * zoom.currentZoom,
offsetX: baseMetrics.offsetX * zoom.currentZoom + zoom.currentPositionX,
offsetY: baseMetrics.offsetY * zoom.currentZoom + zoom.currentPositionY,
};
});
let ocrBoxes = $derived(ocrManager.showOverlay ? getOcrBoundingBoxes(ocrManager.data, overlayMetrics) : []);
let isOcrActive = $derived(ocrManager.showOverlay);
@@ -176,6 +190,7 @@
imageLoaded = false;
originalImageLoaded = false;
imageError = false;
visibleImageReady = false;
});
}
lastUrl = imageLoaderUrl;
@@ -226,6 +241,7 @@
<img
bind:this={assetViewerManager.imgRef}
src={imageLoaderUrl}
onload={() => (visibleImageReady = true)}
alt={$getAltText(toTimelineAsset(asset))}
class="h-full w-full {$slideshowState === SlideshowState.None
? 'object-contain'
@@ -233,7 +249,7 @@
draggable="false"
/>
<!-- eslint-disable-next-line svelte/require-each-key -->
{#each getBoundingBox($boundingBoxesArray, assetViewerManager.zoomState, assetViewerManager.imgRef) as boundingbox}
{#each getBoundingBox($boundingBoxesArray, overlayMetrics) as boundingbox}
<div
class="absolute border-solid border-white border-3 rounded-lg"
style="top: {boundingbox.top}px; left: {boundingbox.left}px; height: {boundingbox.height}px; width: {boundingbox.width}px;"

View File

@@ -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}

View File

@@ -0,0 +1,47 @@
export interface DisplayMetrics {
displayWidth: number;
displayHeight: number;
offsetX: number;
offsetY: number;
}
export const getContainedSize = (element: HTMLImageElement | HTMLVideoElement): { width: number; height: number } => {
if (element instanceof HTMLVideoElement) {
const ratio = element.videoWidth / element.videoHeight;
let width = element.clientHeight * ratio;
let height = element.clientHeight;
if (width > element.clientWidth) {
width = element.clientWidth;
height = element.clientWidth / ratio;
}
return { width, height };
}
const ratio = element.naturalWidth / element.naturalHeight;
let width = element.height * ratio;
let height = element.height;
if (width > element.width) {
width = element.width;
height = element.width / ratio;
}
return { width, height };
};
export const getDisplayMetrics = (element: HTMLImageElement | HTMLVideoElement): DisplayMetrics => {
const { width: displayWidth, height: displayHeight } = getContainedSize(element);
const clientWidth = element instanceof HTMLVideoElement ? element.clientWidth : element.width;
const clientHeight = element instanceof HTMLVideoElement ? element.clientHeight : element.height;
return {
displayWidth,
displayHeight,
offsetX: (clientWidth - displayWidth) / 2,
offsetY: (clientHeight - displayHeight) / 2,
};
};
export const getNaturalSize = (element: HTMLImageElement | HTMLVideoElement): { width: number; height: number } => {
if (element instanceof HTMLVideoElement) {
return { width: element.videoWidth, height: element.videoHeight };
}
return { width: element.naturalWidth, height: element.naturalHeight };
};

View File

@@ -15,6 +15,7 @@ import {
getBaseUrl,
type AssetMediaResponseDto,
} from '@immich/sdk';
import { toastManager } from '@immich/ui';
import { tick } from 'svelte';
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
@@ -112,6 +113,10 @@ export const fileUploadHandler = async ({
promises.push(
uploadExecutionQueue.addTask(() => fileUploader({ assetFile: file, deviceAssetId, albumId, isLockedAssets })),
);
} else {
toastManager.warning(get(t)('unsupported_file_type', { values: { file: file.name, type: file.type } }), {
timeout: 10_000,
});
}
}

View File

@@ -1,16 +1,5 @@
import type { OcrBoundingBox } from '$lib/stores/ocr.svelte';
import type { ZoomImageWheelState } from '@zoom-image/core';
const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => {
const ratio = img.naturalWidth / img.naturalHeight;
let width = img.height * ratio;
let height = img.height;
if (width > img.width) {
width = img.width;
height = img.width / ratio;
}
return { width, height };
};
import type { DisplayMetrics } from '$lib/utils/container-utils';
export type Point = {
x: number;
@@ -66,53 +55,17 @@ export const calculateBoundingBoxMatrix = (points: Point[]): { matrix: number[];
return { matrix, width, height };
};
/**
* Convert normalized OCR coordinates to screen coordinates
* OCR coordinates are normalized (0-1) and represent the 4 corners of a rotated rectangle
*/
export const getOcrBoundingBoxes = (
ocrData: OcrBoundingBox[],
zoom: ZoomImageWheelState,
photoViewer: HTMLImageElement | null,
): OcrBox[] => {
if (photoViewer === null || !photoViewer.naturalWidth || !photoViewer.naturalHeight) {
return [];
}
const clientHeight = photoViewer.clientHeight;
const clientWidth = photoViewer.clientWidth;
const { width, height } = getContainedSize(photoViewer);
const offset = {
x: ((clientWidth - width) / 2) * zoom.currentZoom + zoom.currentPositionX,
y: ((clientHeight - height) / 2) * zoom.currentZoom + zoom.currentPositionY,
};
return getOcrBoundingBoxesAtSize(
ocrData,
{ width: width * zoom.currentZoom, height: height * zoom.currentZoom },
offset,
);
};
export const getOcrBoundingBoxesAtSize = (
ocrData: OcrBoundingBox[],
targetSize: { width: number; height: number },
offset?: Point,
) => {
export const getOcrBoundingBoxes = (ocrData: OcrBoundingBox[], metrics: DisplayMetrics): OcrBox[] => {
const boxes: OcrBox[] = [];
for (const ocr of ocrData) {
// Convert normalized coordinates (0-1) to actual pixel positions
// OCR provides 4 corners of a potentially rotated rectangle
const points = [
{ x: ocr.x1, y: ocr.y1 },
{ x: ocr.x2, y: ocr.y2 },
{ x: ocr.x3, y: ocr.y3 },
{ x: ocr.x4, y: ocr.y4 },
].map((point) => ({
x: targetSize.width * point.x + (offset?.x ?? 0),
y: targetSize.height * point.y + (offset?.y ?? 0),
x: point.x * metrics.displayWidth + metrics.offsetX,
y: point.y * metrics.displayHeight + metrics.offsetY,
}));
boxes.push({

View File

@@ -1,65 +1,27 @@
import type { Faces } from '$lib/stores/people.store';
import { getAssetMediaUrl } from '$lib/utils';
import type { DisplayMetrics } from '$lib/utils/container-utils';
import { AssetTypeEnum, type AssetFaceResponseDto } from '@immich/sdk';
import type { ZoomImageWheelState } from '@zoom-image/core';
const getContainedSize = (img: HTMLImageElement): { width: number; height: number } => {
const ratio = img.naturalWidth / img.naturalHeight;
let width = img.height * ratio;
let height = img.height;
if (width > img.width) {
width = img.width;
height = img.width / ratio;
}
return { width, height };
};
export interface boundingBox {
export interface BoundingBox {
top: number;
left: number;
width: number;
height: number;
}
export const getBoundingBox = (
faces: Faces[],
zoom: ZoomImageWheelState,
photoViewer: HTMLImageElement | undefined,
): boundingBox[] => {
const boxes: boundingBox[] = [];
if (!photoViewer) {
return boxes;
}
const clientHeight = photoViewer.clientHeight;
const clientWidth = photoViewer.clientWidth;
const { width, height } = getContainedSize(photoViewer);
export const getBoundingBox = (faces: Faces[], metrics: DisplayMetrics): BoundingBox[] => {
const boxes: BoundingBox[] = [];
for (const face of faces) {
/*
*
* Create the coordinates of the box based on the displayed image.
* The coordinates must take into account margins due to the 'object-fit: contain;' css property of the photo-viewer.
*
*/
const scaleX = metrics.displayWidth / face.imageWidth;
const scaleY = metrics.displayHeight / face.imageHeight;
const coordinates = {
x1:
(width / face.imageWidth) * zoom.currentZoom * face.boundingBoxX1 +
((clientWidth - width) / 2) * zoom.currentZoom +
zoom.currentPositionX,
x2:
(width / face.imageWidth) * zoom.currentZoom * face.boundingBoxX2 +
((clientWidth - width) / 2) * zoom.currentZoom +
zoom.currentPositionX,
y1:
(height / face.imageHeight) * zoom.currentZoom * face.boundingBoxY1 +
((clientHeight - height) / 2) * zoom.currentZoom +
zoom.currentPositionY,
y2:
(height / face.imageHeight) * zoom.currentZoom * face.boundingBoxY2 +
((clientHeight - height) / 2) * zoom.currentZoom +
zoom.currentPositionY,
x1: scaleX * face.boundingBoxX1 + metrics.offsetX,
x2: scaleX * face.boundingBoxX2 + metrics.offsetX,
y1: scaleY * face.boundingBoxY1 + metrics.offsetY,
y2: scaleY * face.boundingBoxY2 + metrics.offsetY,
};
boxes.push({
@@ -69,6 +31,7 @@ export const getBoundingBox = (
height: Math.round(coordinates.y2 - coordinates.y1),
});
}
return boxes;
};