Merge remote-tracking branch 'origin/main' into feat/integrity-checks-izzy

This commit is contained in:
izzy
2026-02-11 16:00:32 +00:00
245 changed files with 5236 additions and 3790 deletions
@@ -0,0 +1,54 @@
import { Selectable } from 'kysely';
import { AlbumUserRole } from 'src/enum';
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
import { AlbumFactory } from 'test/factories/album.factory';
import { build } from 'test/factories/builder.factory';
import { AlbumUserLike, FactoryBuilder, UserLike } from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class AlbumUserFactory {
#user!: UserFactory;
private constructor(private readonly value: Selectable<AlbumUserTable>) {
value.userId ??= newUuid();
this.#user = UserFactory.from({ id: value.userId });
}
static create(dto: AlbumUserLike = {}) {
return AlbumUserFactory.from(dto).build();
}
static from(dto: AlbumUserLike = {}) {
return new AlbumUserFactory({
albumId: newUuid(),
userId: newUuid(),
role: AlbumUserRole.Editor,
createId: newUuidV7(),
createdAt: newDate(),
updateId: newUuidV7(),
updatedAt: newDate(),
...dto,
});
}
album(dto: AlbumUserLike = {}, builder?: FactoryBuilder<AlbumFactory>) {
const album = build(AlbumFactory.from(dto), builder);
this.value.albumId = album.build().id;
return this;
}
user(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
const user = build(UserFactory.from(dto), builder);
this.value.userId = user.build().id;
this.#user = user;
return this;
}
build() {
return {
...this.value,
user: this.#user.build(),
};
}
}
+87
View File
@@ -0,0 +1,87 @@
import { Selectable } from 'kysely';
import { AssetOrder } from 'src/enum';
import { AlbumTable } from 'src/schema/tables/album.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { AlbumUserFactory } from 'test/factories/album-user.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { build } from 'test/factories/builder.factory';
import { AlbumLike, AlbumUserLike, AssetLike, FactoryBuilder, UserLike } from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class AlbumFactory {
#owner: UserFactory;
#sharedLinks: Selectable<SharedLinkTable>[] = [];
#albumUsers: AlbumUserFactory[] = [];
#assets: AssetFactory[] = [];
private constructor(private readonly value: Selectable<AlbumTable>) {
value.ownerId ??= newUuid();
this.#owner = UserFactory.from({ id: value.ownerId });
}
static create(dto: AlbumLike = {}) {
return AlbumFactory.from(dto).build();
}
static from(dto: AlbumLike = {}) {
return new AlbumFactory({
id: newUuid(),
ownerId: newUuid(),
albumName: 'My Album',
albumThumbnailAssetId: null,
createdAt: newDate(),
deletedAt: null,
description: 'Album description',
isActivityEnabled: false,
order: AssetOrder.Desc,
updatedAt: newDate(),
updateId: newUuidV7(),
...dto,
}).owner();
}
owner(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
this.#owner = build(UserFactory.from(dto), builder);
this.value.ownerId = this.#owner.build().id;
return this;
}
sharedLinks() {
this.#sharedLinks = [];
return this;
}
albumUser(dto: AlbumUserLike = {}, builder?: FactoryBuilder<AlbumUserFactory>) {
const albumUser = build(AlbumUserFactory.from(dto).album(this.value), builder);
this.#albumUsers.push(albumUser);
return this;
}
asset(dto: AssetLike = {}, builder?: FactoryBuilder<AssetFactory>) {
const asset = build(AssetFactory.from(dto), builder);
// use album owner by default
if (!dto.ownerId) {
asset.owner(this.#owner.build());
}
if (!this.#assets) {
this.#assets = [];
}
this.#assets.push(asset);
return this;
}
build() {
return {
...this.value,
owner: this.#owner.build(),
assets: this.#assets.map((asset) => asset.build()),
albumUsers: this.#albumUsers.map((albumUser) => albumUser.build()),
sharedLinks: this.#sharedLinks,
};
}
}
@@ -0,0 +1,38 @@
import { Selectable } from 'kysely';
import { AssetEditAction } from 'src/dtos/editing.dto';
import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
import { AssetFactory } from 'test/factories/asset.factory';
import { build } from 'test/factories/builder.factory';
import { AssetEditLike, AssetLike, FactoryBuilder } from 'test/factories/types';
import { newUuid } from 'test/small.factory';
export class AssetEditFactory {
private constructor(private readonly value: Selectable<AssetEditTable>) {}
static create(dto: AssetEditLike = {}) {
return AssetEditFactory.from(dto).build();
}
static from(dto: AssetEditLike = {}) {
const id = dto.id ?? newUuid();
return new AssetEditFactory({
id,
assetId: newUuid(),
action: AssetEditAction.Crop,
parameters: { x: 5, y: 6, width: 200, height: 100 },
sequence: 1,
...dto,
});
}
asset(dto: AssetLike = {}, builder?: FactoryBuilder<AssetFactory>) {
const asset = build(AssetFactory.from(dto), builder);
this.value.assetId = asset.build().id;
return this;
}
build() {
return { ...this.value } as Selectable<AssetEditTable<AssetEditAction.Crop>>;
}
}
@@ -0,0 +1,55 @@
import { Selectable } from 'kysely';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetExifLike } from 'test/factories/types';
import { factory } from 'test/small.factory';
export class AssetExifFactory {
private constructor(private readonly value: Selectable<AssetExifTable>) {}
static create(dto: AssetExifLike = {}) {
return AssetExifFactory.from(dto).build();
}
static from(dto: AssetExifLike = {}) {
return new AssetExifFactory({
updatedAt: factory.date(),
updateId: factory.uuid(),
assetId: factory.uuid(),
autoStackId: null,
bitsPerSample: null,
city: 'Austin',
colorspace: null,
country: 'United States of America',
dateTimeOriginal: factory.date(),
description: '',
exifImageHeight: 420,
exifImageWidth: 42,
exposureTime: null,
fileSizeInByte: 69,
fNumber: 1.7,
focalLength: 4.38,
fps: null,
iso: 947,
latitude: 30.267_334_570_570_195,
longitude: -97.789_833_534_282_07,
lensModel: null,
livePhotoCID: null,
make: 'Google',
model: 'Pixel 7',
modifyDate: factory.date(),
orientation: '1',
profileDescription: null,
projectionType: null,
rating: 4,
lockedProperties: [],
state: 'Texas',
tags: ['parent/child'],
timeZone: 'UTC-6',
...dto,
});
}
build() {
return { ...this.value };
}
}
@@ -0,0 +1,43 @@
import { Selectable } from 'kysely';
import { AssetFileType } from 'src/enum';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetFactory } from 'test/factories/asset.factory';
import { build } from 'test/factories/builder.factory';
import { AssetFileLike, AssetLike, FactoryBuilder } from 'test/factories/types';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class AssetFileFactory {
private constructor(private readonly value: Selectable<AssetFileTable>) {}
static create(dto: AssetFileLike = {}) {
return AssetFileFactory.from(dto).build();
}
static from(dto: AssetFileLike = {}) {
const id = dto.id ?? newUuid();
const isEdited = dto.isEdited ?? false;
return new AssetFileFactory({
id,
assetId: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
type: AssetFileType.Thumbnail,
path: `/data/12/34/thumbs/${id.slice(0, 2)}/${id.slice(2, 4)}/${id}${isEdited ? '_edited' : ''}.jpg`,
updateId: newUuidV7(),
isProgressive: false,
isEdited,
...dto,
});
}
asset(dto: AssetLike = {}, builder?: FactoryBuilder<AssetFactory>) {
const asset = build(AssetFactory.from(dto), builder);
this.value.assetId = asset.build().id;
return this;
}
build() {
return { ...this.value };
}
}
+126
View File
@@ -0,0 +1,126 @@
import { Selectable } from 'kysely';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility } from 'src/enum';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { AssetEditFactory } from 'test/factories/asset-edit.factory';
import { AssetExifFactory } from 'test/factories/asset-exif.factory';
import { AssetFileFactory } from 'test/factories/asset-file.factory';
import { build } from 'test/factories/builder.factory';
import { AssetEditLike, AssetExifLike, AssetFileLike, AssetLike, FactoryBuilder, UserLike } from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { newDate, newSha1, newUuid, newUuidV7 } from 'test/small.factory';
export class AssetFactory {
#owner!: UserFactory;
#assetExif?: AssetExifFactory;
#files: AssetFileFactory[] = [];
#edits: AssetEditFactory[] = [];
private constructor(private readonly value: Selectable<AssetTable>) {
value.ownerId ??= newUuid();
this.#owner = UserFactory.from({ id: value.ownerId });
}
static create(dto: AssetLike = {}) {
return AssetFactory.from(dto).build();
}
static from(dto: AssetLike = {}) {
const id = dto.id ?? newUuid();
const originalFileName = dto.originalFileName ?? `IMG_${id}.jpg`;
return new AssetFactory({
id,
createdAt: newDate(),
updatedAt: newDate(),
deletedAt: null,
updateId: newUuidV7(),
status: AssetStatus.Active,
checksum: newSha1(),
deviceAssetId: '',
deviceId: '',
duplicateId: null,
duration: null,
encodedVideoPath: null,
fileCreatedAt: newDate(),
fileModifiedAt: newDate(),
isExternal: false,
isFavorite: false,
isOffline: false,
libraryId: null,
livePhotoVideoId: null,
localDateTime: newDate(),
originalFileName,
originalPath: `/data/library/${originalFileName}`,
ownerId: newUuid(),
stackId: null,
thumbhash: null,
type: AssetType.Image,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
isEdited: false,
...dto,
});
}
owner(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>) {
this.#owner = build(UserFactory.from(dto), builder);
this.value.ownerId = this.#owner.build().id;
return this;
}
exif(dto: AssetExifLike = {}, builder?: FactoryBuilder<AssetExifFactory>) {
this.#assetExif = build(AssetExifFactory.from(dto), builder);
return this;
}
edit(dto: AssetEditLike = {}, builder?: FactoryBuilder<AssetEditFactory>) {
this.#edits.push(build(AssetEditFactory.from(dto).asset(this.value), builder));
this.value.isEdited = true;
return this;
}
file(dto: AssetFileLike = {}, builder?: FactoryBuilder<AssetFileFactory>) {
this.#files.push(build(AssetFileFactory.from(dto).asset(this.value), builder));
return this;
}
files(dto?: 'edits'): AssetFactory;
files(items: AssetFileLike[], builder?: FactoryBuilder<AssetFileFactory>): AssetFactory;
files(items: AssetFileType[], builder?: FactoryBuilder<AssetFileFactory>): AssetFactory;
files(dto?: 'edits' | AssetFileLike[] | AssetFileType[], builder?: FactoryBuilder<AssetFileFactory>): AssetFactory {
const items: AssetFileLike[] = [];
if (dto === undefined || dto === 'edits') {
items.push(...Object.values(AssetFileType).map((type) => ({ type })));
if (dto === 'edits') {
items.push(...Object.values(AssetFileType).map((type) => ({ type, isEdited: true })));
}
} else {
for (const item of dto) {
items.push(typeof item === 'string' ? { type: item as AssetFileType } : item);
}
}
for (const item of items) {
this.file(item, builder);
}
return this;
}
build() {
const exif = this.#assetExif?.build();
return {
...this.value,
owner: this.#owner.build(),
exifInfo: exif as NonNullable<typeof exif>,
files: this.#files.map((file) => file.build()),
edits: this.#edits.map((edit) => edit.build()),
faces: [] as Selectable<AssetFaceTable>[],
};
}
}
+48
View File
@@ -0,0 +1,48 @@
import { AuthDto } from 'src/dtos/auth.dto';
import { build } from 'test/factories/builder.factory';
import { SharedLinkFactory } from 'test/factories/shared-link.factory';
import { FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
export class AuthFactory {
#user: UserFactory;
#sharedLink?: SharedLinkFactory;
private constructor(user: UserFactory) {
this.#user = user;
}
static create(dto: UserLike = {}) {
return AuthFactory.from(dto).build();
}
static from(dto: UserLike = {}) {
return new AuthFactory(UserFactory.from(dto));
}
apiKey() {
// TODO
return this;
}
sharedLink(dto: SharedLinkLike = {}, builder?: FactoryBuilder<SharedLinkFactory>) {
this.#sharedLink = build(SharedLinkFactory.from(dto), builder);
return this;
}
build(): AuthDto {
const { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes } = this.#user.build();
return {
user: {
id,
isAdmin,
name,
email,
quotaUsageInBytes,
quotaSizeInBytes,
},
sharedLink: this.#sharedLink?.build(),
};
}
}
+5
View File
@@ -0,0 +1,5 @@
import { FactoryBuilder } from 'test/factories/types';
export const build = <T>(factory: T, builder?: FactoryBuilder<T>) => {
return builder ? builder(factory) : factory;
};
@@ -0,0 +1,63 @@
import { Selectable } from 'kysely';
import { SharedLinkType } from 'src/enum';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { AlbumFactory } from 'test/factories/album.factory';
import { build } from 'test/factories/builder.factory';
import { AlbumLike, FactoryBuilder, SharedLinkLike, UserLike } from 'test/factories/types';
import { UserFactory } from 'test/factories/user.factory';
import { factory, newDate, newUuid } from 'test/small.factory';
export class SharedLinkFactory {
#owner: UserFactory;
#album?: AlbumFactory;
private constructor(private readonly value: Selectable<SharedLinkTable>) {
value.userId ??= newUuid();
this.#owner = UserFactory.from({ id: value.userId });
}
static create(dto: SharedLinkLike = {}) {
return SharedLinkFactory.from(dto).build();
}
static from(dto: SharedLinkLike = {}) {
const type = dto.type ?? SharedLinkType.Individual;
const albumId = (dto.albumId ?? type === SharedLinkType.Album) ? newUuid() : null;
return new SharedLinkFactory({
id: factory.uuid(),
description: 'Shared link description',
userId: newUuid(),
key: factory.buffer(),
type,
albumId,
createdAt: newDate(),
expiresAt: null,
allowUpload: true,
allowDownload: true,
showExif: true,
password: null,
slug: null,
...dto,
});
}
owner(dto: UserLike = {}, builder?: FactoryBuilder<UserFactory>): SharedLinkFactory {
this.#owner = build(UserFactory.from(dto), builder);
return this;
}
album(dto: AlbumLike = {}, builder?: FactoryBuilder<AlbumFactory>) {
this.#album = build(AlbumFactory.from(dto), builder);
return this;
}
build() {
return {
...this.value,
owner: this.#owner.build(),
album: this.#album?.build(),
assets: [],
};
}
}
+20
View File
@@ -0,0 +1,20 @@
import { Selectable } from 'kysely';
import { AlbumUserTable } from 'src/schema/tables/album-user.table';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetEditTable } from 'src/schema/tables/asset-edit.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { UserTable } from 'src/schema/tables/user.table';
export type FactoryBuilder<T, R extends T = T> = (builder: T) => R;
export type AssetLike = Partial<Selectable<AssetTable>>;
export type AssetExifLike = Partial<Selectable<AssetExifTable>>;
export type AssetEditLike = Partial<Selectable<AssetEditTable>>;
export type AssetFileLike = Partial<Selectable<AssetFileTable>>;
export type AlbumLike = Partial<Selectable<AlbumTable>>;
export type AlbumUserLike = Partial<Selectable<AlbumUserTable>>;
export type SharedLinkLike = Partial<Selectable<SharedLinkTable>>;
export type UserLike = Partial<Selectable<UserTable>>;
+59
View File
@@ -0,0 +1,59 @@
import { Selectable } from 'kysely';
import { UserStatus } from 'src/enum';
import { UserMetadataTable } from 'src/schema/tables/user-metadata.table';
import { UserTable } from 'src/schema/tables/user.table';
import { UserLike } from 'test/factories/types';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class UserFactory {
#metadata: Selectable<UserMetadataTable>[] = [];
private constructor(private value: Selectable<UserTable>) {}
static create(dto: UserLike = {}) {
return UserFactory.from(dto).build();
}
static from(dto: UserLike = {}) {
return new UserFactory({
id: newUuid(),
email: 'test@immich.cloud',
password: '',
pinCode: null,
createdAt: newDate(),
profileImagePath: '',
isAdmin: false,
shouldChangePassword: false,
avatarColor: null,
deletedAt: null,
oauthId: '',
updatedAt: newDate(),
storageLabel: null,
name: 'Test User',
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
status: UserStatus.Active,
profileChangedAt: newDate(),
updateId: newUuidV7(),
...dto,
});
}
metadata(dto: Partial<Selectable<UserMetadataTable>> & Pick<Selectable<UserMetadataTable>, 'key' | 'value'>) {
this.#metadata.push({
updatedAt: newDate(),
updateId: newUuid(),
userId: newUuid(),
...dto,
});
return this;
}
build() {
return {
...this.value,
metadata: this.#metadata,
};
}
}
-152
View File
@@ -1,152 +0,0 @@
import { AlbumUserRole, AssetOrder } from 'src/enum';
import { assetStub } from 'test/fixtures/asset.stub';
import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub';
export const albumStub = {
empty: Object.freeze({
id: 'album-1',
albumName: 'Empty album',
description: '',
ownerId: authStub.admin.user.id,
owner: userStub.admin,
assets: [],
albumThumbnailAsset: null,
albumThumbnailAssetId: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
albumUsers: [],
isActivityEnabled: true,
order: AssetOrder.Desc,
updateId: '42',
}),
sharedWithUser: Object.freeze({
id: 'album-2',
albumName: 'Empty album shared with user',
description: '',
ownerId: authStub.admin.user.id,
owner: userStub.admin,
assets: [],
albumThumbnailAsset: null,
albumThumbnailAssetId: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
albumUsers: [
{
user: userStub.user1,
role: AlbumUserRole.Editor,
},
],
isActivityEnabled: true,
order: AssetOrder.Desc,
updateId: '42',
}),
sharedWithMultiple: Object.freeze({
id: 'album-3',
albumName: 'Empty album shared with users',
description: '',
ownerId: authStub.admin.user.id,
owner: userStub.admin,
assets: [],
albumThumbnailAsset: null,
albumThumbnailAssetId: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
albumUsers: [
{
user: userStub.user1,
role: AlbumUserRole.Editor,
},
{
user: userStub.user2,
role: AlbumUserRole.Editor,
},
],
isActivityEnabled: true,
order: AssetOrder.Desc,
updateId: '42',
}),
sharedWithAdmin: Object.freeze({
id: 'album-3',
albumName: 'Empty album shared with admin',
description: '',
ownerId: authStub.user1.user.id,
owner: userStub.user1,
assets: [],
albumThumbnailAsset: null,
albumThumbnailAssetId: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
albumUsers: [
{
user: userStub.admin,
role: AlbumUserRole.Editor,
},
],
isActivityEnabled: true,
order: AssetOrder.Desc,
updateId: '42',
}),
oneAsset: Object.freeze({
id: 'album-4',
albumName: 'Album with one asset',
description: '',
ownerId: authStub.admin.user.id,
owner: userStub.admin,
assets: [assetStub.image],
albumThumbnailAsset: null,
albumThumbnailAssetId: null,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
albumUsers: [],
isActivityEnabled: true,
order: AssetOrder.Desc,
updateId: '42',
}),
twoAssets: Object.freeze({
id: 'album-4a',
albumName: 'Album with two assets',
description: '',
ownerId: authStub.admin.user.id,
owner: userStub.admin,
assets: [assetStub.image, assetStub.withLocation],
albumThumbnailAsset: assetStub.image,
albumThumbnailAssetId: assetStub.image.id,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
albumUsers: [],
isActivityEnabled: true,
order: AssetOrder.Desc,
updateId: '42',
}),
emptyWithValidThumbnail: Object.freeze({
id: 'album-5',
albumName: 'Empty album with valid thumbnail',
description: '',
ownerId: authStub.admin.user.id,
owner: userStub.admin,
assets: [],
albumThumbnailAsset: assetStub.image,
albumThumbnailAssetId: assetStub.image.id,
createdAt: new Date(),
updatedAt: new Date(),
deletedAt: null,
sharedLinks: [],
albumUsers: [],
isActivityEnabled: true,
order: AssetOrder.Desc,
updateId: '42',
}),
};
-410
View File
@@ -20,45 +20,8 @@ const fullsizeFile = factory.assetFile({
path: '/uploads/user-id/fullsize/path.webp',
});
const sidecarFileWithExt = factory.assetFile({
type: AssetFileType.Sidecar,
path: '/original/path.ext.xmp',
});
const sidecarFileWithoutExt = factory.assetFile({
type: AssetFileType.Sidecar,
path: '/original/path.xmp',
});
const editedPreviewFile = factory.assetFile({
type: AssetFileType.Preview,
path: '/uploads/user-id/preview/path_edited.jpg',
isEdited: true,
});
const editedThumbnailFile = factory.assetFile({
type: AssetFileType.Thumbnail,
path: '/uploads/user-id/thumbnail/path_edited.jpg',
isEdited: true,
});
const editedFullsizeFile = factory.assetFile({
type: AssetFileType.FullSize,
path: '/uploads/user-id/fullsize/path_edited.jpg',
isEdited: true,
});
const files = [fullsizeFile, previewFile, thumbnailFile];
const editedFiles = [
fullsizeFile,
previewFile,
thumbnailFile,
editedFullsizeFile,
editedPreviewFile,
editedThumbnailFile,
];
export const stackStub = (stackId: string, assets: (MapAsset & { exifInfo: Exif })[]) => {
return {
id: stackId,
@@ -132,87 +95,6 @@ export const assetStub = {
isEdited: false,
}),
noWebpPath: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/data/library/IMG_456.jpg',
files: [previewFile],
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
duration: null,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
originalFileName: 'IMG_456.jpg',
faces: [],
isExternal: false,
exifInfo: {
fileSizeInByte: 123_000,
} as Exif,
deletedAt: null,
duplicateId: null,
isOffline: false,
libraryId: null,
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
noThumbhash: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
files,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
thumbhash: null,
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
duration: null,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
deletedAt: null,
duplicateId: null,
isOffline: false,
libraryId: null,
stackId: null,
updateId: '42',
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
primaryImage: Object.freeze({
id: 'primary-asset-id',
status: AssetStatus.Active,
@@ -526,48 +408,6 @@ export const assetStub = {
isEdited: false,
}),
imageFrom2015: Object.freeze({
id: 'asset-id-2015',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2015-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2015-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
files,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2015-02-23T05:06:29.716Z'),
updatedAt: new Date('2015-02-23T05:06:29.716Z'),
localDateTime: new Date('2015-02-23T05:06:29.716Z'),
isFavorite: true,
isExternal: false,
duration: null,
livePhotoVideo: null,
livePhotoVideoId: null,
updateId: 'foo',
libraryId: null,
stackId: null,
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
exifInfo: {
fileSizeInByte: 5000,
} as Exif,
deletedAt: null,
duplicateId: null,
isOffline: false,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
video: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
@@ -736,81 +576,6 @@ export const assetStub = {
isEdited: false,
}),
sidecar: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
thumbhash: null,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
files: [previewFile, sidecarFileWithExt],
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isExternal: false,
duration: null,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
deletedAt: null,
duplicateId: null,
isOffline: false,
updateId: 'foo',
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
sidecarWithoutExt: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
thumbhash: null,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
files: [previewFile, sidecarFileWithoutExt],
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isExternal: false,
duration: null,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
deletedAt: null,
duplicateId: null,
isOffline: false,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
hasEncodedVideo: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
@@ -854,46 +619,6 @@ export const assetStub = {
isEdited: false,
}),
hasFileExtension: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/data/user1/photo.jpg',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
files,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
isExternal: true,
duration: null,
livePhotoVideo: null,
livePhotoVideoId: null,
libraryId: 'library-id',
sharedLinks: [],
originalFileName: 'photo.jpg',
faces: [],
deletedAt: null,
exifInfo: {
fileSizeInByte: 5000,
} as Exif,
duplicateId: null,
isOffline: false,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
imageDng: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
@@ -938,93 +663,6 @@ export const assetStub = {
isEdited: false,
}),
imageHif: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.hif',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
files,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
duration: null,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
originalFileName: 'asset-id.hif',
faces: [],
deletedAt: null,
exifInfo: {
fileSizeInByte: 5000,
profileDescription: 'Adobe RGB',
bitsPerSample: 14,
} as Exif,
duplicateId: null,
isOffline: false,
updateId: '42',
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
isEdited: false,
}),
panoramaTif: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.tif',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
files,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2023-02-23T05:06:29.716Z'),
isFavorite: true,
duration: null,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
sharedLinks: [],
originalFileName: 'asset-id.tif',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
projectionType: 'EQUIRECTANGULAR',
} as Exif,
duplicateId: null,
isOffline: false,
updateId: '42',
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
edits: [],
}),
withCropEdit: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
@@ -1082,52 +720,4 @@ export const assetStub = {
] as AssetEditActionItem[],
isEdited: true,
}),
withoutEdits: Object.freeze({
id: 'asset-id',
status: AssetStatus.Active,
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.jpg',
files: editedFiles,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.Image,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
localDateTime: new Date('2025-01-01T01:02:03.456Z'),
isFavorite: true,
duration: null,
isExternal: false,
livePhotoVideo: null,
livePhotoVideoId: null,
updateId: 'foo',
libraryId: null,
stackId: null,
sharedLinks: [],
originalFileName: 'asset-id.jpg',
faces: [],
deletedAt: null,
sidecarPath: null,
exifInfo: {
fileSizeInByte: 5000,
exifImageHeight: 3840,
exifImageWidth: 2160,
} as Exif,
duplicateId: null,
isOffline: false,
stack: null,
orientation: '',
projectionType: null,
height: 3840,
width: 2160,
visibility: AssetVisibility.Timeline,
edits: [],
isEdited: false,
}),
};
-17
View File
@@ -38,21 +38,4 @@ export const userStub = {
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
},
user2: <UserAdmin>{
...authStub.user2.user,
status: UserStatus.Active,
profileChangedAt: new Date('2021-01-01'),
metadata: [],
name: 'immich_name',
storageLabel: null,
oauthId: '',
shouldChangePassword: false,
avatarColor: null,
profileImagePath: '',
createdAt: new Date('2021-01-01'),
deletedAt: null,
updatedAt: new Date('2021-01-01'),
quotaSizeInBytes: null,
quotaUsageInBytes: 0,
},
};
@@ -0,0 +1,118 @@
import { Kysely } from 'kysely';
import { AssetFileType } from 'src/enum';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { DB } from 'src/schema';
import { BaseService } from 'src/services/base.service';
import { newMediumService } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
const consume = async <T>(generator: AsyncIterableIterator<T>) => {
const values: T[] = [];
for await (const value of generator) {
values.push(value);
}
return values;
};
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
const { ctx } = newMediumService(BaseService, {
database: db || defaultDatabase,
real: [],
mock: [LoggingRepository],
});
return { ctx, sut: ctx.get(AssetJobRepository) };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(AssetJobRepository.name, () => {
describe('streamForThumbnailJob', () => {
it('should work', async () => {
const { sut } = setup();
const stream = sut.streamForThumbnailJob({ force: false, fullsizeEnabled: false });
await expect(stream.next()).resolves.toEqual({ done: true, value: undefined });
});
it('should queue an asset with missing thumbnails', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
await ctx.newJobStatus({ assetId: asset.id, metadataExtractedAt: new Date() });
const stream = sut.streamForThumbnailJob({ force: false, fullsizeEnabled: false });
await expect(consume(stream)).resolves.toEqual([expect.objectContaining({ id: asset.id })]);
});
it('should skip assets without missing thumbnails', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, thumbhash: Buffer.from('fake-thumbhash-buffer') });
await ctx.newJobStatus({ assetId: asset.id, metadataExtractedAt: new Date() });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Thumbnail, path: 'thumbnail.jpg' });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: 'preview.jpg' });
const stream = sut.streamForThumbnailJob({ force: false, fullsizeEnabled: false });
await expect(consume(stream)).resolves.not.toEqual(
expect.arrayContaining([expect.objectContaining({ id: asset.id })]),
);
});
it('should queue assets with a missing full size', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({
ownerId: user.id,
thumbhash: Buffer.from('fake-thumbhash-buffer'),
originalFileName: 'photo.cr2',
});
await ctx.newJobStatus({ assetId: asset.id, metadataExtractedAt: new Date() });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Thumbnail, path: 'thumbnail.jpg' });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: 'preview.jpg' });
const stream = sut.streamForThumbnailJob({ force: false, fullsizeEnabled: true });
await expect(consume(stream)).resolves.toEqual(
expect.arrayContaining([expect.objectContaining({ id: asset.id })]),
);
});
it('should skip assets with after they have full size previews', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id, thumbhash: Buffer.from('fake-thumbhash-buffer') });
await ctx.newJobStatus({ assetId: asset.id, metadataExtractedAt: new Date() });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Thumbnail, path: 'thumbnail.jpg' });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: 'preview.jpg' });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.FullSize, path: 'fullsize.jpg' });
const stream = sut.streamForThumbnailJob({ force: false, fullsizeEnabled: true });
await expect(consume(stream)).resolves.not.toEqual(
expect.arrayContaining([expect.objectContaining({ id: asset.id })]),
);
});
it('should skip assets with web-compatible originals', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({
ownerId: user.id,
thumbhash: Buffer.from('fake-thumbhash-buffer'),
originalFileName: 'photo.jpg',
});
await ctx.newJobStatus({ assetId: asset.id, metadataExtractedAt: new Date() });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Thumbnail, path: 'thumbnail.jpg' });
await ctx.newAssetFile({ assetId: asset.id, type: AssetFileType.Preview, path: 'preview.jpg' });
const stream = sut.streamForThumbnailJob({ force: false, fullsizeEnabled: true });
await expect(consume(stream)).resolves.not.toEqual(
expect.arrayContaining([expect.objectContaining({ id: asset.id })]),
);
});
});
});
@@ -0,0 +1,68 @@
import { Kysely } from 'kysely';
import { AssetFileType } from 'src/enum';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { PersonRepository } from 'src/repositories/person.repository';
import { DB } from 'src/schema';
import { BaseService } from 'src/services/base.service';
import { newMediumService } from 'test/medium.factory';
import { getKyselyDB } from 'test/utils';
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
const { ctx } = newMediumService(BaseService, {
database: db || defaultDatabase,
real: [],
mock: [LoggingRepository],
});
return { ctx, sut: ctx.get(PersonRepository) };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(PersonRepository.name, () => {
describe('getDataForThumbnailGenerationJob', () => {
it('should not return the edited preview path', async () => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ ownerId: user.id });
const { person } = await ctx.newPerson({ ownerId: user.id });
const { assetFace } = await ctx.newAssetFace({
assetId: asset.id,
personId: person.id,
boundingBoxX1: 10,
boundingBoxY1: 10,
boundingBoxX2: 90,
boundingBoxY2: 90,
});
// theres a circular dependency between assetFace and person, so we need to update the person after creating the assetFace
await ctx.database.updateTable('person').set({ faceAssetId: assetFace.id }).where('id', '=', person.id).execute();
await ctx.newAssetFile({
assetId: asset.id,
type: AssetFileType.Preview,
path: 'preview_edited.jpg',
isEdited: true,
});
await ctx.newAssetFile({
assetId: asset.id,
type: AssetFileType.Preview,
path: 'preview_unedited.jpg',
isEdited: false,
});
const result = await sut.getDataForThumbnailGenerationJob(person.id);
expect(result).toEqual(
expect.objectContaining({
previewPath: 'preview_unedited.jpg',
}),
);
});
});
});
@@ -1,24 +1,21 @@
import { Kysely } from 'kysely';
import { Stats } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
import { AssetRepository } from 'src/repositories/asset.repository';
import { ConfigRepository } from 'src/repositories/config.repository';
import { EventRepository } from 'src/repositories/event.repository';
import { LoggingRepository } from 'src/repositories/logging.repository';
import { MetadataRepository } from 'src/repositories/metadata.repository';
import { StorageRepository } from 'src/repositories/storage.repository';
import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository';
import { TagRepository } from 'src/repositories/tag.repository';
import { DB } from 'src/schema';
import { MetadataService } from 'src/services/metadata.service';
import { automock, newRandomImage, newTestService, ServiceMocks } from 'test/utils';
const metadataRepository = new MetadataRepository(
// eslint-disable-next-line no-sparse-arrays
automock(LoggingRepository, { args: [, { getEnv: () => ({}) }], strict: false }),
);
const createTestFile = async (exifData: Record<string, any>) => {
const data = newRandomImage();
const filePath = join(tmpdir(), 'test.png');
await writeFile(filePath, data);
await metadataRepository.writeTags(filePath, exifData);
return { filePath };
};
import { newMediumService } from 'test/medium.factory';
import { getKyselyDB, newRandomImage } from 'test/utils';
type TimeZoneTest = {
description: string;
@@ -31,24 +28,52 @@ type TimeZoneTest = {
};
};
let defaultDatabase: Kysely<DB>;
const setup = (db?: Kysely<DB>) => {
const { sut, ctx } = newMediumService(MetadataService, {
database: db || defaultDatabase,
real: [
AssetRepository,
AssetJobRepository,
ConfigRepository,
MetadataRepository,
SystemMetadataRepository,
TagRepository,
],
mock: [EventRepository, StorageRepository, LoggingRepository],
});
ctx.getMock(StorageRepository).stat.mockResolvedValue({
size: 123_456,
mtime: new Date(654_321),
mtimeMs: 654_321,
birthtimeMs: 654_322,
} as Stats);
return { sut, ctx };
};
const createTestFile = async (exifData: Record<string, any>) => {
const { ctx } = setup();
const data = newRandomImage();
const filePath = join(tmpdir(), 'test.png');
await writeFile(filePath, data);
await ctx.get(MetadataRepository).writeTags(filePath, exifData);
return { filePath };
};
beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
describe(MetadataService.name, () => {
let sut: MetadataService;
let mocks: ServiceMocks;
beforeEach(() => {
({ sut, mocks } = newTestService(MetadataService, { metadata: metadataRepository }));
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: new Date(654_321),
mtimeMs: 654_321,
birthtimeMs: 654_322,
} as Stats);
delete process.env.TZ;
afterEach(() => {
vi.unstubAllEnvs();
});
it('should be defined', () => {
const { sut } = setup();
expect(sut).toBeDefined();
});
@@ -79,30 +104,52 @@ describe(MetadataService.name, () => {
];
it.each(timeZoneTests)('$description', async ({ exifData, serverTimeZone, expected }) => {
process.env.TZ = serverTimeZone ?? undefined;
vi.stubEnv('TZ', serverTimeZone);
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const { filePath } = await createTestFile(exifData);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
id: 'asset-1',
originalPath: filePath,
files: [],
} as any);
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ originalPath: filePath, ownerId: user.id });
await ctx.newExif({ assetId: asset.id, description: '' });
await sut.handleMetadataExtraction({ id: 'asset-1' });
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.asset.upsertExif).toHaveBeenCalledWith(
expect.objectContaining({
dateTimeOriginal: new Date(expected.dateTimeOriginal),
timeZone: expected.timeZone,
}),
{ lockedPropertiesBehavior: 'skip' },
await expect(
ctx.database
.selectFrom('asset_exif')
.select(['dateTimeOriginal', 'timeZone', 'lockedProperties'])
.where('assetId', '=', asset.id)
.executeTakeFirstOrThrow(),
).resolves.toEqual({
dateTimeOriginal: new Date(expected.dateTimeOriginal),
timeZone: expected.timeZone,
lockedProperties: null,
});
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toEqual(
expect.objectContaining({ localDateTime: new Date(expected.localDateTime) }),
);
});
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
localDateTime: new Date(expected.localDateTime),
}),
);
it('should handle dates far in the future', async () => {
const { sut, ctx } = setup();
ctx.getMock(EventRepository).emit.mockResolvedValue();
const { filePath } = await createTestFile({ CreateDate: '42603:05:04 04:12:48' });
const { user } = await ctx.newUser();
const { asset } = await ctx.newAsset({ originalPath: filePath, ownerId: user.id });
await ctx.newExif({ assetId: asset.id, description: '' });
await sut.handleMetadataExtraction({ id: asset.id });
await expect(
ctx.database
.selectFrom('asset_exif')
.where('assetId', '=', asset.id)
.select('dateTimeOriginal')
.executeTakeFirstOrThrow(),
// note that this date is technically wrong. it does not throw though and should get the user's attention either way.
).resolves.toEqual({ dateTimeOriginal: new Date('4260-03-05T04:04:12.000Z') });
});
});
});
@@ -685,5 +685,75 @@ describe(PersonService.name, () => {
]),
);
});
it('should properly handle exif orientation when creating a face on an edited asset', async () => {
const { sut, ctx } = setup();
const { user } = await ctx.newUser();
const { person } = await ctx.newPerson({ ownerId: user.id });
const { asset } = await ctx.newAsset({ id: factory.uuid(), ownerId: user.id, width: 100, height: 100 });
await ctx.newExif({ assetId: asset.id, exifImageHeight: 200, exifImageWidth: 100, orientation: '6' });
await ctx.newEdits(asset.id, {
edits: [
{
action: AssetEditAction.Mirror,
parameters: {
axis: MirrorAxis.Horizontal,
},
},
{
action: AssetEditAction.Mirror,
parameters: {
axis: MirrorAxis.Vertical,
},
},
],
});
const auth = factory.auth({ user });
const dto: AssetFaceCreateDto = {
imageWidth: 100,
imageHeight: 100,
x: 10,
y: 10,
width: 80,
height: 80,
personId: person.id,
assetId: asset.id,
};
await sut.createFace(auth, dto);
const faces = sut.getFacesById(auth, { id: asset.id });
await expect(faces).resolves.toHaveLength(1);
await expect(faces).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: 110,
boundingBoxY1: 10,
boundingBoxX2: 190,
boundingBoxY2: 90,
}),
]),
);
// remove edits and verify the stored coordinates map to the original image
await ctx.newEdits(asset.id, { edits: [] });
const facesAfterRemovingEdits = sut.getFacesById(auth, { id: asset.id });
await expect(facesAfterRemovingEdits).resolves.toEqual(
expect.arrayContaining([
expect.objectContaining({
person: expect.objectContaining({ id: person.id }),
boundingBoxX1: 10,
boundingBoxY1: 10,
boundingBoxX2: 90,
boundingBoxY2: 90,
}),
]),
);
});
});
});
+5 -4
View File
@@ -500,10 +500,12 @@ export const mockSpawn = vitest.fn((exitCode: number, stdout: string, stderr: st
} as unknown as ChildProcessWithoutNullStreams;
});
export const mockDuplex = vitest.fn(
export const mockDuplex =
(chunkCb?: (chunk: Buffer) => void) =>
(command: string, exitCode: number, stdout: string, stderr: string, error?: unknown) => {
const duplex = new Duplex({
write(_chunk, _encoding, callback) {
write(chunk, _encoding, callback) {
chunkCb?.(chunk);
callback();
},
@@ -528,8 +530,7 @@ export const mockDuplex = vitest.fn(
});
return duplex;
},
);
};
export const mockFork = vitest.fn((exitCode: number, stdout: string, stderr: string, error?: unknown) => {
const stdoutStream = new Readable({