chore: upgrade to kysely 0.28.11

This commit is contained in:
Daniel Dietzler 2026-03-06 14:22:23 +01:00
parent c259fee309
commit 592364a217
No known key found for this signature in database
GPG Key ID: A1C0B97CD8E18DFF
49 changed files with 1026 additions and 878 deletions

24
pnpm-lock.yaml generated
View File

@ -488,11 +488,11 @@ importers:
specifier: ^9.0.2
version: 9.0.3
kysely:
specifier: 0.28.2
version: 0.28.2
specifier: 0.28.11
version: 0.28.11
kysely-postgres-js:
specifier: ^3.0.0
version: 3.0.0(kysely@0.28.2)(postgres@3.4.8)
version: 3.0.0(kysely@0.28.11)(postgres@3.4.8)
lodash:
specifier: ^4.17.21
version: 4.17.23
@ -513,7 +513,7 @@ importers:
version: 5.4.3(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)
nestjs-kysely:
specifier: 3.1.2
version: 3.1.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(kysely@0.28.2)(reflect-metadata@0.2.2)
version: 3.1.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(kysely@0.28.11)(reflect-metadata@0.2.2)
nestjs-otel:
specifier: ^7.0.0
version: 7.0.1(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)
@ -8379,10 +8379,6 @@ packages:
resolution: {integrity: sha512-zpGIFg0HuoC893rIjYX1BETkVWdDnzTzF5e0kWXJFg5lE0k1/LfNWBejrcnOFu8Q2Rfq/hTDTU7XLUM8QOrpzg==}
engines: {node: '>=20.0.0'}
kysely@0.28.2:
resolution: {integrity: sha512-4YAVLoF0Sf0UTqlhgQMFU9iQECdah7n+13ANkiuVfRvlK+uI0Etbgd7bVP36dKlG+NXWbhGua8vnGt+sdhvT7A==}
engines: {node: '>=18.0.0'}
langium@3.3.1:
resolution: {integrity: sha512-QJv/h939gDpvT+9SiLVlY7tZC3xB2qK57v0J04Sh9wpMb6MP1q8gB21L3WIo8T5P1MSMg3Ep14L7KkDCFG3y4w==}
engines: {node: '>=16.0.0'}
@ -21040,16 +21036,8 @@ snapshots:
optionalDependencies:
postgres: 3.4.8
kysely-postgres-js@3.0.0(kysely@0.28.2)(postgres@3.4.8):
dependencies:
kysely: 0.28.2
optionalDependencies:
postgres: 3.4.8
kysely@0.28.11: {}
kysely@0.28.2: {}
langium@3.3.1:
dependencies:
chevrotain: 11.0.3
@ -22128,11 +22116,11 @@ snapshots:
reflect-metadata: 0.2.2
rxjs: 7.8.2
nestjs-kysely@3.1.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(kysely@0.28.2)(reflect-metadata@0.2.2):
nestjs-kysely@3.1.2(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.14)(kysely@0.28.11)(reflect-metadata@0.2.2):
dependencies:
'@nestjs/common': 11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.14(@nestjs/common@11.1.14(class-transformer@0.5.1)(class-validator@0.14.4)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.14)(@nestjs/websockets@11.1.14)(reflect-metadata@0.2.2)(rxjs@7.8.2)
kysely: 0.28.2
kysely: 0.28.11
reflect-metadata: 0.2.2
tslib: 2.8.1

View File

@ -82,7 +82,7 @@
"jose": "^5.10.0",
"js-yaml": "^4.1.0",
"jsonwebtoken": "^9.0.2",
"kysely": "0.28.2",
"kysely": "0.28.11",
"kysely-postgres-js": "^3.0.0",
"lodash": "^4.17.21",
"luxon": "^3.4.2",

View File

@ -1,4 +1,4 @@
import { Selectable } from 'kysely';
import { Selectable, ShallowDehydrateObject } from 'kysely';
import { MapAsset } from 'src/dtos/asset-response.dto';
import {
AlbumUserRole,
@ -16,6 +16,7 @@ import {
} from 'src/enum';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { PluginActionTable, PluginFilterTable, PluginTable } from 'src/schema/tables/plugin.table';
import { WorkflowActionTable, WorkflowFilterTable, WorkflowTable } from 'src/schema/tables/workflow.table';
import { UserMetadataItem } from 'src/types';
@ -31,7 +32,7 @@ export type AuthUser = {
};
export type AlbumUser = {
user: User;
user: ShallowDehydrateObject<User>;
role: AlbumUserRole;
};
@ -67,7 +68,7 @@ export type Activity = {
updatedAt: Date;
albumId: string;
userId: string;
user: User;
user: ShallowDehydrateObject<User>;
assetId: string | null;
comment: string | null;
isLiked: boolean;
@ -105,7 +106,7 @@ export type Memory = {
data: object;
ownerId: string;
isSaved: boolean;
assets: MapAsset[];
assets: ShallowDehydrateObject<MapAsset>[];
};
export type Asset = {
@ -159,9 +160,9 @@ export type StorageAsset = {
export type Stack = {
id: string;
primaryAssetId: string;
owner?: User;
owner?: ShallowDehydrateObject<User>;
ownerId: string;
assets: MapAsset[];
assets: ShallowDehydrateObject<MapAsset>[];
assetCount?: number;
};
@ -177,11 +178,11 @@ export type AuthSharedLink = {
export type SharedLink = {
id: string;
album?: Album | null;
album?: ShallowDehydrateObject<Album> | null;
albumId: string | null;
allowDownload: boolean;
allowUpload: boolean;
assets: MapAsset[];
assets: ShallowDehydrateObject<MapAsset>[];
createdAt: Date;
description: string | null;
expiresAt: Date | null;
@ -194,8 +195,8 @@ export type SharedLink = {
};
export type Album = Selectable<AlbumTable> & {
owner: User;
assets: MapAsset[];
owner: ShallowDehydrateObject<User>;
assets: ShallowDehydrateObject<Selectable<AssetTable>>[];
};
export type AuthSession = {
@ -205,9 +206,9 @@ export type AuthSession = {
export type Partner = {
sharedById: string;
sharedBy: User;
sharedBy: ShallowDehydrateObject<User>;
sharedWithId: string;
sharedWith: User;
sharedWith: ShallowDehydrateObject<User>;
createdAt: Date;
createId: string;
updatedAt: Date;
@ -270,7 +271,7 @@ export type AssetFace = {
imageWidth: number;
personId: string | null;
sourceType: SourceType;
person?: Person | null;
person?: ShallowDehydrateObject<Person> | null;
updatedAt: Date;
updateId: string;
isVisible: boolean;

View File

@ -79,6 +79,6 @@ export const mapActivity = (activity: Activity): ActivityResponseDto => {
createdAt: activity.createdAt,
comment: activity.comment,
type: activity.isLiked ? ReactionType.LIKE : ReactionType.COMMENT,
user: mapUser(activity.user),
user: mapUser({ ...activity.user, profileChangedAt: new Date(activity.user.profileChangedAt) }),
};
};

View File

@ -1,18 +1,22 @@
import { mapAlbum } from 'src/dtos/album.dto';
import { AlbumFactory } from 'test/factories/album.factory';
import { getForAlbum } from 'test/mappers';
describe('mapAlbum', () => {
it('should set start and end dates', () => {
const startDate = new Date('2023-02-22T05:06:29.716Z');
const endDate = new Date('2025-01-01T01:02:03.456Z');
const album = AlbumFactory.from().asset({ localDateTime: endDate }).asset({ localDateTime: startDate }).build();
const dto = mapAlbum(album, false);
const album = AlbumFactory.from()
.asset({ localDateTime: endDate }, (builder) => builder.exif())
.asset({ localDateTime: startDate }, (builder) => builder.exif())
.build();
const dto = mapAlbum(getForAlbum(album), false);
expect(dto.startDate).toEqual(startDate);
expect(dto.endDate).toEqual(endDate);
});
it('should not set start and end dates for empty assets', () => {
const dto = mapAlbum(AlbumFactory.create(), false);
const dto = mapAlbum(getForAlbum(AlbumFactory.create()), false);
expect(dto.startDate).toBeUndefined();
expect(dto.endDate).toBeUndefined();
});

View File

@ -1,12 +1,13 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { ArrayNotEmpty, IsArray, IsString, ValidateNested } from 'class-validator';
import { ShallowDehydrateObject } from 'kysely';
import _ from 'lodash';
import { AlbumUser, AuthSharedLink, User } from 'src/database';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { AssetResponseDto, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AssetResponseDto, hydrateAsset, MapAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
import { mapUser, UserResponseDto } from 'src/dtos/user.dto';
import { AlbumUserRole, AssetOrder } from 'src/enum';
import { Optional, ValidateBoolean, ValidateEnum, ValidateUUID } from 'src/validation';
@ -191,8 +192,8 @@ export class AlbumResponseDto {
export type MapAlbumDto = {
albumUsers?: AlbumUser[];
assets?: MapAsset[];
sharedLinks?: AuthSharedLink[];
assets?: ShallowDehydrateObject<MapAsset>[];
sharedLinks?: ShallowDehydrateObject<AuthSharedLink>[];
albumName: string;
description: string;
albumThumbnailAssetId: string | null;
@ -200,7 +201,7 @@ export type MapAlbumDto = {
updatedAt: Date;
id: string;
ownerId: string;
owner: User;
owner: ShallowDehydrateObject<User>;
isActivityEnabled: boolean;
order: AssetOrder;
};
@ -210,7 +211,7 @@ export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDt
if (entity.albumUsers) {
for (const albumUser of entity.albumUsers) {
const user = mapUser(albumUser.user);
const user = mapUser({ ...albumUser.user, profileChangedAt: new Date(albumUser.user.profileChangedAt) });
albumUsers.push({
user,
role: albumUser.role,
@ -240,13 +241,13 @@ export const mapAlbum = (entity: MapAlbumDto, withAssets: boolean, auth?: AuthDt
updatedAt: entity.updatedAt,
id: entity.id,
ownerId: entity.ownerId,
owner: mapUser(entity.owner),
owner: mapUser({ ...entity.owner, profileChangedAt: new Date(entity.owner.profileChangedAt) }),
albumUsers: albumUsersSorted,
shared: hasSharedUser || hasSharedLink,
hasSharedLink,
startDate,
endDate,
assets: (withAssets ? assets : []).map((asset) => mapAsset(asset, { auth })),
startDate: startDate ? new Date(startDate) : undefined,
endDate: endDate ? new Date(endDate) : undefined,
assets: (withAssets ? assets : []).map((asset) => mapAsset(hydrateAsset(asset), { auth })),
assetCount: entity.assets?.length || 0,
isActivityEnabled: entity.isActivityEnabled,
order: entity.order,

View File

@ -3,6 +3,7 @@ import { AssetEditAction } from 'src/dtos/editing.dto';
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { PersonFactory } from 'test/factories/person.factory';
import { getForAsset } from 'test/mappers';
describe('mapAsset', () => {
describe('peopleWithFaces', () => {
@ -41,7 +42,7 @@ describe('mapAsset', () => {
})
.build();
const result = mapAsset(asset);
const result = mapAsset(getForAsset(asset));
expect(result.people).toBeDefined();
expect(result.people).toHaveLength(1);
@ -80,7 +81,7 @@ describe('mapAsset', () => {
.edit({ action: AssetEditAction.Crop, parameters: { x: 50, y: 50, width: 500, height: 400 } })
.build();
const result = mapAsset(asset);
const result = mapAsset(getForAsset(asset));
expect(result.unassignedFaces).toBeDefined();
expect(result.unassignedFaces).toHaveLength(1);
@ -130,7 +131,7 @@ describe('mapAsset', () => {
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
.build();
const result = mapAsset(asset);
const result = mapAsset(getForAsset(asset));
expect(result.people).toBeDefined();
expect(result.people).toHaveLength(2);
@ -179,7 +180,7 @@ describe('mapAsset', () => {
.exif({ exifImageWidth: 1000, exifImageHeight: 800 })
.build();
const result = mapAsset(asset);
const result = mapAsset(getForAsset(asset));
expect(result.people).toBeDefined();
expect(result.people).toHaveLength(1);

View File

@ -1,5 +1,5 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { Selectable } from 'kysely';
import { Selectable, ShallowDehydrateObject } from 'kysely';
import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database';
import { HistoryBuilder, Property } from 'src/decorators';
import { AuthDto } from 'src/dtos/auth.dto';
@ -151,13 +151,13 @@ export type MapAsset = {
deviceId: string;
duplicateId: string | null;
duration: string | null;
edits?: AssetEditActionItem[];
edits?: ShallowDehydrateObject<AssetEditActionItem>[];
encodedVideoPath: string | null;
exifInfo?: Selectable<Exif> | null;
faces?: AssetFace[];
exifInfo?: ShallowDehydrateObject<Selectable<Exif>> | null;
faces?: ShallowDehydrateObject<AssetFace>[];
fileCreatedAt: Date;
fileModifiedAt: Date;
files?: AssetFile[];
files?: ShallowDehydrateObject<AssetFile>[];
isExternal: boolean;
isFavorite: boolean;
isOffline: boolean;
@ -167,11 +167,11 @@ export type MapAsset = {
localDateTime: Date;
originalFileName: string;
originalPath: string;
owner?: User | null;
owner?: ShallowDehydrateObject<User> | null;
ownerId: string;
stack?: Stack | null;
stack?: (ShallowDehydrateObject<Stack> & { assets: Stack['assets'] }) | null;
stackId: string | null;
tags?: Tag[];
tags?: ShallowDehydrateObject<Tag>[];
thumbhash: Buffer<ArrayBufferLike> | null;
type: AssetType;
width: number | null;
@ -213,7 +213,15 @@ const peopleWithFaces = (
}
if (!peopleFaces.has(face.person.id)) {
peopleFaces.set(face.person.id, { ...mapPerson(face.person), faces: [] });
peopleFaces.set(face.person.id, {
...mapPerson({
...face.person,
birthDate: face.person.birthDate ? new Date(face.person.birthDate) : null,
createdAt: new Date(face.person.createdAt),
updatedAt: new Date(face.person.updatedAt),
}),
faces: [],
});
}
const mappedFace = mapFacesWithoutPerson(face, edits, assetDimensions);
peopleFaces.get(face.person.id)!.faces.push(mappedFace);
@ -260,7 +268,9 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
createdAt: entity.createdAt,
deviceAssetId: entity.deviceAssetId,
ownerId: entity.ownerId,
owner: entity.owner ? mapUser(entity.owner) : undefined,
owner: entity.owner
? mapUser({ ...entity.owner, profileChangedAt: new Date(entity.owner.profileChangedAt) })
: undefined,
deviceId: entity.deviceId,
libraryId: entity.libraryId,
type: entity.type,
@ -277,13 +287,35 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
isTrashed: !!entity.deletedAt,
visibility: entity.visibility,
duration: entity.duration ?? '0:00:00.00000',
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
exifInfo: entity.exifInfo
? mapExif({
...entity.exifInfo,
dateTimeOriginal: entity.exifInfo.dateTimeOriginal ? new Date(entity.exifInfo.dateTimeOriginal) : null,
modifyDate: entity.exifInfo.modifyDate ? new Date(entity.exifInfo.modifyDate) : null,
})
: undefined,
livePhotoVideoId: entity.livePhotoVideoId,
tags: entity.tags?.map((tag) => mapTag(tag)),
people: peopleWithFaces(entity.faces, entity.edits, assetDimensions),
tags: entity.tags?.map((tag) =>
mapTag({ ...tag, createdAt: new Date(tag.createdAt), updatedAt: new Date(tag.updatedAt) }),
),
people: peopleWithFaces(
entity.faces?.map((face) => ({
...face,
deletedAt: face.deletedAt ? new Date(face.deletedAt) : null,
updatedAt: new Date(face.updatedAt),
})),
entity.edits,
assetDimensions,
),
unassignedFaces: entity.faces
?.filter((face) => !face.person)
.map((a) => mapFacesWithoutPerson(a, entity.edits, assetDimensions)),
.map((face) =>
mapFacesWithoutPerson(
{ ...face, deletedAt: face.deletedAt ? new Date(face.deletedAt) : null, updatedAt: new Date(face.updatedAt) },
entity.edits,
assetDimensions,
),
),
checksum: hexOrBufferToBase64(entity.checksum)!,
stack: withStack ? mapStack(entity) : undefined,
isOffline: entity.isOffline,
@ -295,3 +327,15 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
isEdited: entity.isEdited,
};
}
export const hydrateAsset = (asset: ShallowDehydrateObject<MapAsset>) => ({
...asset,
checksum: Buffer.from(asset.checksum),
createdAt: new Date(asset.createdAt),
deletedAt: asset.deletedAt ? new Date(asset.deletedAt) : null,
fileCreatedAt: new Date(asset.fileCreatedAt),
fileModifiedAt: new Date(asset.fileModifiedAt),
localDateTime: new Date(asset.localDateTime),
thumbhash: asset.thumbhash ? Buffer.from(asset.thumbhash) : null,
updatedAt: new Date(asset.updatedAt),
});

View File

@ -3,7 +3,7 @@ import { Type } from 'class-transformer';
import { IsInt, IsObject, IsPositive, ValidateNested } from 'class-validator';
import { Memory } from 'src/database';
import { HistoryBuilder } from 'src/decorators';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AssetResponseDto, hydrateAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetOrderWithRandom, MemoryType } from 'src/enum';
import { Optional, ValidateBoolean, ValidateDate, ValidateEnum, ValidateUUID } from 'src/validation';
@ -146,6 +146,6 @@ export const mapMemory = (entity: Memory, auth: AuthDto): MemoryResponseDto => {
type: entity.type as MemoryType,
data: entity.data as unknown as MemoryData,
isSaved: entity.isSaved,
assets: ('assets' in entity ? entity.assets : []).map((asset) => mapAsset(asset, { auth })),
assets: ('assets' in entity ? entity.assets : []).map((asset) => mapAsset(hydrateAsset(asset), { auth })),
};
};

View File

@ -266,6 +266,14 @@ export function mapFaces(
): AssetFaceResponseDto {
return {
...mapFacesWithoutPerson(face, edits, assetDimensions),
person: face.person?.ownerId === auth.user.id ? mapPerson(face.person) : null,
person:
face.person?.ownerId === auth.user.id
? mapPerson({
...face.person,
birthDate: face.person.birthDate ? new Date(face.person.birthDate) : null,
createdAt: new Date(face.person.createdAt),
updatedAt: new Date(face.person.updatedAt),
})
: null,
};
}

View File

@ -163,8 +163,29 @@ export function mapSharedLink(sharedLink: SharedLink, options: { stripAssetMetad
type: sharedLink.type,
createdAt: sharedLink.createdAt,
expiresAt: sharedLink.expiresAt,
assets: assets.map((asset) => mapAsset(asset, { stripMetadata: options.stripAssetMetadata })),
album: sharedLink.album ? mapAlbumWithoutAssets(sharedLink.album) : undefined,
assets: assets.map((asset) =>
mapAsset(
{
...asset,
checksum: Buffer.from(asset.checksum),
createdAt: new Date(asset.createdAt),
deletedAt: asset.deletedAt ? new Date(asset.deletedAt) : null,
localDateTime: new Date(asset.localDateTime),
fileCreatedAt: new Date(asset.fileCreatedAt),
fileModifiedAt: new Date(asset.fileModifiedAt),
thumbhash: asset.thumbhash ? Buffer.from(asset.thumbhash) : null,
updatedAt: new Date(asset.updatedAt),
},
{ stripMetadata: options.stripAssetMetadata },
),
),
album: sharedLink.album
? mapAlbumWithoutAssets({
...sharedLink.album,
createdAt: new Date(sharedLink.album.createdAt),
updatedAt: new Date(sharedLink.album.updatedAt),
})
: undefined,
allowUpload: sharedLink.allowUpload,
allowDownload: sharedLink.allowDownload,
showMetadata: sharedLink.showExif,

View File

@ -1,7 +1,7 @@
import { ApiProperty } from '@nestjs/swagger';
import { ArrayMinSize } from 'class-validator';
import { Stack } from 'src/database';
import { AssetResponseDto, mapAsset } from 'src/dtos/asset-response.dto';
import { AssetResponseDto, hydrateAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { ValidateUUID } from 'src/validation';
@ -37,6 +37,6 @@ export const mapStack = (stack: Stack, { auth }: { auth?: AuthDto }) => {
return {
id: stack.id,
primaryAssetId: stack.primaryAssetId,
assets: [...primary, ...others].map((asset) => mapAsset(asset, { auth })),
assets: [...primary, ...others].map((asset) => mapAsset(hydrateAsset(asset), { auth })),
};
};

View File

@ -1,12 +1,22 @@
import { Injectable } from '@nestjs/common';
import { ExpressionBuilder, Insertable, Kysely, NotNull, sql, Updateable } from 'kysely';
import {
ExpressionBuilder,
Insertable,
Kysely,
NotNull,
Selectable,
ShallowDehydrateObject,
sql,
Updateable,
} from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { columns, Exif } from 'src/database';
import { columns } from 'src/database';
import { Chunked, ChunkedArray, ChunkedSet, DummyValue, GenerateSql } from 'src/decorators';
import { AlbumUserCreateDto } from 'src/dtos/album.dto';
import { DB } from 'src/schema';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { withDefaultVisibility } from 'src/utils/database';
export interface AlbumAssetCount {
@ -56,7 +66,11 @@ const withAssets = (eb: ExpressionBuilder<DB, 'album'>) => {
.selectFrom('asset')
.selectAll('asset')
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.select((eb) => eb.table('asset_exif').$castTo<Exif>().as('exifInfo'))
.select((eb) =>
jsonObjectFrom(eb.table('asset_exif'))
.$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>()
.as('exifInfo'),
)
.innerJoin('album_asset', 'album_asset.assetId', 'asset.id')
.whereRef('album_asset.albumId', '=', 'album.id')
.where('asset.deletedAt', 'is', null)

View File

@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Kysely, sql } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
@ -9,7 +9,6 @@ import { DB } from 'src/schema';
import {
anyUuid,
asUuid,
toJson,
withDefaultVisibility,
withEdits,
withExif,
@ -296,7 +295,11 @@ export class AssetJobRepository {
.as('stack_result'),
(join) => join.onTrue(),
)
.select((eb) => toJson(eb, 'stack_result').as('stack'))
.select((eb) =>
jsonObjectFrom(eb.table('stack_result'))
.$castTo<{ id: string; primaryAssetId: string; assets: { id: string }[] } | null>()
.as('stack'),
)
.where('asset.id', '=', id)
.executeTakeFirst();
}

View File

@ -10,7 +10,7 @@ import {
Updateable,
UpdateResult,
} from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { isEmpty, isUndefined, omitBy } from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { LockableProperty, Stack } from 'src/database';
@ -545,7 +545,7 @@ export class AssetRepository {
qb
.leftJoin('stack', 'stack.id', 'asset.stackId')
.$if(!stack!.assets, (qb) =>
qb.select((eb) => eb.fn.toJson(eb.table('stack')).$castTo<Stack | null>().as('stack')),
qb.select((eb) => jsonObjectFrom(eb.table('stack')).$castTo<Stack | null>().as('stack')),
)
.$if(!!stack!.assets, (qb) =>
qb
@ -554,7 +554,7 @@ export class AssetRepository {
eb
.selectFrom('asset as stacked')
.selectAll('stack')
.select((eb) => eb.fn('array_agg', [eb.table('stacked')]).as('assets'))
.select((eb) => jsonArrayFrom(eb.table('stacked')).as('assets'))
.whereRef('stacked.stackId', '=', 'stack.id')
.whereRef('stacked.id', '!=', 'stack.primaryAssetId')
.where('stacked.deletedAt', 'is', null)
@ -563,7 +563,7 @@ export class AssetRepository {
.as('stacked_assets'),
(join) => join.on('stack.id', 'is not', null),
)
.select((eb) => eb.fn.toJson(eb.table('stacked_assets')).$castTo<Stack | null>().as('stack')),
.select((eb) => jsonObjectFrom(eb.table('stacked_assets')).as('stack')),
),
)
.$if(!!files, (qb) => qb.select(withFiles))

View File

@ -1,8 +1,8 @@
import { Injectable } from '@nestjs/common';
import { Kysely, NotNull, sql } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { InjectKysely } from 'nestjs-kysely';
import { Chunked, DummyValue, GenerateSql } from 'src/decorators';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetType, VectorIndex } from 'src/enum';
import { probes } from 'src/repositories/database.repository';
import { DB } from 'src/schema';
@ -39,14 +39,14 @@ export class DuplicateRepository {
qb
.selectFrom('asset_exif')
.selectAll('asset')
.select((eb) => eb.table('asset_exif').as('exifInfo'))
.select((eb) => jsonObjectFrom(eb.table('asset_exif')).as('exifInfo'))
.whereRef('asset_exif.assetId', '=', 'asset.id')
.as('asset2'),
(join) => join.onTrue(),
)
.select('asset.duplicateId')
.select((eb) =>
eb.fn.jsonAgg('asset2').orderBy('asset.localDateTime', 'asc').$castTo<MapAsset[]>().as('assets'),
jsonArrayFrom(eb.selectFrom('asset2').selectAll().orderBy('asset.localDateTime', 'asc')).as('assets'),
)
.where('asset.ownerId', '=', asUuid(userId))
.where('asset.duplicateId', 'is not', null)

View File

@ -1,5 +1,5 @@
import { Injectable } from '@nestjs/common';
import { Kysely, OrderByDirection, Selectable, sql } from 'kysely';
import { Kysely, OrderByDirection, Selectable, ShallowDehydrateObject, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { randomUUID } from 'node:crypto';
import { DummyValue, GenerateSql } from 'src/decorators';
@ -433,7 +433,7 @@ export class SearchRepository {
.select((eb) =>
eb
.fn('to_jsonb', [eb.table('asset_exif')])
.$castTo<Selectable<AssetExifTable>>()
.$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>>>()
.as('exifInfo'),
)
.orderBy('asset_exif.city')

View File

@ -1,13 +1,14 @@
import { Injectable } from '@nestjs/common';
import { Insertable, Kysely, sql, Updateable } from 'kysely';
import { Insertable, Kysely, Selectable, ShallowDehydrateObject, sql, Updateable } from 'kysely';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import _ from 'lodash';
import { InjectKysely } from 'nestjs-kysely';
import { Album, columns } from 'src/database';
import { DummyValue, GenerateSql } from 'src/decorators';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkType } from 'src/enum';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
export type SharedLinkSearchOptions = {
@ -71,7 +72,7 @@ export class SharedLinkRepository {
.as('exifInfo'),
(join) => join.onTrue(),
)
.select((eb) => eb.fn.toJson(eb.table('exifInfo')).as('exifInfo'))
.select((eb) => jsonObjectFrom(eb.table('exifInfo')).as('exifInfo'))
.orderBy('asset.fileCreatedAt', 'asc')
.as('assets'),
(join) => join.onTrue(),
@ -106,11 +107,15 @@ export class SharedLinkRepository {
.select((eb) =>
eb.fn
.coalesce(eb.fn.jsonAgg('a').filterWhere('a.id', 'is not', null), sql`'[]'`)
.$castTo<MapAsset[]>()
.$castTo<
(ShallowDehydrateObject<Selectable<AssetTable>> & {
exifInfo: ShallowDehydrateObject<Selectable<AssetExifTable>>;
})[]
>()
.as('assets'),
)
.groupBy(['shared_link.id', sql`"album".*`])
.select((eb) => eb.fn.toJson('album').$castTo<Album | null>().as('album'))
.select((eb) => jsonObjectFrom(eb.table('album')).$castTo<ShallowDehydrateObject<Album> | null>().as('album'))
.where('shared_link.id', '=', id)
.where('shared_link.userId', '=', userId)
.where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)]))
@ -134,9 +139,7 @@ export class SharedLinkRepository {
.selectAll('asset')
.orderBy('asset.fileCreatedAt', 'asc')
.limit(1),
)
.$castTo<MapAsset[]>()
.as('assets'),
).as('assets'),
)
.leftJoinLateral(
(eb) =>
@ -175,7 +178,7 @@ export class SharedLinkRepository {
.as('album'),
(join) => join.onTrue(),
)
.select((eb) => eb.fn.toJson('album').$castTo<Album | null>().as('album'))
.select((eb) => eb.fn.toJson('album').$castTo<ShallowDehydrateObject<Album> | null>().as('album'))
.where((eb) => eb.or([eb('shared_link.type', '=', SharedLinkType.Individual), eb('album.id', 'is not', null)]))
.$if(!!albumId, (eb) => eb.where('shared_link.albumId', '=', albumId!))
.$if(!!id, (eb) => eb.where('shared_link.id', '=', id!))
@ -268,8 +271,12 @@ export class SharedLinkRepository {
)
.select((eb) =>
eb.fn
.coalesce(eb.fn.jsonAgg('assets').filterWhere('assets.id', 'is not', null), sql`'[]'`)
.$castTo<MapAsset[]>()
.coalesce(jsonArrayFrom(eb.selectFrom('assets').selectAll().where('assets.id', 'is not', null)), sql`'[]'`)
.$castTo<
(ShallowDehydrateObject<Selectable<AssetTable>> & {
exifInfo: ShallowDehydrateObject<Selectable<AssetExifTable>>;
})[]
>()
.as('assets'),
)
.groupBy('shared_link.id')

View File

@ -1,6 +1,7 @@
import { BadRequestException } from '@nestjs/common';
import { ReactionType } from 'src/dtos/activity.dto';
import { ActivityService } from 'src/services/activity.service';
import { getForActivity } from 'test/mappers';
import { factory, newUuid, newUuids } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@ -78,7 +79,7 @@ describe(ActivityService.name, () => {
const activity = factory.activity({ albumId, assetId, userId });
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.create.mockResolvedValue(activity);
mocks.activity.create.mockResolvedValue(getForActivity(activity));
await sut.create(factory.auth({ user: { id: userId } }), {
albumId,
@ -101,7 +102,7 @@ describe(ActivityService.name, () => {
const activity = factory.activity({ albumId, assetId });
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.create.mockResolvedValue(activity);
mocks.activity.create.mockResolvedValue(getForActivity(activity));
await expect(
sut.create(factory.auth(), { albumId, assetId, type: ReactionType.COMMENT, comment: 'comment' }),
@ -113,7 +114,7 @@ describe(ActivityService.name, () => {
const activity = factory.activity({ userId, albumId, assetId, isLiked: true });
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.create.mockResolvedValue(activity);
mocks.activity.create.mockResolvedValue(getForActivity(activity));
mocks.activity.search.mockResolvedValue([]);
await sut.create(factory.auth({ user: { id: userId } }), { albumId, assetId, type: ReactionType.LIKE });
@ -127,7 +128,7 @@ describe(ActivityService.name, () => {
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([albumId]));
mocks.access.activity.checkCreateAccess.mockResolvedValue(new Set([albumId]));
mocks.activity.search.mockResolvedValue([activity]);
mocks.activity.search.mockResolvedValue([getForActivity(activity)]);
await sut.create(factory.auth(), { albumId, assetId, type: ReactionType.LIKE });

View File

@ -1,5 +1,4 @@
import { BadRequestException } from '@nestjs/common';
import _ from 'lodash';
import { BulkIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { AlbumUserRole, AssetOrder, UserMetadataKey } from 'src/enum';
import { AlbumService } from 'src/services/album.service';
@ -9,6 +8,7 @@ import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { UserFactory } from 'test/factories/user.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { getForAlbum } from 'test/mappers';
import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@ -45,7 +45,7 @@ describe(AlbumService.name, () => {
it('gets list of albums for auth user', async () => {
const album = AlbumFactory.from().albumUser().build();
const sharedWithUserAlbum = AlbumFactory.from().owner(album.owner).albumUser().build();
mocks.album.getOwned.mockResolvedValue([album, sharedWithUserAlbum]);
mocks.album.getOwned.mockResolvedValue([getForAlbum(album), getForAlbum(sharedWithUserAlbum)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{
albumId: album.id,
@ -70,8 +70,13 @@ describe(AlbumService.name, () => {
});
it('gets list of albums that have a specific asset', async () => {
const album = AlbumFactory.from().owner({ isAdmin: true }).albumUser().asset().asset().build();
mocks.album.getByAssetId.mockResolvedValue([album]);
const album = AlbumFactory.from()
.owner({ isAdmin: true })
.albumUser()
.asset({}, (builder) => builder.exif())
.asset({}, (builder) => builder.exif())
.build();
mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{
albumId: album.id,
@ -90,7 +95,7 @@ describe(AlbumService.name, () => {
it('gets list of albums that are shared', async () => {
const album = AlbumFactory.from().albumUser().build();
mocks.album.getShared.mockResolvedValue([album]);
mocks.album.getShared.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{
albumId: album.id,
@ -109,7 +114,7 @@ describe(AlbumService.name, () => {
it('gets list of albums that are NOT shared', async () => {
const album = AlbumFactory.create();
mocks.album.getNotShared.mockResolvedValue([album]);
mocks.album.getNotShared.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{
albumId: album.id,
@ -129,7 +134,7 @@ describe(AlbumService.name, () => {
it('counts assets correctly', async () => {
const album = AlbumFactory.create();
mocks.album.getOwned.mockResolvedValue([album]);
mocks.album.getOwned.mockResolvedValue([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValue([
{
albumId: album.id,
@ -155,7 +160,7 @@ describe(AlbumService.name, () => {
.albumUser(albumUser)
.build();
mocks.album.create.mockResolvedValue(album);
mocks.album.create.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValue(UserFactory.create(album.albumUsers[0].user));
mocks.user.getMetadata.mockResolvedValue([]);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
@ -192,7 +197,7 @@ describe(AlbumService.name, () => {
.asset({ id: assetId }, (asset) => asset.exif())
.albumUser(albumUser)
.build();
mocks.album.create.mockResolvedValue(album);
mocks.album.create.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValue(album.albumUsers[0].user);
mocks.user.getMetadata.mockResolvedValue([
{
@ -250,7 +255,7 @@ describe(AlbumService.name, () => {
.albumUser()
.build();
mocks.user.get.mockResolvedValue(album.albumUsers[0].user);
mocks.album.create.mockResolvedValue(album);
mocks.album.create.mockResolvedValue(getForAlbum(album));
mocks.user.getMetadata.mockResolvedValue([]);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
@ -316,7 +321,7 @@ describe(AlbumService.name, () => {
it('should require a valid thumbnail asset id', async () => {
const album = AlbumFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValue(new Set());
await expect(
@ -330,8 +335,8 @@ describe(AlbumService.name, () => {
it('should allow the owner to update the album', async () => {
const album = AlbumFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.update.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.update.mockResolvedValue(getForAlbum(album));
await sut.update(AuthFactory.create(album.owner), album.id, { albumName: 'new album name' });
@ -352,7 +357,7 @@ describe(AlbumService.name, () => {
it('should not let a shared user delete the album', async () => {
const album = AlbumFactory.create();
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set());
await expect(sut.delete(AuthFactory.create(album.owner), album.id)).rejects.toBeInstanceOf(BadRequestException);
@ -363,7 +368,7 @@ describe(AlbumService.name, () => {
it('should let the owner delete an album', async () => {
const album = AlbumFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await sut.delete(AuthFactory.create(album.owner), album.id);
@ -387,7 +392,7 @@ describe(AlbumService.name, () => {
const userId = newUuid();
const album = AlbumFactory.from().albumUser({ userId }).build();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await expect(
sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId }] }),
).rejects.toBeInstanceOf(BadRequestException);
@ -398,7 +403,7 @@ describe(AlbumService.name, () => {
it('should throw an error if the userId does not exist', async () => {
const album = AlbumFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValue(void 0);
await expect(
sut.addUsers(AuthFactory.create(album.owner), album.id, { albumUsers: [{ userId: 'unknown-user' }] }),
@ -410,7 +415,7 @@ describe(AlbumService.name, () => {
it('should throw an error if the userId is the ownerId', async () => {
const album = AlbumFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await expect(
sut.addUsers(AuthFactory.create(album.owner), album.id, {
albumUsers: [{ userId: album.owner.id }],
@ -424,8 +429,8 @@ describe(AlbumService.name, () => {
const album = AlbumFactory.create();
const user = UserFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.update.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.update.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValue(user);
mocks.albumUser.create.mockResolvedValue(AlbumUserFactory.from().album(album).user(user).build());
@ -456,7 +461,7 @@ describe(AlbumService.name, () => {
const userId = newUuid();
const album = AlbumFactory.from().albumUser({ userId }).build();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.albumUser.delete.mockResolvedValue();
await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, userId)).resolves.toBeUndefined();
@ -470,7 +475,7 @@ describe(AlbumService.name, () => {
const user1 = UserFactory.create();
const user2 = UserFactory.create();
const album = AlbumFactory.from().albumUser({ userId: user1.id }).albumUser({ userId: user2.id }).build();
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await expect(sut.removeUser(AuthFactory.create(user1), album.id, user2.id)).rejects.toBeInstanceOf(
BadRequestException,
@ -483,7 +488,7 @@ describe(AlbumService.name, () => {
it('should allow a shared user to remove themselves', async () => {
const user1 = UserFactory.create();
const album = AlbumFactory.from().albumUser({ userId: user1.id }).build();
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.albumUser.delete.mockResolvedValue();
await sut.removeUser(AuthFactory.create(user1), album.id, user1.id);
@ -495,7 +500,7 @@ describe(AlbumService.name, () => {
it('should allow a shared user to remove themselves using "me"', async () => {
const user = UserFactory.create();
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.albumUser.delete.mockResolvedValue();
await sut.removeUser(AuthFactory.create(user), album.id, 'me');
@ -506,7 +511,7 @@ describe(AlbumService.name, () => {
it('should not allow the owner to be removed', async () => {
const album = AlbumFactory.from().albumUser().build();
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, album.owner.id)).rejects.toBeInstanceOf(
BadRequestException,
@ -517,7 +522,7 @@ describe(AlbumService.name, () => {
it('should throw an error for a user not in the album', async () => {
const album = AlbumFactory.from().albumUser().build();
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await expect(sut.removeUser(AuthFactory.create(album.owner), album.id, 'user-3')).rejects.toBeInstanceOf(
BadRequestException,
@ -546,7 +551,7 @@ describe(AlbumService.name, () => {
describe('getAlbumInfo', () => {
it('should get a shared album', async () => {
const album = AlbumFactory.from().albumUser().build();
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getMetadataForIds.mockResolvedValue([
{
@ -566,7 +571,7 @@ describe(AlbumService.name, () => {
it('should get a shared album via a shared link', async () => {
const album = AlbumFactory.from().albumUser().build();
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getMetadataForIds.mockResolvedValue([
{
@ -588,7 +593,7 @@ describe(AlbumService.name, () => {
it('should get a shared album via shared with user', async () => {
const user = UserFactory.create();
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getMetadataForIds.mockResolvedValue([
{
@ -630,7 +635,7 @@ describe(AlbumService.name, () => {
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
await expect(
@ -654,7 +659,7 @@ describe(AlbumService.name, () => {
const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset2.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset2.id] })).resolves.toEqual([
@ -675,7 +680,7 @@ describe(AlbumService.name, () => {
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
await expect(
@ -703,7 +708,7 @@ describe(AlbumService.name, () => {
const album = AlbumFactory.from().albumUser({ userId: user.id, role: AlbumUserRole.Viewer }).build();
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.access.album.checkSharedAlbumAccess.mockResolvedValue(new Set());
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await expect(
sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset1.id, asset2.id, asset3.id] }),
@ -718,7 +723,7 @@ describe(AlbumService.name, () => {
const auth = AuthFactory.from(album.owner).sharedLink({ allowUpload: true, userId: album.ownerId }).build();
mocks.access.album.checkSharedLinkAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
await expect(sut.addAssets(auth, album.id, { ids: [asset1.id, asset2.id, asset3.id] })).resolves.toEqual([
@ -742,7 +747,7 @@ describe(AlbumService.name, () => {
const asset = AssetFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
@ -762,7 +767,7 @@ describe(AlbumService.name, () => {
const album = AlbumFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set([asset.id]));
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
@ -776,7 +781,7 @@ describe(AlbumService.name, () => {
const asset = AssetFactory.create();
const album = AlbumFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set());
await expect(sut.addAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
@ -791,7 +796,7 @@ describe(AlbumService.name, () => {
const user = UserFactory.create();
const album = AlbumFactory.create();
const asset = AssetFactory.create({ ownerId: user.id });
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await expect(sut.addAssets(AuthFactory.create(user), album.id, { ids: [asset.id] })).rejects.toBeInstanceOf(
BadRequestException,
@ -804,7 +809,7 @@ describe(AlbumService.name, () => {
it('should not allow unauthorized shared link access to the album', async () => {
const album = AlbumFactory.create();
const asset = AssetFactory.create();
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
await expect(
sut.addAssets(AuthFactory.from().sharedLink({ allowUpload: true }).build(), album.id, { ids: [asset.id] }),
@ -821,7 +826,7 @@ describe(AlbumService.name, () => {
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
@ -859,7 +864,7 @@ describe(AlbumService.name, () => {
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
@ -897,7 +902,7 @@ describe(AlbumService.name, () => {
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
@ -943,7 +948,7 @@ describe(AlbumService.name, () => {
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.access.album.checkSharedAlbumAccess.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
@ -965,7 +970,7 @@ describe(AlbumService.name, () => {
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.access.album.checkSharedLinkAccess.mockResolvedValueOnce(new Set([album1.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
const auth = AuthFactory.from(album1.owner).sharedLink({ allowUpload: true }).build();
@ -1004,7 +1009,7 @@ describe(AlbumService.name, () => {
];
mocks.access.album.checkOwnerAccess.mockResolvedValueOnce(new Set([album1.id, album2.id]));
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
@ -1048,7 +1053,7 @@ describe(AlbumService.name, () => {
mocks.album.getAssetIds
.mockResolvedValueOnce(new Set([asset1.id, asset2.id, asset3.id]))
.mockResolvedValueOnce(new Set());
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
await expect(
sut.addAssetsToAlbums(AuthFactory.create(album1.owner), {
@ -1078,7 +1083,7 @@ describe(AlbumService.name, () => {
.mockResolvedValueOnce(new Set([album1.id]))
.mockResolvedValueOnce(new Set([album2.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id, asset3.id]));
await expect(
@ -1107,7 +1112,7 @@ describe(AlbumService.name, () => {
mocks.access.album.checkSharedAlbumAccess
.mockResolvedValueOnce(new Set([album1.id]))
.mockResolvedValueOnce(new Set([album2.id]));
mocks.album.getById.mockResolvedValueOnce(_.cloneDeep(album1)).mockResolvedValueOnce(_.cloneDeep(album2));
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
mocks.album.getAssetIds.mockResolvedValueOnce(new Set()).mockResolvedValueOnce(new Set());
await expect(
@ -1138,7 +1143,7 @@ describe(AlbumService.name, () => {
const album1 = AlbumFactory.create();
const album2 = AlbumFactory.create();
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
await expect(
sut.addAssetsToAlbums(AuthFactory.create(user), {
@ -1160,7 +1165,7 @@ describe(AlbumService.name, () => {
const album1 = AlbumFactory.create();
const album2 = AlbumFactory.create();
const [asset1, asset2, asset3] = [AssetFactory.create(), AssetFactory.create(), AssetFactory.create()];
mocks.album.getById.mockResolvedValueOnce(album1).mockResolvedValueOnce(album2);
mocks.album.getById.mockResolvedValueOnce(getForAlbum(album1)).mockResolvedValueOnce(getForAlbum(album2));
await expect(
sut.addAssetsToAlbums(AuthFactory.from().sharedLink({ allowUpload: true }).build(), {
@ -1182,7 +1187,7 @@ describe(AlbumService.name, () => {
const album = AlbumFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id]));
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
@ -1196,7 +1201,7 @@ describe(AlbumService.name, () => {
const asset = AssetFactory.create();
const album = AlbumFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValue(new Set());
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
@ -1210,7 +1215,7 @@ describe(AlbumService.name, () => {
const asset = AssetFactory.create();
const album = AlbumFactory.create();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValue(new Set([asset.id]));
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset.id] })).resolves.toEqual([
@ -1224,7 +1229,7 @@ describe(AlbumService.name, () => {
const album = AlbumFactory.from({ albumThumbnailAssetId: asset1.id }).build();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset1.id]));
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.album.getAssetIds.mockResolvedValue(new Set([asset1.id, asset2.id]));
await expect(sut.removeAssets(AuthFactory.create(album.owner), album.id, { ids: [asset1.id] })).resolves.toEqual([

View File

@ -4,13 +4,12 @@ import {
NotFoundException,
UnauthorizedException,
} from '@nestjs/common';
import { Stats } from 'node:fs';
import { AssetFile } from 'src/database';
import { AssetMediaStatus, AssetRejectReason, AssetUploadAction } from 'src/dtos/asset-media-response.dto';
import { AssetMediaCreateDto, AssetMediaReplaceDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto';
import { AssetMediaCreateDto, AssetMediaSize, UploadFieldName } from 'src/dtos/asset-media.dto';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { AssetEditAction } from 'src/dtos/editing.dto';
import { AssetFileType, AssetStatus, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum';
import { AssetFileType, AssetType, AssetVisibility, CacheControl, JobName } from 'src/enum';
import { AuthRequest } from 'src/middleware/auth.guard';
import { AssetMediaService } from 'src/services/asset-media.service';
import { UploadBody } from 'src/types';
@ -22,6 +21,7 @@ import { AuthFactory } from 'test/factories/auth.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { fileStub } from 'test/fixtures/file.stub';
import { userStub } from 'test/fixtures/user.stub';
import { getForAsset } from 'test/mappers';
import { newTestService, ServiceMocks } from 'test/utils';
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');
@ -152,13 +152,6 @@ const createDto = Object.freeze({
duration: '0:00:00.000000',
}) as AssetMediaCreateDto;
const replaceDto = Object.freeze({
deviceAssetId: 'deviceAssetId',
deviceId: 'deviceId',
fileModifiedAt: new Date('2024-04-15T23:41:36.910Z'),
fileCreatedAt: new Date('2024-04-15T23:41:36.910Z'),
}) as AssetMediaReplaceDto;
const assetEntity = Object.freeze({
id: 'id_1',
ownerId: 'user_id_1',
@ -180,25 +173,6 @@ const assetEntity = Object.freeze({
livePhotoVideoId: null,
} as MapAsset);
const existingAsset = Object.freeze({
...assetEntity,
duration: null,
type: AssetType.Image,
checksum: Buffer.from('_getExistingAsset', 'utf8'),
libraryId: 'libraryId',
originalFileName: 'existing-filename.jpeg',
}) as MapAsset;
const sidecarAsset = Object.freeze({
...existingAsset,
checksum: Buffer.from('_getExistingAssetWithSideCar', 'utf8'),
}) as MapAsset;
const copiedAsset = Object.freeze({
id: 'copied-asset',
originalPath: 'copied-path',
}) as MapAsset;
describe(AssetMediaService.name, () => {
let sut: AssetMediaService;
let mocks: ServiceMocks;
@ -434,7 +408,7 @@ describe(AssetMediaService.name, () => {
.owner(authStub.user1.user)
.build();
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
mocks.asset.getById.mockResolvedValueOnce(motionAsset);
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
mocks.asset.create.mockResolvedValueOnce(asset);
await expect(
@ -451,7 +425,7 @@ describe(AssetMediaService.name, () => {
it('should hide the linked motion asset', async () => {
const motionAsset = AssetFactory.from({ type: AssetType.Video }).owner(authStub.user1.user).build();
const asset = AssetFactory.create();
mocks.asset.getById.mockResolvedValueOnce(motionAsset);
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
mocks.asset.create.mockResolvedValueOnce(asset);
await expect(
@ -470,7 +444,7 @@ describe(AssetMediaService.name, () => {
it('should handle a sidecar file', async () => {
const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build();
mocks.asset.getById.mockResolvedValueOnce(asset);
mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset));
mocks.asset.create.mockResolvedValueOnce(asset);
await expect(sut.uploadAsset(authStub.user1, createDto, fileStub.photo, fileStub.photoSidecar)).resolves.toEqual({
@ -776,177 +750,6 @@ describe(AssetMediaService.name, () => {
});
});
describe('replaceAsset', () => {
it('should fail the auth check when update photo does not exist', async () => {
await expect(sut.replaceAsset(authStub.user1, 'id', replaceDto, fileStub.photo)).rejects.toThrow(
'Not found or no asset.update access',
);
expect(mocks.asset.create).not.toHaveBeenCalled();
});
it('should fail if asset cannot be fetched', async () => {
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, fileStub.photo)).rejects.toThrow(
'Asset not found',
);
expect(mocks.asset.create).not.toHaveBeenCalled();
});
it('should update a photo with no sidecar to photo with no sidecar', async () => {
const updatedFile = fileStub.photo;
const updatedAsset = { ...existingAsset, ...updatedFile };
mocks.asset.getById.mockResolvedValueOnce(existingAsset);
mocks.asset.getById.mockResolvedValueOnce(updatedAsset);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([existingAsset.id]));
// this is the original file size
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
// this is for the clone call
mocks.asset.create.mockResolvedValue(copiedAsset);
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
status: AssetMediaStatus.REPLACED,
id: 'copied-asset',
});
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
id: existingAsset.id,
originalFileName: 'photo1.jpeg',
originalPath: 'fake_path/photo1.jpeg',
}),
);
expect(mocks.asset.create).toHaveBeenCalledWith(
expect.objectContaining({
originalFileName: 'existing-filename.jpeg',
originalPath: 'fake_path/asset_1.jpeg',
}),
);
expect(mocks.asset.deleteFile).toHaveBeenCalledWith(
expect.objectContaining({
assetId: existingAsset.id,
type: AssetFileType.Sidecar,
}),
);
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
deletedAt: expect.any(Date),
status: AssetStatus.Trashed,
});
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(mocks.storage.utimes).toHaveBeenCalledWith(
updatedFile.originalPath,
expect.any(Date),
new Date(replaceDto.fileModifiedAt),
);
});
it('should update a photo with sidecar to photo with sidecar', async () => {
const updatedFile = fileStub.photo;
const sidecarFile = fileStub.photoSidecar;
const updatedAsset = { ...sidecarAsset, ...updatedFile };
mocks.asset.getById.mockResolvedValueOnce(existingAsset);
mocks.asset.getById.mockResolvedValueOnce(updatedAsset);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
// this is the original file size
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
// this is for the clone call
mocks.asset.create.mockResolvedValue(copiedAsset);
await expect(
sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile, sidecarFile),
).resolves.toEqual({
status: AssetMediaStatus.REPLACED,
id: 'copied-asset',
});
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
deletedAt: expect.any(Date),
status: AssetStatus.Trashed,
});
expect(mocks.asset.upsertFile).toHaveBeenCalledWith(
expect.objectContaining({
assetId: existingAsset.id,
path: sidecarFile.originalPath,
type: AssetFileType.Sidecar,
}),
);
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(mocks.storage.utimes).toHaveBeenCalledWith(
updatedFile.originalPath,
expect.any(Date),
new Date(replaceDto.fileModifiedAt),
);
});
it('should update a photo with a sidecar to photo with no sidecar', async () => {
const updatedFile = fileStub.photo;
const updatedAsset = { ...sidecarAsset, ...updatedFile };
mocks.asset.getById.mockResolvedValueOnce(sidecarAsset);
mocks.asset.getById.mockResolvedValueOnce(updatedAsset);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
// this is the original file size
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
// this is for the copy call
mocks.asset.create.mockResolvedValue(copiedAsset);
await expect(sut.replaceAsset(authStub.user1, existingAsset.id, replaceDto, updatedFile)).resolves.toEqual({
status: AssetMediaStatus.REPLACED,
id: 'copied-asset',
});
expect(mocks.asset.updateAll).toHaveBeenCalledWith([copiedAsset.id], {
deletedAt: expect.any(Date),
status: AssetStatus.Trashed,
});
expect(mocks.asset.deleteFile).toHaveBeenCalledWith(
expect.objectContaining({
assetId: existingAsset.id,
type: AssetFileType.Sidecar,
}),
);
expect(mocks.user.updateUsage).toHaveBeenCalledWith(authStub.user1.user.id, updatedFile.size);
expect(mocks.storage.utimes).toHaveBeenCalledWith(
updatedFile.originalPath,
expect.any(Date),
new Date(replaceDto.fileModifiedAt),
);
});
it('should handle a photo with sidecar to duplicate photo ', async () => {
const updatedFile = fileStub.photo;
const error = new Error('unique key violation');
(error as any).constraint_name = ASSET_CHECKSUM_CONSTRAINT;
mocks.asset.update.mockRejectedValue(error);
mocks.asset.getById.mockResolvedValueOnce(sidecarAsset);
mocks.asset.getUploadAssetIdByChecksum.mockResolvedValue(sidecarAsset.id);
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([sidecarAsset.id]));
// this is the original file size
mocks.storage.stat.mockResolvedValue({ size: 0 } as Stats);
// this is for the clone call
mocks.asset.create.mockResolvedValue(copiedAsset);
await expect(sut.replaceAsset(authStub.user1, sidecarAsset.id, replaceDto, updatedFile)).resolves.toEqual({
status: AssetMediaStatus.DUPLICATE,
id: sidecarAsset.id,
});
expect(mocks.asset.create).not.toHaveBeenCalled();
expect(mocks.asset.updateAll).not.toHaveBeenCalled();
expect(mocks.asset.upsertFile).not.toHaveBeenCalled();
expect(mocks.asset.deleteFile).not.toHaveBeenCalled();
expect(mocks.job.queue).toHaveBeenCalledWith({
name: JobName.FileDelete,
data: { files: [updatedFile.originalPath, undefined] },
});
expect(mocks.user.updateUsage).not.toHaveBeenCalled();
});
});
describe('bulkUploadCheck', () => {
it('should accept hex and base64 checksums', async () => {
const file1 = Buffer.from('d2947b871a706081be194569951b7db246907957', 'hex');

View File

@ -8,6 +8,7 @@ import { AssetService } from 'src/services/asset.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { getForAsset, getForAssetDeletion, getForPartner } from 'test/mappers';
import { factory, newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
@ -71,7 +72,7 @@ describe(AssetService.name, () => {
describe('getRandom', () => {
it('should get own random assets', async () => {
mocks.partner.getAll.mockResolvedValue([]);
mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]);
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
await sut.getRandom(authStub.admin, 1);
@ -82,8 +83,8 @@ describe(AssetService.name, () => {
const partner = factory.partner({ inTimeline: false });
const auth = factory.auth({ user: { id: partner.sharedWithId } });
mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]);
mocks.partner.getAll.mockResolvedValue([partner]);
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
await sut.getRandom(auth, 1);
@ -94,8 +95,8 @@ describe(AssetService.name, () => {
const partner = factory.partner({ inTimeline: true });
const auth = factory.auth({ user: { id: partner.sharedWithId } });
mocks.asset.getRandom.mockResolvedValue([AssetFactory.create()]);
mocks.partner.getAll.mockResolvedValue([partner]);
mocks.asset.getRandom.mockResolvedValue([getForAsset(AssetFactory.create())]);
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
await sut.getRandom(auth, 1);
@ -107,7 +108,7 @@ describe(AssetService.name, () => {
it('should allow owner access', async () => {
const asset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getById.mockResolvedValue(asset);
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
await sut.get(authStub.admin, asset.id);
@ -121,7 +122,7 @@ describe(AssetService.name, () => {
it('should allow shared link access', async () => {
const asset = AssetFactory.create();
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getById.mockResolvedValue(asset);
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
await sut.get(authStub.adminSharedLink, asset.id);
@ -134,7 +135,7 @@ describe(AssetService.name, () => {
it('should strip metadata for shared link if exif is disabled', async () => {
const asset = AssetFactory.from().exif({ description: 'foo' }).build();
mocks.access.asset.checkSharedLinkAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getById.mockResolvedValue(asset);
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
const result = await sut.get(
{ ...authStub.adminSharedLink, sharedLink: { ...authStub.adminSharedLink.sharedLink!, showExif: false } },
@ -152,7 +153,7 @@ describe(AssetService.name, () => {
it('should allow partner sharing access', async () => {
const asset = AssetFactory.create();
mocks.access.asset.checkPartnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getById.mockResolvedValue(asset);
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
await sut.get(authStub.admin, asset.id);
@ -162,7 +163,7 @@ describe(AssetService.name, () => {
it('should allow shared album access', async () => {
const asset = AssetFactory.create();
mocks.access.asset.checkAlbumAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getById.mockResolvedValue(asset);
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
await sut.get(authStub.admin, asset.id);
@ -204,8 +205,8 @@ describe(AssetService.name, () => {
it('should update the asset', async () => {
const asset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getById.mockResolvedValue(asset);
mocks.asset.update.mockResolvedValue(asset);
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
mocks.asset.update.mockResolvedValue(getForAsset(asset));
await sut.update(authStub.admin, asset.id, { isFavorite: true });
@ -215,8 +216,8 @@ describe(AssetService.name, () => {
it('should update the exif description', async () => {
const asset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getById.mockResolvedValue(asset);
mocks.asset.update.mockResolvedValue(asset);
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
mocks.asset.update.mockResolvedValue(getForAsset(asset));
await sut.update(authStub.admin, asset.id, { description: 'Test description' });
@ -229,8 +230,8 @@ describe(AssetService.name, () => {
it('should update the exif rating', async () => {
const asset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getById.mockResolvedValueOnce(asset);
mocks.asset.update.mockResolvedValueOnce(asset);
mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset));
mocks.asset.update.mockResolvedValueOnce(getForAsset(asset));
await sut.update(authStub.admin, asset.id, { rating: 3 });
@ -274,7 +275,7 @@ describe(AssetService.name, () => {
const motionAsset = AssetFactory.from().owner(auth.user).build();
const asset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getById.mockResolvedValue(asset);
mocks.asset.getById.mockResolvedValue(getForAsset(asset));
await expect(
sut.update(authStub.admin, asset.id, {
@ -301,7 +302,7 @@ describe(AssetService.name, () => {
const motionAsset = AssetFactory.create({ type: AssetType.Video });
const asset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getById.mockResolvedValue(motionAsset);
mocks.asset.getById.mockResolvedValue(getForAsset(motionAsset));
await expect(
sut.update(auth, asset.id, {
@ -327,9 +328,9 @@ describe(AssetService.name, () => {
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Timeline });
const stillAsset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([stillAsset.id]));
mocks.asset.getById.mockResolvedValueOnce(motionAsset);
mocks.asset.getById.mockResolvedValueOnce(stillAsset);
mocks.asset.update.mockResolvedValue(stillAsset);
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
mocks.asset.getById.mockResolvedValueOnce(getForAsset(stillAsset));
mocks.asset.update.mockResolvedValue(getForAsset(stillAsset));
const auth = AuthFactory.from(motionAsset.owner).build();
await sut.update(auth, stillAsset.id, { livePhotoVideoId: motionAsset.id });
@ -354,9 +355,9 @@ describe(AssetService.name, () => {
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
const unlinkedAsset = AssetFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.asset.getById.mockResolvedValueOnce(asset);
mocks.asset.getById.mockResolvedValueOnce(motionAsset);
mocks.asset.update.mockResolvedValueOnce(unlinkedAsset);
mocks.asset.getById.mockResolvedValueOnce(getForAsset(asset));
mocks.asset.getById.mockResolvedValueOnce(getForAsset(motionAsset));
mocks.asset.update.mockResolvedValueOnce(getForAsset(unlinkedAsset));
await sut.update(auth, asset.id, { livePhotoVideoId: null });
@ -569,7 +570,7 @@ describe(AssetService.name, () => {
.file({ type: AssetFileType.Preview, isEdited: true })
.file({ type: AssetFileType.Thumbnail, isEdited: true })
.build();
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset));
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
@ -583,7 +584,7 @@ describe(AssetService.name, () => {
},
],
]);
expect(mocks.asset.remove).toHaveBeenCalledWith(asset);
expect(mocks.asset.remove).toHaveBeenCalledWith(getForAssetDeletion(asset));
});
it('should delete the entire stack if deleted asset was the primary asset and the stack would only contain one asset afterwards', async () => {
@ -591,11 +592,7 @@ describe(AssetService.name, () => {
.stack({}, (builder) => builder.asset())
.build();
mocks.stack.delete.mockResolvedValue();
mocks.assetJob.getForAssetDeletion.mockResolvedValue({
...asset,
// TODO the specific query filters out the primary asset from `stack.assets`. This should be in a mapper eventually
stack: { ...asset.stack!, assets: asset.stack!.assets.filter(({ id }) => id !== asset.stack!.primaryAssetId) },
});
mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset));
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
@ -605,7 +602,7 @@ describe(AssetService.name, () => {
it('should delete a live photo', async () => {
const motionAsset = AssetFactory.from({ type: AssetType.Video, visibility: AssetVisibility.Hidden }).build();
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset));
mocks.asset.getLivePhotoCount.mockResolvedValue(0);
await sut.handleAssetDeletion({
@ -622,7 +619,7 @@ describe(AssetService.name, () => {
it('should not delete a live motion part if it is being used by another asset', async () => {
const asset = AssetFactory.create({ livePhotoVideoId: newUuid() });
mocks.asset.getLivePhotoCount.mockResolvedValue(2);
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset));
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
@ -633,7 +630,7 @@ describe(AssetService.name, () => {
it('should update usage', async () => {
const asset = AssetFactory.from().exif({ fileSizeInByte: 5000 }).build();
mocks.assetJob.getForAssetDeletion.mockResolvedValue(asset);
mocks.assetJob.getForAssetDeletion.mockResolvedValue(getForAssetDeletion(asset));
await sut.handleAssetDeletion({ id: asset.id, deleteOnDisk: true });
expect(mocks.user.updateUsage).toHaveBeenCalledWith(asset.ownerId, -5000);
});

View File

@ -3,6 +3,7 @@ import { DuplicateService } from 'src/services/duplicate.service';
import { SearchService } from 'src/services/search.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { getForDuplicate } from 'test/mappers';
import { newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
import { beforeEach, vitest } from 'vitest';
@ -39,11 +40,11 @@ describe(SearchService.name, () => {
describe('getDuplicates', () => {
it('should get duplicates', async () => {
const asset = AssetFactory.create();
const asset = AssetFactory.from().exif().build();
mocks.duplicateRepository.getAll.mockResolvedValue([
{
duplicateId: 'duplicate-id',
assets: [asset, asset],
assets: [getForDuplicate(asset), getForDuplicate(asset)],
},
]);
await expect(sut.getDuplicates(authStub.admin)).resolves.toEqual([

View File

@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { OnJob } from 'src/decorators';
import { BulkIdsDto } from 'src/dtos/asset-ids.response.dto';
import { mapAsset } from 'src/dtos/asset-response.dto';
import { hydrateAsset, mapAsset } from 'src/dtos/asset-response.dto';
import { AuthDto } from 'src/dtos/auth.dto';
import { DuplicateResponseDto } from 'src/dtos/duplicate.dto';
import { AssetVisibility, JobName, JobStatus, QueueName } from 'src/enum';
@ -17,7 +17,7 @@ export class DuplicateService extends BaseService {
const duplicates = await this.duplicateRepository.getAll(auth.user.id);
return duplicates.map(({ duplicateId, assets }) => ({
duplicateId,
assets: assets.map((asset) => mapAsset(asset, { auth })),
assets: assets.map((asset) => mapAsset(hydrateAsset(asset), { auth })),
}));
}

View File

@ -186,8 +186,8 @@ export class JobService extends BaseService {
exifImageHeight: exif.exifImageHeight,
fileSizeInByte: exif.fileSizeInByte,
orientation: exif.orientation,
dateTimeOriginal: exif.dateTimeOriginal,
modifyDate: exif.modifyDate,
dateTimeOriginal: exif.dateTimeOriginal ? new Date(exif.dateTimeOriginal) : null,
modifyDate: exif.modifyDate ? new Date(exif.modifyDate) : null,
timeZone: exif.timeZone,
latitude: exif.latitude,
longitude: exif.longitude,

View File

@ -3,6 +3,7 @@ import { AlbumFactory } from 'test/factories/album.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { userStub } from 'test/fixtures/user.stub';
import { getForAlbum, getForPartner } from 'test/mappers';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@ -52,7 +53,7 @@ describe(MapService.name, () => {
state: asset.exifInfo.state,
country: asset.exifInfo.country,
};
mocks.partner.getAll.mockResolvedValue([partner]);
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
mocks.map.getMapMarkers.mockResolvedValue([marker]);
const markers = await sut.getMapMarkers(auth, { withPartners: true });
@ -81,8 +82,10 @@ describe(MapService.name, () => {
};
mocks.partner.getAll.mockResolvedValue([]);
mocks.map.getMapMarkers.mockResolvedValue([marker]);
mocks.album.getOwned.mockResolvedValue([AlbumFactory.create()]);
mocks.album.getShared.mockResolvedValue([AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()]);
mocks.album.getOwned.mockResolvedValue([getForAlbum(AlbumFactory.create())]);
mocks.album.getShared.mockResolvedValue([
getForAlbum(AlbumFactory.from().albumUser({ userId: userStub.user1.id }).build()),
]);
const markers = await sut.getMapMarkers(auth, { withSharedAlbums: true });

View File

@ -1,3 +1,4 @@
import { ShallowDehydrateObject } from 'kysely';
import { OutputInfo } from 'sharp';
import { SystemConfig } from 'src/config';
import { Exif } from 'src/database';
@ -27,6 +28,7 @@ import { PersonFactory } from 'test/factories/person.factory';
import { probeStub } from 'test/fixtures/media.stub';
import { personThumbnailStub } from 'test/fixtures/person.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { getForGenerateThumbnail } from 'test/mappers';
import { factory, newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
@ -367,8 +369,10 @@ describe(MediaService.name, () => {
});
it('should skip thumbnail generation if asset type is unknown', async () => {
const asset = AssetFactory.create({ type: 'foo' as AssetType });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
const asset = AssetFactory.from({ type: 'foo' as AssetType })
.exif()
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await expect(sut.handleGenerateThumbnails({ id: asset.id })).resolves.toBe(JobStatus.Skipped);
expect(mocks.media.probe).not.toHaveBeenCalled();
@ -377,17 +381,17 @@ describe(MediaService.name, () => {
});
it('should skip video thumbnail generation if no video stream', async () => {
const asset = AssetFactory.create({ type: AssetType.Video });
const asset = AssetFactory.from({ type: AssetType.Video }).exif().build();
mocks.media.probe.mockResolvedValue(probeStub.noVideoStreams);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await expect(sut.handleGenerateThumbnails({ id: asset.id })).rejects.toThrowError();
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
expect(mocks.asset.update).not.toHaveBeenCalledWith();
});
it('should skip invisible assets', async () => {
const asset = AssetFactory.create({ visibility: AssetVisibility.Hidden });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
const asset = AssetFactory.from({ visibility: AssetVisibility.Hidden }).exif().build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
expect(await sut.handleGenerateThumbnails({ id: asset.id })).toEqual(JobStatus.Skipped);
@ -398,7 +402,7 @@ describe(MediaService.name, () => {
it('should delete previous preview if different path', async () => {
const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).exif().build();
mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -415,7 +419,7 @@ describe(MediaService.name, () => {
.exif({ profileDescription: 'Adobe RGB', bitsPerSample: 14 })
.files([AssetFileType.Preview, AssetFileType.Thumbnail])
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
@ -490,9 +494,9 @@ describe(MediaService.name, () => {
});
it('should generate a thumbnail for a video', async () => {
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
@ -532,9 +536,9 @@ describe(MediaService.name, () => {
});
it('should tonemap thumbnail for hdr video', async () => {
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
expect(mocks.storage.mkdirSync).toHaveBeenCalledWith(expect.any(String));
@ -574,12 +578,12 @@ describe(MediaService.name, () => {
});
it('should always generate video thumbnail in one pass', async () => {
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR);
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { twoPass: true, maxBitrate: '5000k' },
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
@ -600,9 +604,9 @@ describe(MediaService.name, () => {
});
it('should not skip intra frames for MTS file', async () => {
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
mocks.media.probe.mockResolvedValue(probeStub.videoStreamMTS);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
@ -618,9 +622,9 @@ describe(MediaService.name, () => {
});
it('should override reserved color metadata', async () => {
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
mocks.media.probe.mockResolvedValue(probeStub.videoStreamReserved);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
@ -638,10 +642,10 @@ describe(MediaService.name, () => {
});
it('should use scaling divisible by 2 even when using quick sync', async () => {
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
mocks.media.probe.mockResolvedValue(probeStub.videoStream2160p);
mocks.systemMetadata.get.mockResolvedValue({ ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
expect(mocks.media.transcode).toHaveBeenCalledWith(
@ -658,7 +662,7 @@ describe(MediaService.name, () => {
it.each(Object.values(ImageFormat))('should generate an image preview in %s format', async (format) => {
const asset = AssetFactory.from().exif().build();
mocks.systemMetadata.get.mockResolvedValue({ image: { preview: { format } } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
const previewPath = `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_preview.${format}`;
@ -708,7 +712,7 @@ describe(MediaService.name, () => {
it.each(Object.values(ImageFormat))('should generate an image thumbnail in %s format', async (format) => {
const asset = AssetFactory.from().exif().build();
mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format } } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
const previewPath = `/data/thumbs/${asset.ownerId}/${asset.id.slice(0, 2)}/${asset.id.slice(2, 4)}/${asset.id}_preview.jpeg`;
@ -760,7 +764,7 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
image: { preview: { progressive: true }, thumbnail: { progressive: false } },
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -799,7 +803,7 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({
image: { preview: { progressive: false }, thumbnail: { format: ImageFormat.Jpeg, progressive: true } },
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -834,12 +838,12 @@ describe(MediaService.name, () => {
});
it('should never set isProgressive for videos', async () => {
const asset = AssetFactory.create({ type: AssetType.Video, originalPath: '/original/path.ext' });
const asset = AssetFactory.from({ type: AssetType.Video, originalPath: '/original/path.ext' }).exif().build();
mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR);
mocks.systemMetadata.get.mockResolvedValue({
image: { preview: { progressive: true }, thumbnail: { progressive: true } },
});
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -860,7 +864,7 @@ describe(MediaService.name, () => {
it('should delete previous thumbnail if different path', async () => {
const asset = AssetFactory.from().exif().file({ type: AssetFileType.Preview }).build();
mocks.systemMetadata.get.mockResolvedValue({ image: { thumbnail: { format: ImageFormat.Webp } } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -879,7 +883,7 @@ describe(MediaService.name, () => {
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -896,7 +900,7 @@ describe(MediaService.name, () => {
.exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined })
.build();
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -910,7 +914,7 @@ describe(MediaService.name, () => {
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -925,7 +929,7 @@ describe(MediaService.name, () => {
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageMetadata.mockResolvedValue({ width: 1000, height: 1000, isTransparent: false });
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -941,7 +945,7 @@ describe(MediaService.name, () => {
.exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined })
.build();
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: true } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -958,7 +962,7 @@ describe(MediaService.name, () => {
.exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined })
.build();
mocks.systemMetadata.get.mockResolvedValue({ image: { extractEmbedded: false } });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -977,7 +981,7 @@ describe(MediaService.name, () => {
.exif({ fileSizeInByte: 5000, profileDescription: 'Adobe RGB', bitsPerSample: 14, orientation: undefined })
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -1018,7 +1022,7 @@ describe(MediaService.name, () => {
});
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -1056,7 +1060,7 @@ describe(MediaService.name, () => {
});
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jxl });
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -1104,7 +1108,7 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true }, extractEmbedded: false } });
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -1156,7 +1160,7 @@ describe(MediaService.name, () => {
bitsPerSample: 14,
})
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -1187,7 +1191,7 @@ describe(MediaService.name, () => {
mocks.systemMetadata.get.mockResolvedValue({ image: { fullsize: { enabled: true } } });
mocks.media.extract.mockResolvedValue({ buffer: extractedBuffer, format: RawExtractedFormat.Jpeg });
mocks.media.getImageMetadata.mockResolvedValue({ width: 3840, height: 2160, isTransparent: false });
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -1219,7 +1223,7 @@ describe(MediaService.name, () => {
})
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -1264,7 +1268,7 @@ describe(MediaService.name, () => {
bitsPerSample: 14,
})
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -1303,7 +1307,7 @@ describe(MediaService.name, () => {
bitsPerSample: 14,
})
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await sut.handleGenerateThumbnails({ id: asset.id });
@ -1338,7 +1342,7 @@ describe(MediaService.name, () => {
it('should skip videos', async () => {
const asset = AssetFactory.from({ type: AssetType.Video }).exif().build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
await expect(sut.handleAssetEditThumbnailGeneration({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.media.generateThumbnail).not.toHaveBeenCalled();
@ -1355,7 +1359,7 @@ describe(MediaService.name, () => {
])
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
mocks.person.getFaces.mockResolvedValue([]);
@ -1377,7 +1381,7 @@ describe(MediaService.name, () => {
.exif()
.edit({ action: AssetEditAction.Crop, parameters: { height: 1152, width: 1512, x: 216, y: 1512 } })
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.person.getFaces.mockResolvedValue([]);
mocks.ocr.getByAssetId.mockResolvedValue([]);
@ -1405,7 +1409,7 @@ describe(MediaService.name, () => {
{ type: AssetFileType.FullSize, path: 'edited3.jpg', isEdited: true },
])
.build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
const status = await sut.handleAssetEditThumbnailGeneration({ id: asset.id });
@ -1423,7 +1427,7 @@ describe(MediaService.name, () => {
it('should generate all 3 edited files if an asset has edits', async () => {
const asset = AssetFactory.from().exif().edit().build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.person.getFaces.mockResolvedValue([]);
mocks.ocr.getByAssetId.mockResolvedValue([]);
@ -1449,7 +1453,7 @@ describe(MediaService.name, () => {
it('should generate the original thumbhash if no edits exist', async () => {
const asset = AssetFactory.from().exif().build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
mocks.media.generateThumbhash.mockResolvedValue(factory.buffer());
await sut.handleAssetEditThumbnailGeneration({ id: asset.id, source: 'upload' });
@ -1459,7 +1463,7 @@ describe(MediaService.name, () => {
it('should apply thumbhash if job source is edit and edits exist', async () => {
const asset = AssetFactory.from().exif().edit().build();
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(asset);
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(getForGenerateThumbnail(asset));
const thumbhashBuffer = factory.buffer();
mocks.media.generateThumbhash.mockResolvedValue(thumbhashBuffer);
mocks.person.getFaces.mockResolvedValue([]);
@ -3553,15 +3557,15 @@ describe(MediaService.name, () => {
describe('isSRGB', () => {
it('should return true for srgb colorspace', () => {
expect(sut.isSRGB({ colorspace: 'sRGB' } as Exif)).toEqual(true);
expect(sut.isSRGB({ colorspace: 'sRGB' } as ShallowDehydrateObject<Exif>)).toEqual(true);
});
it('should return true for srgb profile description', () => {
expect(sut.isSRGB({ profileDescription: 'sRGB v1.31' } as Exif)).toEqual(true);
expect(sut.isSRGB({ profileDescription: 'sRGB v1.31' } as ShallowDehydrateObject<Exif>)).toEqual(true);
});
it('should return true for 8-bit image with no colorspace metadata', () => {
expect(sut.isSRGB({ bitsPerSample: 8 } as Exif)).toEqual(true);
expect(sut.isSRGB({ bitsPerSample: 8 } as ShallowDehydrateObject<Exif>)).toEqual(true);
});
it('should return true for image with no colorspace or bit depth metadata', () => {
@ -3569,23 +3573,25 @@ describe(MediaService.name, () => {
});
it('should return false for non-srgb colorspace', () => {
expect(sut.isSRGB({ colorspace: 'Adobe RGB' } as Exif)).toEqual(false);
expect(sut.isSRGB({ colorspace: 'Adobe RGB' } as ShallowDehydrateObject<Exif>)).toEqual(false);
});
it('should return false for non-srgb profile description', () => {
expect(sut.isSRGB({ profileDescription: 'sP3C' } as Exif)).toEqual(false);
expect(sut.isSRGB({ profileDescription: 'sP3C' } as ShallowDehydrateObject<Exif>)).toEqual(false);
});
it('should return false for 16-bit image with no colorspace metadata', () => {
expect(sut.isSRGB({ bitsPerSample: 16 } as Exif)).toEqual(false);
expect(sut.isSRGB({ bitsPerSample: 16 } as ShallowDehydrateObject<Exif>)).toEqual(false);
});
it('should return true for 16-bit image with sRGB colorspace', () => {
expect(sut.isSRGB({ colorspace: 'sRGB', bitsPerSample: 16 } as Exif)).toEqual(true);
expect(sut.isSRGB({ colorspace: 'sRGB', bitsPerSample: 16 } as ShallowDehydrateObject<Exif>)).toEqual(true);
});
it('should return true for 16-bit image with sRGB profile', () => {
expect(sut.isSRGB({ profileDescription: 'sRGB', bitsPerSample: 16 } as Exif)).toEqual(true);
expect(sut.isSRGB({ profileDescription: 'sRGB', bitsPerSample: 16 } as ShallowDehydrateObject<Exif>)).toEqual(
true,
);
});
});

View File

@ -2,7 +2,7 @@ import { Injectable } from '@nestjs/common';
import { SystemConfig } from 'src/config';
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
import { AssetFile, Exif } from 'src/database';
import { AssetFile } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
import { SystemConfigFFmpegDto } from 'src/dtos/system-config.dto';
@ -258,7 +258,7 @@ export class MediaService extends BaseService {
return extracted;
}
private async decodeImage(thumbSource: string | Buffer, exifInfo: Exif, targetSize?: number) {
private async decodeImage(thumbSource: string | Buffer, exifInfo: ThumbnailAsset['exifInfo'], targetSize?: number) {
const { image } = await this.getConfig({ withCache: true });
const colorspace = this.isSRGB(exifInfo) ? Colorspace.Srgb : image.colorspace;
const decodeOptions: DecodeToBufferOptions = {
@ -753,7 +753,15 @@ export class MediaService extends BaseService {
return name !== VideoContainer.Mp4 && !ffmpegConfig.acceptedContainers.includes(name);
}
isSRGB({ colorspace, profileDescription, bitsPerSample }: Exif): boolean {
isSRGB({
colorspace,
profileDescription,
bitsPerSample,
}: {
colorspace: string | null;
profileDescription: string | null;
bitsPerSample: number | null;
}): boolean {
if (colorspace || profileDescription) {
return [colorspace, profileDescription].some((s) => s?.toLowerCase().includes('srgb'));
} else if (bitsPerSample) {

View File

@ -1,6 +1,9 @@
import { BadRequestException } from '@nestjs/common';
import { MemoryService } from 'src/services/memory.service';
import { OnThisDayData } from 'src/types';
import { AssetFactory } from 'test/factories/asset.factory';
import { MemoryFactory } from 'test/factories/memory.factory';
import { getForMemory } from 'test/mappers';
import { factory, newUuid, newUuids } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@ -27,11 +30,11 @@ describe(MemoryService.name, () => {
describe('search', () => {
it('should search memories', async () => {
const [userId] = newUuids();
const asset = factory.asset();
const memory1 = factory.memory({ ownerId: userId, assets: [asset] });
const memory2 = factory.memory({ ownerId: userId });
const asset = AssetFactory.create();
const memory1 = MemoryFactory.from({ ownerId: userId }).asset(asset).build();
const memory2 = MemoryFactory.create({ ownerId: userId });
mocks.memory.search.mockResolvedValue([memory1, memory2]);
mocks.memory.search.mockResolvedValue([getForMemory(memory1), getForMemory(memory2)]);
await expect(sut.search(factory.auth({ user: { id: userId } }), {})).resolves.toEqual(
expect.arrayContaining([
@ -64,9 +67,9 @@ describe(MemoryService.name, () => {
it('should get a memory by id', async () => {
const userId = newUuid();
const memory = factory.memory({ ownerId: userId });
const memory = MemoryFactory.create({ ownerId: userId });
mocks.memory.get.mockResolvedValue(memory);
mocks.memory.get.mockResolvedValue(getForMemory(memory));
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
await expect(sut.get(factory.auth({ user: { id: userId } }), memory.id)).resolves.toMatchObject({
@ -81,9 +84,9 @@ describe(MemoryService.name, () => {
describe('create', () => {
it('should skip assets the user does not have access to', async () => {
const [assetId, userId] = newUuids();
const memory = factory.memory({ ownerId: userId });
const memory = MemoryFactory.create({ ownerId: userId });
mocks.memory.create.mockResolvedValue(memory);
mocks.memory.create.mockResolvedValue(getForMemory(memory));
await expect(
sut.create(factory.auth({ user: { id: userId } }), {
@ -109,11 +112,11 @@ describe(MemoryService.name, () => {
it('should create a memory', async () => {
const [assetId, userId] = newUuids();
const asset = factory.asset({ id: assetId, ownerId: userId });
const memory = factory.memory({ assets: [asset] });
const asset = AssetFactory.create({ id: assetId, ownerId: userId });
const memory = MemoryFactory.from().asset(asset).build();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.memory.create.mockResolvedValue(memory);
mocks.memory.create.mockResolvedValue(getForMemory(memory));
await expect(
sut.create(factory.auth({ user: { id: userId } }), {
@ -131,9 +134,9 @@ describe(MemoryService.name, () => {
});
it('should create a memory without assets', async () => {
const memory = factory.memory();
const memory = MemoryFactory.create();
mocks.memory.create.mockResolvedValue(memory);
mocks.memory.create.mockResolvedValue(getForMemory(memory));
await expect(
sut.create(factory.auth(), {
@ -155,10 +158,10 @@ describe(MemoryService.name, () => {
});
it('should update a memory', async () => {
const memory = factory.memory();
const memory = MemoryFactory.create();
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
mocks.memory.update.mockResolvedValue(memory);
mocks.memory.update.mockResolvedValue(getForMemory(memory));
await expect(sut.update(factory.auth(), memory.id, { isSaved: true })).resolves.toBeDefined();
@ -198,10 +201,10 @@ describe(MemoryService.name, () => {
it('should require asset access', async () => {
const assetId = newUuid();
const memory = factory.memory();
const memory = MemoryFactory.create();
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
mocks.memory.get.mockResolvedValue(memory);
mocks.memory.get.mockResolvedValue(getForMemory(memory));
mocks.memory.getAssetIds.mockResolvedValue(new Set());
await expect(sut.addAssets(factory.auth(), memory.id, { ids: [assetId] })).resolves.toEqual([
@ -212,11 +215,11 @@ describe(MemoryService.name, () => {
});
it('should skip assets already in the memory', async () => {
const asset = factory.asset();
const memory = factory.memory({ assets: [asset] });
const asset = AssetFactory.create();
const memory = MemoryFactory.from().asset(asset).build();
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
mocks.memory.get.mockResolvedValue(memory);
mocks.memory.get.mockResolvedValue(getForMemory(memory));
mocks.memory.getAssetIds.mockResolvedValue(new Set([asset.id]));
await expect(sut.addAssets(factory.auth(), memory.id, { ids: [asset.id] })).resolves.toEqual([
@ -228,12 +231,12 @@ describe(MemoryService.name, () => {
it('should add assets', async () => {
const assetId = newUuid();
const memory = factory.memory();
const memory = MemoryFactory.create();
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([assetId]));
mocks.memory.get.mockResolvedValue(memory);
mocks.memory.update.mockResolvedValue(memory);
mocks.memory.get.mockResolvedValue(getForMemory(memory));
mocks.memory.update.mockResolvedValue(getForMemory(memory));
mocks.memory.getAssetIds.mockResolvedValue(new Set());
mocks.memory.addAssetIds.mockResolvedValue();
@ -266,14 +269,14 @@ describe(MemoryService.name, () => {
});
it('should remove assets', async () => {
const memory = factory.memory();
const asset = factory.asset();
const memory = MemoryFactory.create();
const asset = AssetFactory.create();
mocks.access.memory.checkOwnerAccess.mockResolvedValue(new Set([memory.id]));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.memory.getAssetIds.mockResolvedValue(new Set([asset.id]));
mocks.memory.removeAssetIds.mockResolvedValue();
mocks.memory.update.mockResolvedValue(memory);
mocks.memory.update.mockResolvedValue(getForMemory(memory));
await expect(sut.removeAssets(factory.auth(), memory.id, { ids: [asset.id] })).resolves.toEqual([
{ id: asset.id, success: true },

View File

@ -19,6 +19,7 @@ import { AssetFactory } from 'test/factories/asset.factory';
import { PersonFactory } from 'test/factories/person.factory';
import { probeStub } from 'test/fixtures/media.stub';
import { tagStub } from 'test/fixtures/tag.stub';
import { getForMetadataExtraction, getForSidecarWrite } from 'test/mappers';
import { factory } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
@ -176,7 +177,7 @@ describe(MetadataService.name, () => {
const originalDate = new Date('2023-11-21T16:13:17.517Z');
const sidecarDate = new Date('2022-01-01T00:00:00.000Z');
const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).build();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ CreationDate: originalDate.toISOString() }, { CreationDate: sidecarDate.toISOString() });
await sut.handleMetadataExtraction({ id: asset.id });
@ -198,7 +199,7 @@ describe(MetadataService.name, () => {
const fileCreatedAt = new Date('2022-01-01T00:00:00.000Z');
const fileModifiedAt = new Date('2021-01-01T00:00:00.000Z');
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: fileModifiedAt,
@ -228,7 +229,7 @@ describe(MetadataService.name, () => {
const fileCreatedAt = new Date('2021-01-01T00:00:00.000Z');
const fileModifiedAt = new Date('2022-01-01T00:00:00.000Z');
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: fileModifiedAt,
@ -257,7 +258,7 @@ describe(MetadataService.name, () => {
it('should determine dateTimeOriginal regardless of the server time zone', async () => {
process.env.TZ = 'America/Los_Angeles';
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ DateTimeOriginal: '2022:01:01 00:00:00' });
await sut.handleMetadataExtraction({ id: asset.id });
@ -277,7 +278,7 @@ describe(MetadataService.name, () => {
it('should handle lists of numbers', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: asset.fileModifiedAt,
@ -305,7 +306,7 @@ describe(MetadataService.name, () => {
it('should not delete latituide and longitude without reverse geocode', async () => {
// regression test for issue 17511
const asset = AssetFactory.from().exif().build();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: false } });
mocks.storage.stat.mockResolvedValue({
size: 123_456,
@ -337,7 +338,7 @@ describe(MetadataService.name, () => {
it('should apply reverse geocoding', async () => {
const asset = AssetFactory.from().exif({ latitude: 10, longitude: 20 }).build();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.systemMetadata.get.mockResolvedValue({ reverseGeocoding: { enabled: true } });
mocks.map.reverseGeocode.mockResolvedValue({ city: 'City', state: 'State', country: 'Country' });
mocks.storage.stat.mockResolvedValue({
@ -367,7 +368,7 @@ describe(MetadataService.name, () => {
it('should discard latitude and longitude on null island', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({
GPSLatitude: 0,
GPSLongitude: 0,
@ -383,7 +384,7 @@ describe(MetadataService.name, () => {
it('should extract tags from TagsList', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] });
mockReadTags({ TagsList: ['Parent'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@ -395,7 +396,7 @@ describe(MetadataService.name, () => {
it('should extract hierarchy from TagsList', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child'] });
mockReadTags({ TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
@ -417,7 +418,7 @@ describe(MetadataService.name, () => {
it('should extract tags from Keywords as a string', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] });
mockReadTags({ Keywords: 'Parent' });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@ -429,7 +430,7 @@ describe(MetadataService.name, () => {
it('should extract tags from Keywords as a list', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent'] });
mockReadTags({ Keywords: ['Parent'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@ -441,7 +442,7 @@ describe(MetadataService.name, () => {
it('should extract tags from Keywords as a list with a number', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent', '2024'] });
mockReadTags({ Keywords: ['Parent', 2024] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@ -454,7 +455,7 @@ describe(MetadataService.name, () => {
it('should extract hierarchal tags from Keywords', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child'] });
mockReadTags({ Keywords: 'Parent/Child' });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@ -474,7 +475,7 @@ describe(MetadataService.name, () => {
it('should ignore Keywords when TagsList is present', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'Child'] });
mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@ -495,7 +496,7 @@ describe(MetadataService.name, () => {
it('should extract hierarchy from HierarchicalSubject', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'TagA'] });
mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
@ -522,7 +523,7 @@ describe(MetadataService.name, () => {
it('should extract tags from HierarchicalSubject as a list with a number', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent', '2024'] });
mockReadTags({ HierarchicalSubject: ['Parent', 2024] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@ -535,7 +536,7 @@ describe(MetadataService.name, () => {
it('should extract ignore / characters in a HierarchicalSubject tag', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Mom|Dad'] });
mockReadTags({ HierarchicalSubject: ['Mom/Dad'] });
mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert);
@ -551,7 +552,7 @@ describe(MetadataService.name, () => {
it('should ignore HierarchicalSubject when TagsList is present', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.asset.getForMetadataExtractionTags.mockResolvedValue({ tags: ['Parent/Child', 'Parent2/Child2'] });
mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] });
mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert);
@ -572,7 +573,7 @@ describe(MetadataService.name, () => {
it('should remove existing tags', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({});
await sut.handleMetadataExtraction({ id: asset.id });
@ -582,7 +583,7 @@ describe(MetadataService.name, () => {
it('should not apply motion photos if asset is video', async () => {
const asset = AssetFactory.create({ type: AssetType.Video });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.media.probe.mockResolvedValue(probeStub.matroskaContainer);
await sut.handleMetadataExtraction({ id: asset.id });
@ -597,7 +598,7 @@ describe(MetadataService.name, () => {
it('should handle an invalid Directory Item', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({
MotionPhoto: 1,
ContainerDirectory: [{ Foo: 100 }],
@ -608,7 +609,7 @@ describe(MetadataService.name, () => {
it('should extract the correct video orientation', async () => {
const asset = AssetFactory.create({ type: AssetType.Video });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
mockReadTags({});
@ -624,7 +625,7 @@ describe(MetadataService.name, () => {
it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => {
const asset = AssetFactory.create();
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: asset.fileModifiedAt,
@ -686,7 +687,7 @@ describe(MetadataService.name, () => {
mtimeMs: asset.fileModifiedAt.valueOf(),
birthtimeMs: asset.fileCreatedAt.valueOf(),
} as Stats);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({
Directory: 'foo/bar/',
EmbeddedVideoFile: new BinaryField(0, ''),
@ -733,7 +734,7 @@ describe(MetadataService.name, () => {
it('should extract the motion photo video from the XMP directory entry ', async () => {
const asset = AssetFactory.create();
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.storage.stat.mockResolvedValue({
size: 123_456,
mtime: asset.fileModifiedAt,
@ -786,7 +787,7 @@ describe(MetadataService.name, () => {
it('should delete old motion photo video assets if they do not match what is extracted', async () => {
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({
Directory: 'foo/bar/',
MotionPhoto: 1,
@ -808,7 +809,7 @@ describe(MetadataService.name, () => {
it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => {
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({
Directory: 'foo/bar/',
MotionPhoto: 1,
@ -832,7 +833,7 @@ describe(MetadataService.name, () => {
it('should link and hide motion video asset to still asset if the hash of the extracted video matches an existing asset', async () => {
const motionAsset = AssetFactory.create({ type: AssetType.Video });
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({
Directory: 'foo/bar/',
MotionPhoto: 1,
@ -859,7 +860,7 @@ describe(MetadataService.name, () => {
it('should not update storage usage if motion photo is external', async () => {
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
const asset = AssetFactory.create({ isExternal: true });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({
Directory: 'foo/bar/',
MotionPhoto: 1,
@ -904,7 +905,7 @@ describe(MetadataService.name, () => {
Rating: 3,
};
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags(tags);
await sut.handleMetadataExtraction({ id: asset.id });
@ -969,7 +970,7 @@ describe(MetadataService.name, () => {
DateTimeOriginal: ExifDateTime.fromISO(someDate + '+00:00'),
zone: undefined,
};
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags(tags);
await sut.handleMetadataExtraction({ id: asset.id });
@ -984,7 +985,7 @@ describe(MetadataService.name, () => {
it('should extract duration', async () => {
const asset = AssetFactory.create({ type: AssetType.Video });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.media.probe.mockResolvedValue({
...probeStub.videoStreamH264,
format: {
@ -1007,7 +1008,7 @@ describe(MetadataService.name, () => {
it('should only extract duration for videos', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.media.probe.mockResolvedValue({
...probeStub.videoStreamH264,
format: {
@ -1029,7 +1030,7 @@ describe(MetadataService.name, () => {
it('should omit duration of zero', async () => {
const asset = AssetFactory.create({ type: AssetType.Video });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.media.probe.mockResolvedValue({
...probeStub.videoStreamH264,
format: {
@ -1052,7 +1053,7 @@ describe(MetadataService.name, () => {
it('should a handle duration of 1 week', async () => {
const asset = AssetFactory.create({ type: AssetType.Video });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.media.probe.mockResolvedValue({
...probeStub.videoStreamH264,
format: {
@ -1075,7 +1076,7 @@ describe(MetadataService.name, () => {
it('should use Duration from exif', async () => {
const asset = AssetFactory.create({ originalFileName: 'file.webp' });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ Duration: 123 }, {});
await sut.handleMetadataExtraction({ id: asset.id });
@ -1086,7 +1087,7 @@ describe(MetadataService.name, () => {
it('should prefer Duration from exif over sidecar', async () => {
const asset = AssetFactory.from({ originalFileName: 'file.webp' }).file({ type: AssetFileType.Sidecar }).build();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ Duration: 123 }, { Duration: 456 });
@ -1098,7 +1099,7 @@ describe(MetadataService.name, () => {
it('should ignore all Duration tags for definitely static images', async () => {
const asset = AssetFactory.from({ originalFileName: 'file.dng' }).build();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ Duration: 123 }, { Duration: 456 });
await sut.handleMetadataExtraction({ id: asset.id });
@ -1109,7 +1110,7 @@ describe(MetadataService.name, () => {
it('should ignore Duration from exif for videos', async () => {
const asset = AssetFactory.create({ type: AssetType.Video });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ Duration: 123 }, {});
mocks.media.probe.mockResolvedValue({
...probeStub.videoStreamH264,
@ -1127,7 +1128,7 @@ describe(MetadataService.name, () => {
it('should trim whitespace from description', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ Description: '\t \v \f \n \r' });
await sut.handleMetadataExtraction({ id: asset.id });
@ -1150,7 +1151,7 @@ describe(MetadataService.name, () => {
it('should handle a numeric description', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ Description: 1000 });
await sut.handleMetadataExtraction({ id: asset.id });
@ -1164,7 +1165,7 @@ describe(MetadataService.name, () => {
it('should skip importing metadata when the feature is disabled', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: false } } });
mockReadTags(makeFaceTags({ Name: 'Person 1' }));
await sut.handleMetadataExtraction({ id: asset.id });
@ -1173,7 +1174,7 @@ describe(MetadataService.name, () => {
it('should skip importing metadata face for assets without tags.RegionInfo', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags();
await sut.handleMetadataExtraction({ id: asset.id });
@ -1182,7 +1183,7 @@ describe(MetadataService.name, () => {
it('should skip importing faces without name', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(makeFaceTags());
mocks.person.getDistinctNames.mockResolvedValue([]);
@ -1195,7 +1196,7 @@ describe(MetadataService.name, () => {
it('should skip importing faces with empty name', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(makeFaceTags({ Name: '' }));
mocks.person.getDistinctNames.mockResolvedValue([]);
@ -1210,7 +1211,7 @@ describe(MetadataService.name, () => {
const asset = AssetFactory.create();
const person = PersonFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(makeFaceTags({ Name: person.name }));
mocks.person.getDistinctNames.mockResolvedValue([]);
@ -1252,7 +1253,7 @@ describe(MetadataService.name, () => {
const asset = AssetFactory.create();
const person = PersonFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(makeFaceTags({ Name: person.name }));
mocks.person.getDistinctNames.mockResolvedValue([{ id: person.id, name: person.name }]);
@ -1339,7 +1340,7 @@ describe(MetadataService.name, () => {
const asset = AssetFactory.create();
const person = PersonFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.systemMetadata.get.mockResolvedValue({ metadata: { faces: { import: true } } });
mockReadTags(makeFaceTags({ Name: person.name }, orientation));
mocks.person.getDistinctNames.mockResolvedValue([]);
@ -1383,7 +1384,7 @@ describe(MetadataService.name, () => {
it('should handle invalid modify date', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ ModifyDate: '00:00:00.000' });
await sut.handleMetadataExtraction({ id: asset.id });
@ -1397,7 +1398,7 @@ describe(MetadataService.name, () => {
it('should handle invalid rating value', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ Rating: 6 });
await sut.handleMetadataExtraction({ id: asset.id });
@ -1411,7 +1412,7 @@ describe(MetadataService.name, () => {
it('should handle valid rating value', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ Rating: 5 });
await sut.handleMetadataExtraction({ id: asset.id });
@ -1425,7 +1426,7 @@ describe(MetadataService.name, () => {
it('should handle 0 as unrated -> null', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ Rating: 0 });
await sut.handleMetadataExtraction({ id: asset.id });
@ -1439,7 +1440,7 @@ describe(MetadataService.name, () => {
it('should handle valid negative rating value', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ Rating: -1 });
await sut.handleMetadataExtraction({ id: asset.id });
@ -1453,7 +1454,7 @@ describe(MetadataService.name, () => {
it('should handle livePhotoCID not set', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
await sut.handleMetadataExtraction({ id: asset.id });
@ -1468,7 +1469,7 @@ describe(MetadataService.name, () => {
it('should handle not finding a match', async () => {
const asset = AssetFactory.create({ type: AssetType.Video });
mocks.media.probe.mockResolvedValue(probeStub.videoStreamVertical2160p);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ ContentIdentifier: 'CID' });
await sut.handleMetadataExtraction({ id: asset.id });
@ -1490,7 +1491,7 @@ describe(MetadataService.name, () => {
it('should link photo and video', async () => {
const motionAsset = AssetFactory.create({ type: AssetType.Video });
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.asset.findLivePhotoMatch.mockResolvedValue(motionAsset);
mockReadTags({ ContentIdentifier: 'CID' });
@ -1518,7 +1519,7 @@ describe(MetadataService.name, () => {
it('should notify clients on live photo link', async () => {
const motionAsset = AssetFactory.create({ type: AssetType.Video });
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.asset.findLivePhotoMatch.mockResolvedValue(motionAsset);
mockReadTags({ ContentIdentifier: 'CID' });
@ -1533,7 +1534,7 @@ describe(MetadataService.name, () => {
it('should search by libraryId', async () => {
const motionAsset = AssetFactory.create({ type: AssetType.Video, libraryId: 'library-id' });
const asset = AssetFactory.create({ libraryId: 'library-id' });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.asset.findLivePhotoMatch.mockResolvedValue(motionAsset);
mockReadTags({ ContentIdentifier: 'CID' });
@ -1570,7 +1571,7 @@ describe(MetadataService.name, () => {
{ exif: { AndroidMake: '1', AndroidModel: '2' }, expected: { make: '1', model: '2' } },
])('should read camera make and model $exif -> $expected', async ({ exif, expected }) => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags(exif);
await sut.handleMetadataExtraction({ id: asset.id });
@ -1595,7 +1596,7 @@ describe(MetadataService.name, () => {
{ exif: { LensID: '' }, expected: null },
])('should read camera lens information $exif -> $expected', async ({ exif, expected }) => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags(exif);
await sut.handleMetadataExtraction({ id: asset.id });
@ -1609,7 +1610,7 @@ describe(MetadataService.name, () => {
it('should properly set width/height for normal images', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ ImageWidth: 1000, ImageHeight: 2000 });
await sut.handleMetadataExtraction({ id: asset.id });
@ -1623,7 +1624,7 @@ describe(MetadataService.name, () => {
it('should properly swap asset width/height for rotated images', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ ImageWidth: 1000, ImageHeight: 2000, Orientation: 6 });
await sut.handleMetadataExtraction({ id: asset.id });
@ -1637,7 +1638,7 @@ describe(MetadataService.name, () => {
it('should not overwrite existing width/height if they already exist', async () => {
const asset = AssetFactory.create({ width: 1920, height: 1080 });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(asset);
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({ ImageWidth: 1280, ImageHeight: 720 });
await sut.handleMetadataExtraction({ id: asset.id });
@ -1754,17 +1755,20 @@ describe(MetadataService.name, () => {
it('should skip jobs with no metadata', async () => {
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([]);
const asset = factory.jobAssets.sidecarWrite();
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).exif().build();
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(getForSidecarWrite(asset));
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Skipped);
expect(mocks.metadata.writeTags).not.toHaveBeenCalled();
});
it('should write tags', async () => {
const asset = factory.jobAssets.sidecarWrite();
const description = 'this is a description';
const gps = 12;
const date = '2023-11-21T22:56:12.196-06:00';
const asset = AssetFactory.from()
.file({ type: AssetFileType.Sidecar })
.exif({ description, dateTimeOriginal: new Date(date), latitude: gps, longitude: gps })
.build();
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue([
'description',
@ -1773,7 +1777,7 @@ describe(MetadataService.name, () => {
'dateTimeOriginal',
'timeZone',
]);
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(getForSidecarWrite(asset));
await expect(
sut.handleSidecarWrite({
id: asset.id,
@ -1796,22 +1800,22 @@ describe(MetadataService.name, () => {
});
it('should write rating', async () => {
const asset = factory.jobAssets.sidecarWrite();
const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).exif().build();
asset.exifInfo.rating = 4;
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']);
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(getForSidecarWrite(asset));
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Rating: 4 });
expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, ['rating']);
});
it('should write null rating as 0', async () => {
const asset = factory.jobAssets.sidecarWrite();
const asset = AssetFactory.from().file({ type: AssetFileType.Sidecar }).exif().build();
asset.exifInfo.rating = null;
mocks.assetJob.getLockedPropertiesForMetadataExtraction.mockResolvedValue(['rating']);
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(asset);
mocks.assetJob.getForSidecarWriteJob.mockResolvedValue(getForSidecarWrite(asset));
await expect(sut.handleSidecarWrite({ id: asset.id })).resolves.toBe(JobStatus.Success);
expect(mocks.metadata.writeTags).toHaveBeenCalledWith(asset.files[0].path, { Rating: 0 });
expect(mocks.asset.unlockProperties).toHaveBeenCalledWith(asset.id, ['rating']);

View File

@ -8,7 +8,7 @@ import { constants } from 'node:fs/promises';
import { join, parse } from 'node:path';
import { JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core';
import { Asset, AssetFace, AssetFile } from 'src/database';
import { Asset, AssetFile } from 'src/database';
import { OnEvent, OnJob } from 'src/decorators';
import {
AssetFileType,
@ -829,7 +829,7 @@ export class MetadataService extends BaseService {
}
private async applyTaggedFaces(
asset: { id: string; ownerId: string; faces: AssetFace[]; originalPath: string },
asset: { id: string; ownerId: string; faces: { id: string; sourceType: SourceType }[]; originalPath: string },
tags: ImmichTags,
) {
if (!tags.RegionInfo?.AppliedToDimensions || tags.RegionInfo.RegionList.length === 0) {

View File

@ -10,6 +10,7 @@ import { AssetFactory } from 'test/factories/asset.factory';
import { UserFactory } from 'test/factories/user.factory';
import { notificationStub } from 'test/fixtures/notification.stub';
import { userStub } from 'test/fixtures/user.stub';
import { getForAlbum } from 'test/mappers';
import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@ -269,14 +270,14 @@ describe(NotificationService.name, () => {
});
it('should skip if recipient could not be found', async () => {
mocks.album.getById.mockResolvedValue(AlbumFactory.create());
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create()));
await expect(sut.handleAlbumInvite({ id: '', recipientId: '' })).resolves.toBe(JobStatus.Skipped);
expect(mocks.job.queue).not.toHaveBeenCalled();
});
it('should skip if the recipient has email notifications disabled', async () => {
mocks.album.getById.mockResolvedValue(AlbumFactory.create());
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create()));
mocks.user.get.mockResolvedValue({
...userStub.user1,
metadata: [
@ -292,7 +293,7 @@ describe(NotificationService.name, () => {
});
it('should skip if the recipient has email notifications for album invite disabled', async () => {
mocks.album.getById.mockResolvedValue(AlbumFactory.create());
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create()));
mocks.user.get.mockResolvedValue({
...userStub.user1,
metadata: [
@ -308,7 +309,7 @@ describe(NotificationService.name, () => {
});
it('should send invite email', async () => {
mocks.album.getById.mockResolvedValue(AlbumFactory.create());
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create()));
mocks.user.get.mockResolvedValue({
...userStub.user1,
metadata: [
@ -331,7 +332,7 @@ describe(NotificationService.name, () => {
it('should send invite email without album thumbnail if thumbnail asset does not exist', async () => {
const album = AlbumFactory.create({ albumThumbnailAssetId: newUuid() });
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValue({
...userStub.user1,
metadata: [
@ -363,7 +364,7 @@ describe(NotificationService.name, () => {
it('should send invite email with album thumbnail as jpeg', async () => {
const assetFile = AssetFileFactory.create({ type: AssetFileType.Thumbnail });
const album = AlbumFactory.create({ albumThumbnailAssetId: assetFile.assetId });
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValue({
...userStub.user1,
metadata: [
@ -394,8 +395,10 @@ describe(NotificationService.name, () => {
it('should send invite email with album thumbnail and arbitrary extension', async () => {
const asset = AssetFactory.from().file({ type: AssetFileType.Thumbnail }).build();
const album = AlbumFactory.from({ albumThumbnailAssetId: asset.id }).asset(asset).build();
mocks.album.getById.mockResolvedValue(album);
const album = AlbumFactory.from({ albumThumbnailAssetId: asset.id })
.asset(asset, (builder) => builder.exif())
.build();
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValue({
...userStub.user1,
metadata: [
@ -432,7 +435,7 @@ describe(NotificationService.name, () => {
});
it('should skip if owner could not be found', async () => {
mocks.album.getById.mockResolvedValue(AlbumFactory.create({ ownerId: 'non-existent' }));
mocks.album.getById.mockResolvedValue(getForAlbum(AlbumFactory.create({ ownerId: 'non-existent' })));
await expect(sut.handleAlbumUpdate({ id: '', recipientId: '1' })).resolves.toBe(JobStatus.Skipped);
expect(mocks.systemMetadata.get).not.toHaveBeenCalled();
@ -440,7 +443,7 @@ describe(NotificationService.name, () => {
it('should skip recipient that could not be looked up', async () => {
const album = AlbumFactory.from().albumUser({ userId: 'non-existent' }).build();
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValueOnce(album.owner);
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
@ -459,7 +462,7 @@ describe(NotificationService.name, () => {
})
.build();
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValue(user);
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
@ -478,7 +481,7 @@ describe(NotificationService.name, () => {
})
.build();
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValue(user);
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });
@ -492,7 +495,7 @@ describe(NotificationService.name, () => {
it('should send email', async () => {
const user = UserFactory.create();
const album = AlbumFactory.from().albumUser({ userId: user.id }).build();
mocks.album.getById.mockResolvedValue(album);
mocks.album.getById.mockResolvedValue(getForAlbum(album));
mocks.user.get.mockResolvedValue(user);
mocks.notification.create.mockResolvedValue(notificationStub.albumEvent);
mocks.email.renderEmail.mockResolvedValue({ html: '', text: '' });

View File

@ -1,6 +1,8 @@
import { BadRequestException } from '@nestjs/common';
import { PartnerDirection } from 'src/repositories/partner.repository';
import { PartnerService } from 'src/services/partner.service';
import { UserFactory } from 'test/factories/user.factory';
import { getDehydratedUser, getForPartner } from 'test/mappers';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@ -18,26 +20,38 @@ describe(PartnerService.name, () => {
describe('search', () => {
it("should return a list of partners with whom I've shared my library", async () => {
const user1 = factory.user();
const user2 = factory.user();
const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 });
const sharedWithUser1 = factory.partner({ sharedBy: user2, sharedWith: user1 });
const user1 = UserFactory.create();
const user2 = UserFactory.create();
const sharedWithUser2 = factory.partner({
sharedBy: getDehydratedUser(user1),
sharedWith: getDehydratedUser(user2),
});
const sharedWithUser1 = factory.partner({
sharedBy: getDehydratedUser(user2),
sharedWith: getDehydratedUser(user1),
});
const auth = factory.auth({ user: { id: user1.id } });
mocks.partner.getAll.mockResolvedValue([sharedWithUser1, sharedWithUser2]);
mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]);
await expect(sut.search(auth, { direction: PartnerDirection.SharedBy })).resolves.toBeDefined();
expect(mocks.partner.getAll).toHaveBeenCalledWith(user1.id);
});
it('should return a list of partners who have shared their libraries with me', async () => {
const user1 = factory.user();
const user2 = factory.user();
const sharedWithUser2 = factory.partner({ sharedBy: user1, sharedWith: user2 });
const sharedWithUser1 = factory.partner({ sharedBy: user2, sharedWith: user1 });
const user1 = UserFactory.create();
const user2 = UserFactory.create();
const sharedWithUser2 = factory.partner({
sharedBy: getDehydratedUser(user1),
sharedWith: getDehydratedUser(user2),
});
const sharedWithUser1 = factory.partner({
sharedBy: getDehydratedUser(user2),
sharedWith: getDehydratedUser(user1),
});
const auth = factory.auth({ user: { id: user1.id } });
mocks.partner.getAll.mockResolvedValue([sharedWithUser1, sharedWithUser2]);
mocks.partner.getAll.mockResolvedValue([getForPartner(sharedWithUser1), getForPartner(sharedWithUser2)]);
await expect(sut.search(auth, { direction: PartnerDirection.SharedWith })).resolves.toBeDefined();
expect(mocks.partner.getAll).toHaveBeenCalledWith(user1.id);
});
@ -45,13 +59,13 @@ describe(PartnerService.name, () => {
describe('create', () => {
it('should create a new partner', async () => {
const user1 = factory.user();
const user2 = factory.user();
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
const user1 = UserFactory.create();
const user2 = UserFactory.create();
const partner = factory.partner({ sharedBy: getDehydratedUser(user1), sharedWith: getDehydratedUser(user2) });
const auth = factory.auth({ user: { id: user1.id } });
mocks.partner.get.mockResolvedValue(void 0);
mocks.partner.create.mockResolvedValue(partner);
mocks.partner.create.mockResolvedValue(getForPartner(partner));
await expect(sut.create(auth, { sharedWithId: user2.id })).resolves.toBeDefined();
@ -62,12 +76,12 @@ describe(PartnerService.name, () => {
});
it('should throw an error when the partner already exists', async () => {
const user1 = factory.user();
const user2 = factory.user();
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
const user1 = UserFactory.create();
const user2 = UserFactory.create();
const partner = factory.partner({ sharedBy: getDehydratedUser(user1), sharedWith: getDehydratedUser(user2) });
const auth = factory.auth({ user: { id: user1.id } });
mocks.partner.get.mockResolvedValue(partner);
mocks.partner.get.mockResolvedValue(getForPartner(partner));
await expect(sut.create(auth, { sharedWithId: user2.id })).rejects.toBeInstanceOf(BadRequestException);
@ -77,12 +91,12 @@ describe(PartnerService.name, () => {
describe('remove', () => {
it('should remove a partner', async () => {
const user1 = factory.user();
const user2 = factory.user();
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
const user1 = UserFactory.create();
const user2 = UserFactory.create();
const partner = factory.partner({ sharedBy: getDehydratedUser(user1), sharedWith: getDehydratedUser(user2) });
const auth = factory.auth({ user: { id: user1.id } });
mocks.partner.get.mockResolvedValue(partner);
mocks.partner.get.mockResolvedValue(getForPartner(partner));
await sut.remove(auth, user2.id);
@ -110,13 +124,13 @@ describe(PartnerService.name, () => {
});
it('should update partner', async () => {
const user1 = factory.user();
const user2 = factory.user();
const partner = factory.partner({ sharedBy: user1, sharedWith: user2 });
const user1 = UserFactory.create();
const user2 = UserFactory.create();
const partner = factory.partner({ sharedBy: getDehydratedUser(user1), sharedWith: getDehydratedUser(user2) });
const auth = factory.auth({ user: { id: user1.id } });
mocks.access.partner.checkUpdateAccess.mockResolvedValue(new Set([user2.id]));
mocks.partner.update.mockResolvedValue(partner);
mocks.partner.update.mockResolvedValue(getForPartner(partner));
await expect(sut.update(auth, user2.id, { inTimeline: true })).resolves.toBeDefined();
expect(mocks.partner.update).toHaveBeenCalledWith(

View File

@ -49,9 +49,11 @@ export class PartnerService extends BaseService {
private mapPartner(partner: Partner, direction: PartnerDirection): PartnerResponseDto {
// this is opposite to return the non-me user of the "partner"
const user = mapUser(
direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy,
) as PartnerResponseDto;
const sharedUser = direction === PartnerDirection.SharedBy ? partner.sharedWith : partner.sharedBy;
const user = mapUser({
...sharedUser,
profileChangedAt: new Date(sharedUser.profileChangedAt),
});
return { ...user, inTimeline: partner.inTimeline };
}

View File

@ -12,7 +12,7 @@ import { PersonFactory } from 'test/factories/person.factory';
import { UserFactory } from 'test/factories/user.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { systemConfigStub } from 'test/fixtures/system-config.stub';
import { getAsDetectedFace, getForFacialRecognitionJob } from 'test/mappers';
import { getAsDetectedFace, getForAssetFace, getForDetectedFaces, getForFacialRecognitionJob } from 'test/mappers';
import { newDate, newUuid } from 'test/small.factory';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
@ -319,7 +319,7 @@ describe(PersonService.name, () => {
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.getFacesByIds.mockResolvedValue([getForAssetFace(face)]);
mocks.person.reassignFace.mockResolvedValue(1);
mocks.person.getRandomFace.mockResolvedValue(AssetFaceFactory.create());
mocks.person.refreshFaces.mockResolvedValue();
@ -353,15 +353,17 @@ describe(PersonService.name, () => {
const face = AssetFaceFactory.create();
const asset = AssetFactory.from({ id: face.assetId }).exif().build();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.person.getFaces.mockResolvedValue([face]);
mocks.person.getFaces.mockResolvedValue([getForAssetFace(face)]);
mocks.asset.getForFaces.mockResolvedValue({ edits: [], ...asset.exifInfo });
await expect(sut.getFacesById(auth, { id: face.assetId })).resolves.toStrictEqual([mapFaces(face, auth)]);
await expect(sut.getFacesById(auth, { id: face.assetId })).resolves.toStrictEqual([
mapFaces(getForAssetFace(face), auth),
]);
});
it('should reject if the user has not access to the asset', async () => {
const face = AssetFaceFactory.create();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set());
mocks.person.getFaces.mockResolvedValue([face]);
mocks.person.getFaces.mockResolvedValue([getForAssetFace(face)]);
await expect(sut.getFacesById(AuthFactory.create(), { id: face.assetId })).rejects.toBeInstanceOf(
BadRequestException,
);
@ -390,7 +392,7 @@ describe(PersonService.name, () => {
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.getFaceById.mockResolvedValue(getForAssetFace(face));
mocks.person.reassignFace.mockResolvedValue(1);
mocks.person.getById.mockResolvedValue(person);
await expect(sut.reassignFacesById(AuthFactory.create(), person.id, { id: face.id })).resolves.toEqual({
@ -412,7 +414,7 @@ describe(PersonService.name, () => {
const person = PersonFactory.create();
mocks.access.person.checkOwnerAccess.mockResolvedValue(new Set([person.id]));
mocks.person.getFaceById.mockResolvedValue(face);
mocks.person.getFaceById.mockResolvedValue(getForAssetFace(face));
mocks.person.reassignFace.mockResolvedValue(1);
mocks.person.getById.mockResolvedValue(person);
await expect(
@ -735,18 +737,18 @@ describe(PersonService.name, () => {
});
it('should skip when no resize path', async () => {
const asset = AssetFactory.create();
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
const asset = AssetFactory.from().exif().build();
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
await sut.handleDetectFaces({ id: asset.id });
expect(mocks.machineLearning.detectFaces).not.toHaveBeenCalled();
});
it('should handle no results', async () => {
const start = Date.now();
const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build();
const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).exif().build();
mocks.machineLearning.detectFaces.mockResolvedValue({ imageHeight: 500, imageWidth: 400, faces: [] });
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
await sut.handleDetectFaces({ id: asset.id });
expect(mocks.machineLearning.detectFaces).toHaveBeenCalledWith(
asset.files[0].path,
@ -764,12 +766,12 @@ describe(PersonService.name, () => {
});
it('should create a face with no person and queue recognition job', async () => {
const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).build();
const asset = AssetFactory.from().file({ type: AssetFileType.Preview }).exif().build();
const face = AssetFaceFactory.create({ assetId: asset.id });
mocks.crypto.randomUUID.mockReturnValue(face.id);
mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face));
mocks.search.searchFaces.mockResolvedValue([{ ...face, distance: 0.7 }]);
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
mocks.person.refreshFaces.mockResolvedValue();
await sut.handleDetectFaces({ id: asset.id });
@ -788,9 +790,9 @@ describe(PersonService.name, () => {
});
it('should delete an existing face not among the new detected faces', async () => {
const asset = AssetFactory.from().face().file({ type: AssetFileType.Preview }).build();
const asset = AssetFactory.from().face().file({ type: AssetFileType.Preview }).exif().build();
mocks.machineLearning.detectFaces.mockResolvedValue({ faces: [], imageHeight: 500, imageWidth: 400 });
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
await sut.handleDetectFaces({ id: asset.id });
@ -809,9 +811,9 @@ describe(PersonService.name, () => {
boundingBoxY1: 200,
boundingBoxY2: 300,
});
const asset = AssetFactory.from({ id: assetId }).face().file({ type: AssetFileType.Preview }).build();
const asset = AssetFactory.from({ id: assetId }).face().file({ type: AssetFileType.Preview }).exif().build();
mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face));
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
mocks.crypto.randomUUID.mockReturnValue(face.id);
mocks.person.refreshFaces.mockResolvedValue();
@ -832,9 +834,9 @@ describe(PersonService.name, () => {
it('should add embedding to matching metadata face', async () => {
const face = AssetFaceFactory.create({ sourceType: SourceType.Exif });
const asset = AssetFactory.from().face(face).file({ type: AssetFileType.Preview }).build();
const asset = AssetFactory.from().face(face).file({ type: AssetFileType.Preview }).exif().build();
mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face));
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
mocks.person.refreshFaces.mockResolvedValue();
await sut.handleDetectFaces({ id: asset.id });
@ -848,9 +850,9 @@ describe(PersonService.name, () => {
it('should not add embedding to non-matching metadata face', async () => {
const assetId = newUuid();
const face = AssetFaceFactory.create({ assetId, sourceType: SourceType.Exif });
const asset = AssetFactory.from({ id: assetId }).file({ type: AssetFileType.Preview }).build();
const asset = AssetFactory.from({ id: assetId }).file({ type: AssetFileType.Preview }).exif().build();
mocks.machineLearning.detectFaces.mockResolvedValue(getAsDetectedFace(face));
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(asset);
mocks.assetJob.getForDetectFacesJob.mockResolvedValue(getForDetectedFaces(asset));
mocks.crypto.randomUUID.mockReturnValue(face.id);
await sut.handleDetectFaces({ id: asset.id });
@ -1237,7 +1239,7 @@ describe(PersonService.name, () => {
const person = PersonFactory.create({ ownerId: user.id });
const face = AssetFaceFactory.from().person(person).build();
expect(mapFaces(face, auth)).toEqual({
expect(mapFaces(getForAssetFace(face), auth)).toEqual({
boundingBoxX1: 100,
boundingBoxX2: 200,
boundingBoxY1: 100,
@ -1251,11 +1253,13 @@ describe(PersonService.name, () => {
});
it('should not map person if person is null', () => {
expect(mapFaces(AssetFaceFactory.create(), AuthFactory.create()).person).toBeNull();
expect(mapFaces(getForAssetFace(AssetFaceFactory.create()), AuthFactory.create()).person).toBeNull();
});
it('should not map person if person does not match auth user id', () => {
expect(mapFaces(AssetFaceFactory.from().person().build(), AuthFactory.create()).person).toBeNull();
expect(
mapFaces(getForAssetFace(AssetFaceFactory.from().person().build()), AuthFactory.create()).person,
).toBeNull();
});
});
});

View File

@ -491,7 +491,7 @@ export class PersonService extends BaseService {
embedding: face.faceSearch.embedding,
maxDistance: machineLearning.facialRecognition.maxDistance,
numResults: machineLearning.facialRecognition.minFaces,
minBirthDate: face.asset.fileCreatedAt ?? undefined,
minBirthDate: new Date(face.asset.fileCreatedAt),
});
// `matches` also includes the face itself
@ -519,7 +519,7 @@ export class PersonService extends BaseService {
maxDistance: machineLearning.facialRecognition.maxDistance,
numResults: 1,
hasPerson: true,
minBirthDate: face.asset.fileCreatedAt ?? undefined,
minBirthDate: new Date(face.asset.fileCreatedAt),
});
if (matchWithPerson.length > 0) {

View File

@ -5,6 +5,7 @@ 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 { getForAsset } from 'test/mappers';
import { newTestService, ServiceMocks } from 'test/utils';
import { beforeEach, vitest } from 'vitest';
@ -74,7 +75,9 @@ describe(SearchService.name, () => {
items: [{ value: 'city', data: asset.id }],
});
mocks.asset.getByIdsWithAllRelationsButStacks.mockResolvedValue([asset as never]);
const expectedResponse = [{ fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(asset) }] }];
const expectedResponse = [
{ fieldName: 'exifInfo.city', items: [{ value: 'city', data: mapAsset(getForAsset(asset)) }] },
];
const result = await sut.getExploreData(auth);

View File

@ -1,12 +1,14 @@
import { BadRequestException, ForbiddenException, UnauthorizedException } from '@nestjs/common';
import { AssetIdErrorReason } from 'src/dtos/asset-ids.response.dto';
import { mapSharedLink } from 'src/dtos/shared-link.dto';
import { SharedLinkType } from 'src/enum';
import { SharedLinkService } from 'src/services/shared-link.service';
import { AlbumFactory } from 'test/factories/album.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { SharedLinkFactory } from 'test/factories/shared-link.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { sharedLinkResponseStub, sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { sharedLinkStub } from 'test/fixtures/shared-link.stub';
import { getForSharedLink } from 'test/mappers';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@ -24,11 +26,13 @@ describe(SharedLinkService.name, () => {
describe('getAll', () => {
it('should return all shared links for a user', async () => {
mocks.sharedLink.getAll.mockResolvedValue([sharedLinkStub.expired, sharedLinkStub.valid]);
await expect(sut.getAll(authStub.user1, {})).resolves.toEqual([
sharedLinkResponseStub.expired,
sharedLinkResponseStub.valid,
]);
const [sharedLink1, sharedLink2] = [SharedLinkFactory.create(), SharedLinkFactory.create()];
mocks.sharedLink.getAll.mockResolvedValue([getForSharedLink(sharedLink1), getForSharedLink(sharedLink2)]);
await expect(sut.getAll(authStub.user1, {})).resolves.toEqual(
[getForSharedLink(sharedLink1), getForSharedLink(sharedLink2)].map((link) =>
mapSharedLink(link, { stripAssetMetadata: false }),
),
);
expect(mocks.sharedLink.getAll).toHaveBeenCalledWith({ userId: authStub.user1.user.id });
});
});
@ -41,8 +45,11 @@ describe(SharedLinkService.name, () => {
it('should return the shared link for the public user', async () => {
const authDto = authStub.adminSharedLink;
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.getMine(authDto, [])).resolves.toEqual(sharedLinkResponseStub.valid);
const sharedLink = SharedLinkFactory.create();
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
await expect(sut.getMine(authDto, [])).resolves.toEqual(
mapSharedLink(getForSharedLink(sharedLink), { stripAssetMetadata: false }),
);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
});
@ -54,7 +61,13 @@ describe(SharedLinkService.name, () => {
allowUpload: true,
},
});
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.readonlyNoExif);
mocks.sharedLink.get.mockResolvedValue(
getForSharedLink(
SharedLinkFactory.from({ showExif: false })
.asset({}, (builder) => builder.exif())
.build(),
),
);
const response = await sut.getMine(authDto, []);
expect(response.assets[0]).toMatchObject({ hasMetadata: false });
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authDto.user.id, authDto.sharedLink?.id);
@ -68,7 +81,8 @@ describe(SharedLinkService.name, () => {
});
it('should accept a valid shared link auth token', async () => {
mocks.sharedLink.get.mockResolvedValue({ ...sharedLinkStub.individual, password: '123' });
const sharedLink = SharedLinkFactory.create({ password: '123' });
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
const secret = Buffer.from('auth-token-123');
mocks.crypto.hashSha256.mockReturnValue(secret);
await expect(sut.getMine(authStub.adminSharedLink, [secret.toString('base64')])).resolves.toBeDefined();
@ -90,9 +104,12 @@ describe(SharedLinkService.name, () => {
});
it('should get a shared link by id', async () => {
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
await expect(sut.get(authStub.user1, sharedLinkStub.valid.id)).resolves.toEqual(sharedLinkResponseStub.valid);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
const sharedLink = SharedLinkFactory.create();
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
await expect(sut.get(authStub.user1, sharedLink.id)).resolves.toEqual(
mapSharedLink(getForSharedLink(sharedLink), { stripAssetMetadata: true }),
);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLink.id);
});
});
@ -123,8 +140,9 @@ describe(SharedLinkService.name, () => {
it('should create an album shared link', async () => {
const album = AlbumFactory.from().asset().build();
const sharedLink = SharedLinkFactory.from().album(album).build();
mocks.access.album.checkOwnerAccess.mockResolvedValue(new Set([album.id]));
mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.valid);
mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink));
await sut.create(authStub.admin, { type: SharedLinkType.Album, albumId: album.id });
@ -145,8 +163,11 @@ describe(SharedLinkService.name, () => {
it('should create an individual shared link', async () => {
const asset = AssetFactory.create();
const sharedLink = SharedLinkFactory.from()
.asset(asset, (builder) => builder.exif())
.build();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual);
mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink));
await sut.create(authStub.admin, {
type: SharedLinkType.Individual,
@ -178,8 +199,11 @@ describe(SharedLinkService.name, () => {
it('should create a shared link with allowDownload set to false when showMetadata is false', async () => {
const asset = AssetFactory.create();
const sharedLink = SharedLinkFactory.from({ allowDownload: false })
.asset(asset, (builder) => builder.exif())
.build();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([asset.id]));
mocks.sharedLink.create.mockResolvedValue(sharedLinkStub.individual);
mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink));
await sut.create(authStub.admin, {
type: SharedLinkType.Individual,
@ -221,8 +245,9 @@ describe(SharedLinkService.name, () => {
});
it('should update a shared link', async () => {
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
mocks.sharedLink.update.mockResolvedValue(sharedLinkStub.valid);
const sharedLink = SharedLinkFactory.create();
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
mocks.sharedLink.update.mockResolvedValue(getForSharedLink(sharedLink));
await sut.update(authStub.user1, sharedLinkStub.valid.id, { allowDownload: false });
@ -247,19 +272,21 @@ describe(SharedLinkService.name, () => {
});
it('should remove a key', async () => {
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
const sharedLink = SharedLinkFactory.create();
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
mocks.sharedLink.remove.mockResolvedValue();
await sut.remove(authStub.user1, sharedLinkStub.valid.id);
await sut.remove(authStub.user1, sharedLink.id);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLinkStub.valid.id);
expect(mocks.sharedLink.remove).toHaveBeenCalledWith(sharedLinkStub.valid.id);
expect(mocks.sharedLink.get).toHaveBeenCalledWith(authStub.user1.user.id, sharedLink.id);
expect(mocks.sharedLink.remove).toHaveBeenCalledWith(sharedLink.id);
});
});
describe('addAssets', () => {
it('should not work on album shared links', async () => {
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
const sharedLink = SharedLinkFactory.from().album().build();
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
await expect(sut.addAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
BadRequestException,
@ -268,11 +295,13 @@ describe(SharedLinkService.name, () => {
it('should add assets to a shared link', async () => {
const asset = AssetFactory.create();
const sharedLink = SharedLinkFactory.from().asset(asset).build();
const sharedLink = SharedLinkFactory.from()
.asset(asset, (builder) => builder.exif())
.build();
const newAsset = AssetFactory.create();
mocks.sharedLink.get.mockResolvedValue(sharedLink);
mocks.sharedLink.create.mockResolvedValue(sharedLink);
mocks.sharedLink.update.mockResolvedValue(sharedLink);
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink));
mocks.sharedLink.update.mockResolvedValue(getForSharedLink(sharedLink));
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([newAsset.id]));
await expect(
@ -286,7 +315,7 @@ describe(SharedLinkService.name, () => {
expect(mocks.access.asset.checkOwnerAccess).toHaveBeenCalledTimes(1);
expect(mocks.sharedLink.update).toHaveBeenCalled();
expect(mocks.sharedLink.update).toHaveBeenCalledWith({
...sharedLink,
...getForSharedLink(sharedLink),
slug: null,
assetIds: [newAsset.id],
});
@ -295,19 +324,22 @@ describe(SharedLinkService.name, () => {
describe('removeAssets', () => {
it('should not work on album shared links', async () => {
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.valid);
const sharedLink = SharedLinkFactory.from().album().build();
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
await expect(sut.removeAssets(authStub.admin, 'link-1', { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
await expect(sut.removeAssets(authStub.admin, sharedLink.id, { assetIds: ['asset-1'] })).rejects.toBeInstanceOf(
BadRequestException,
);
});
it('should remove assets from a shared link', async () => {
const asset = AssetFactory.create();
const sharedLink = SharedLinkFactory.from().asset(asset).build();
mocks.sharedLink.get.mockResolvedValue(sharedLink);
mocks.sharedLink.create.mockResolvedValue(sharedLink);
mocks.sharedLink.update.mockResolvedValue(sharedLink);
const sharedLink = SharedLinkFactory.from()
.asset(asset, (builder) => builder.exif())
.build();
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
mocks.sharedLink.create.mockResolvedValue(getForSharedLink(sharedLink));
mocks.sharedLink.update.mockResolvedValue(getForSharedLink(sharedLink));
mocks.sharedLinkAsset.remove.mockResolvedValue([asset.id]);
await expect(
@ -338,11 +370,14 @@ describe(SharedLinkService.name, () => {
});
it('should return metadata tags', async () => {
mocks.sharedLink.get.mockResolvedValue(sharedLinkStub.individual);
const sharedLink = SharedLinkFactory.from({ description: null })
.asset({}, (builder) => builder.exif())
.build();
mocks.sharedLink.get.mockResolvedValue(getForSharedLink(sharedLink));
await expect(sut.getMetadataTags(authStub.adminSharedLink)).resolves.toEqual({
description: '1 shared photos & videos',
imageUrl: `https://my.immich.app/api/assets/${sharedLinkStub.individual.assets[0].id}/thumbnail?key=LCtkaJX4R1O_9D-2lq0STzsPryoL1UdAbyb6Sna1xxmQCSuqU2J1ZUsqt6GR-yGm1s0`,
imageUrl: `https://my.immich.app/api/assets/${sharedLink.assets[0].id}/thumbnail?key=${sharedLink.key.toString('base64url')}`,
title: 'Public Share',
});

View File

@ -4,6 +4,7 @@ import { AssetFactory } from 'test/factories/asset.factory';
import { AuthFactory } from 'test/factories/auth.factory';
import { StackFactory } from 'test/factories/stack.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { getForStack } from 'test/mappers';
import { newUuid } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@ -22,9 +23,11 @@ describe(StackService.name, () => {
describe('search', () => {
it('should search stacks', async () => {
const auth = AuthFactory.create();
const asset = AssetFactory.create();
const stack = StackFactory.from().primaryAsset(asset).build();
mocks.stack.search.mockResolvedValue([stack]);
const asset = AssetFactory.from().exif().build();
const stack = StackFactory.from()
.primaryAsset(asset, (builder) => builder.exif())
.build();
mocks.stack.search.mockResolvedValue([getForStack(stack)]);
await sut.search(auth, { primaryAssetId: asset.id });
expect(mocks.stack.search).toHaveBeenCalledWith({
@ -49,11 +52,14 @@ describe(StackService.name, () => {
it('should create a stack', async () => {
const auth = AuthFactory.create();
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build();
const [primaryAsset, asset] = [AssetFactory.from().exif().build(), AssetFactory.from().exif().build()];
const stack = StackFactory.from()
.primaryAsset(primaryAsset, (builder) => builder.exif())
.asset(asset, (builder) => builder.exif())
.build();
mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set([primaryAsset.id, asset.id]));
mocks.stack.create.mockResolvedValue(stack);
mocks.stack.create.mockResolvedValue(getForStack(stack));
await expect(sut.create(auth, { assetIds: [primaryAsset.id, asset.id] })).resolves.toEqual({
id: stack.id,
@ -88,11 +94,14 @@ describe(StackService.name, () => {
it('should get stack', async () => {
const auth = AuthFactory.create();
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build();
const [primaryAsset, asset] = [AssetFactory.from().exif().build(), AssetFactory.from().exif().build()];
const stack = StackFactory.from()
.primaryAsset(primaryAsset, (builder) => builder.exif())
.asset(asset, (builder) => builder.exif())
.build();
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id]));
mocks.stack.getById.mockResolvedValue(stack);
mocks.stack.getById.mockResolvedValue(getForStack(stack));
await expect(sut.get(auth, stack.id)).resolves.toEqual({
id: stack.id,
@ -125,10 +134,13 @@ describe(StackService.name, () => {
it('should fail if the provided primary asset id is not in the stack', async () => {
const auth = AuthFactory.create();
const stack = StackFactory.from().primaryAsset().asset().build();
const stack = StackFactory.from()
.primaryAsset({}, (builder) => builder.exif())
.asset({}, (builder) => builder.exif())
.build();
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id]));
mocks.stack.getById.mockResolvedValue(stack);
mocks.stack.getById.mockResolvedValue(getForStack(stack));
await expect(sut.update(auth, stack.id, { primaryAssetId: 'unknown-asset' })).rejects.toBeInstanceOf(
BadRequestException,
@ -141,12 +153,15 @@ describe(StackService.name, () => {
it('should update stack', async () => {
const auth = AuthFactory.create();
const [primaryAsset, asset] = [AssetFactory.create(), AssetFactory.create()];
const stack = StackFactory.from().primaryAsset(primaryAsset).asset(asset).build();
const [primaryAsset, asset] = [AssetFactory.from().exif().build(), AssetFactory.from().exif().build()];
const stack = StackFactory.from()
.primaryAsset(primaryAsset, (builder) => builder.exif())
.asset(asset, (builder) => builder.exif())
.build();
mocks.access.stack.checkOwnerAccess.mockResolvedValue(new Set([stack.id]));
mocks.stack.getById.mockResolvedValue(stack);
mocks.stack.update.mockResolvedValue(stack);
mocks.stack.getById.mockResolvedValue(getForStack(stack));
mocks.stack.update.mockResolvedValue(getForStack(stack));
await sut.update(auth, stack.id, { primaryAssetId: asset.id });

View File

@ -6,7 +6,7 @@ import { AlbumFactory } from 'test/factories/album.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { UserFactory } from 'test/factories/user.factory';
import { userStub } from 'test/fixtures/user.stub';
import { getForStorageTemplate } from 'test/mappers';
import { getForAlbum, getForStorageTemplate } from 'test/mappers';
import { makeStream, newTestService, ServiceMocks } from 'test/utils';
const motionAsset = AssetFactory.from({ type: AssetType.Video }).exif().build();
@ -170,7 +170,9 @@ describe(StorageTemplateService.name, () => {
.exif()
.build();
const album = AlbumFactory.from().asset().build();
const album = AlbumFactory.from()
.asset({}, (builder) => builder.exif())
.build();
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
sut.onConfigInit({ newConfig: config });
@ -182,7 +184,7 @@ describe(StorageTemplateService.name, () => {
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(stillAsset));
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
mocks.album.getByAssetId.mockResolvedValue([album]);
mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]);
mocks.move.create.mockResolvedValueOnce({
id: '123',
@ -211,7 +213,9 @@ describe(StorageTemplateService.name, () => {
it('should use handlebar if condition for album', async () => {
const user = UserFactory.create();
const asset = AssetFactory.from().owner(user).exif().build();
const album = AlbumFactory.from().asset().build();
const album = AlbumFactory.from()
.asset({}, (builder) => builder.exif())
.build();
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
@ -219,7 +223,7 @@ describe(StorageTemplateService.name, () => {
mocks.user.get.mockResolvedValue(user);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset));
mocks.album.getByAssetId.mockResolvedValueOnce([album]);
mocks.album.getByAssetId.mockResolvedValueOnce([getForAlbum(album)]);
expect(await sut.handleMigrationSingle({ id: asset.id })).toBe(JobStatus.Success);
@ -259,7 +263,9 @@ describe(StorageTemplateService.name, () => {
it('should handle album startDate', async () => {
const user = UserFactory.create();
const asset = AssetFactory.from().owner(user).exif().build();
const album = AlbumFactory.from().asset().build();
const album = AlbumFactory.from()
.asset({}, (builder) => builder.exif())
.build();
const config = structuredClone(defaults);
config.storageTemplate.template =
'{{#if album}}{{album-startDate-y}}/{{album-startDate-MM}} - {{album}}{{else}}{{y}}/{{MM}}/{{/if}}/{{filename}}';
@ -268,7 +274,7 @@ describe(StorageTemplateService.name, () => {
mocks.user.get.mockResolvedValue(user);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(asset));
mocks.album.getByAssetId.mockResolvedValueOnce([album]);
mocks.album.getByAssetId.mockResolvedValueOnce([getForAlbum(album)]);
mocks.album.getMetadataForIds.mockResolvedValueOnce([
{
startDate: asset.fileCreatedAt,
@ -764,7 +770,9 @@ describe(StorageTemplateService.name, () => {
})
.exif()
.build();
const album = AlbumFactory.from().asset().build();
const album = AlbumFactory.from()
.asset({}, (builder) => builder.exif())
.build();
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other/{{MM}}{{/if}}/{{filename}}';
sut.onConfigInit({ newConfig: config });
@ -775,7 +783,7 @@ describe(StorageTemplateService.name, () => {
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)]));
mocks.user.getList.mockResolvedValue([userStub.user1]);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
mocks.album.getByAssetId.mockResolvedValue([album]);
mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]);
mocks.move.create.mockResolvedValueOnce({
id: '123',
@ -803,7 +811,9 @@ describe(StorageTemplateService.name, () => {
it('should use still photo album info when migrating live photo motion video', async () => {
const user = userStub.user1;
const album = AlbumFactory.from().asset().build();
const album = AlbumFactory.from()
.asset({}, (builder) => builder.exif())
.build();
const config = structuredClone(defaults);
config.storageTemplate.template = '{{y}}/{{#if album}}{{album}}{{else}}other{{/if}}/{{filename}}';
@ -812,7 +822,7 @@ describe(StorageTemplateService.name, () => {
mocks.assetJob.streamForStorageTemplateJob.mockReturnValue(makeStream([getForStorageTemplate(stillAsset)]));
mocks.user.getList.mockResolvedValue([user]);
mocks.assetJob.getForStorageTemplateJob.mockResolvedValueOnce(getForStorageTemplate(motionAsset));
mocks.album.getByAssetId.mockResolvedValue([album]);
mocks.album.getByAssetId.mockResolvedValue([getForAlbum(album)]);
mocks.move.create.mockResolvedValueOnce({
id: '123',

View File

@ -2,6 +2,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto';
import { SyncService } from 'src/services/sync.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { getForAsset, getForPartner } from 'test/mappers';
import { factory } from 'test/small.factory';
import { newTestService, ServiceMocks } from 'test/utils';
@ -26,10 +27,10 @@ describe(SyncService.name, () => {
AssetFactory.from({ libraryId: 'library-id', isExternal: true }).owner(authStub.user1.user).build(),
AssetFactory.from().owner(authStub.user1.user).build(),
];
mocks.asset.getAllForUserFullSync.mockResolvedValue([asset1, asset2]);
mocks.asset.getAllForUserFullSync.mockResolvedValue([getForAsset(asset1), getForAsset(asset2)]);
await expect(sut.getFullSync(authStub.user1, { limit: 2, updatedUntil: untilDate })).resolves.toEqual([
mapAsset(asset1, mapAssetOpts),
mapAsset(asset2, mapAssetOpts),
mapAsset(getForAsset(asset1), mapAssetOpts),
mapAsset(getForAsset(asset2), mapAssetOpts),
]);
expect(mocks.asset.getAllForUserFullSync).toHaveBeenCalledWith({
ownerId: authStub.user1.user.id,
@ -44,7 +45,7 @@ describe(SyncService.name, () => {
const partner = factory.partner();
const auth = factory.auth({ user: { id: partner.sharedWithId } });
mocks.partner.getAll.mockResolvedValue([partner]);
mocks.partner.getAll.mockResolvedValue([getForPartner(partner)]);
await expect(
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [auth.user.id] }),
@ -66,7 +67,9 @@ describe(SyncService.name, () => {
it('should return a response requiring a full sync when there are too many changes', async () => {
const asset = AssetFactory.create();
mocks.partner.getAll.mockResolvedValue([]);
mocks.asset.getChangedDeltaSync.mockResolvedValue(Array.from<typeof asset>({ length: 10_000 }).fill(asset));
mocks.asset.getChangedDeltaSync.mockResolvedValue(
Array.from<ReturnType<typeof getForAsset>>({ length: 10_000 }).fill(getForAsset(asset)),
);
await expect(
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),
).resolves.toEqual({ needsFullSync: true, upserted: [], deleted: [] });
@ -78,13 +81,13 @@ describe(SyncService.name, () => {
const asset = AssetFactory.create({ ownerId: authStub.user1.user.id });
const deletedAsset = AssetFactory.create({ libraryId: 'library-id', isExternal: true });
mocks.partner.getAll.mockResolvedValue([]);
mocks.asset.getChangedDeltaSync.mockResolvedValue([asset]);
mocks.asset.getChangedDeltaSync.mockResolvedValue([getForAsset(asset)]);
mocks.audit.getAfter.mockResolvedValue([deletedAsset.id]);
await expect(
sut.getDeltaSync(authStub.user1, { updatedAfter: new Date(), userIds: [authStub.user1.user.id] }),
).resolves.toEqual({
needsFullSync: false,
upserted: [mapAsset(asset, mapAssetOpts)],
upserted: [mapAsset(getForAsset(asset), mapAssetOpts)],
deleted: [deletedAsset.id],
});
expect(mocks.asset.getChangedDeltaSync).toHaveBeenCalledTimes(1);

View File

@ -2,6 +2,7 @@ import { mapAsset } from 'src/dtos/asset-response.dto';
import { ViewService } from 'src/services/view.service';
import { AssetFactory } from 'test/factories/asset.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { getForAsset } from 'test/mappers';
import { newTestService, ServiceMocks } from 'test/utils';
describe(ViewService.name, () => {
@ -37,7 +38,7 @@ describe(ViewService.name, () => {
const mockAssets = [asset1, asset2];
const mockAssetReponseDto = mockAssets.map((a) => mapAsset(a, { auth: authStub.admin }));
const mockAssetReponseDto = mockAssets.map((asset) => mapAsset(getForAsset(asset), { auth: authStub.admin }));
mocks.view.getAssetsByOriginalPath.mockResolvedValue(mockAssets as any);

View File

@ -4,23 +4,23 @@ import {
DeduplicateJoinsPlugin,
Expression,
ExpressionBuilder,
ExpressionWrapper,
Kysely,
KyselyConfig,
Nullable,
NotNull,
Selectable,
SelectQueryBuilder,
Simplify,
ShallowDehydrateObject,
sql,
} from 'kysely';
import { PostgresJSDialect } from 'kysely-postgres-js';
import { jsonArrayFrom, jsonObjectFrom } from 'kysely/helpers/postgres';
import { Notice, PostgresError } from 'postgres';
import { columns, Exif, lockableProperties, LockableProperty, Person } from 'src/database';
import { columns, lockableProperties, LockableProperty, Person } from 'src/database';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { AssetFileType, AssetVisibility, DatabaseExtension } from 'src/enum';
import { AssetSearchBuilderOptions } from 'src/repositories/search.repository';
import { DB } from 'src/schema';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { VectorExtension } from 'src/types';
export const getKyselyConfig = (connection: DatabaseConnectionParams): KyselyConfig => {
@ -70,28 +70,6 @@ export const removeUndefinedKeys = <T extends object>(update: T, template: unkno
return update;
};
/** Modifies toJson return type to not set all properties as nullable */
export function toJson<DB, TB extends keyof DB & string, T extends TB | Expression<unknown>>(
eb: ExpressionBuilder<DB, TB>,
table: T,
) {
return eb.fn.toJson<T>(table) as ExpressionWrapper<
DB,
TB,
Simplify<
T extends TB
? Selectable<DB[T]> extends Nullable<infer N>
? N | null
: Selectable<DB[T]>
: T extends Expression<infer O>
? O extends Nullable<infer N>
? N | null
: O
: never
>
>;
}
export const ASSET_CHECKSUM_CONSTRAINT = 'UQ_assets_owner_checksum';
export const isAssetChecksumConstraint = (error: unknown) => {
@ -106,19 +84,24 @@ export function withDefaultVisibility<O>(qb: SelectQueryBuilder<DB, 'asset', O>)
export function withExif<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
return qb
.leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.select((eb) => eb.fn.toJson(eb.table('asset_exif')).$castTo<Exif | null>().as('exifInfo'));
.select((eb) =>
jsonObjectFrom(eb.table('asset_exif'))
.$castTo<ShallowDehydrateObject<Selectable<AssetExifTable>> | null>()
.as('exifInfo'),
);
}
export function withExifInner<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
return qb
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.select((eb) => eb.fn.toJson(eb.table('asset_exif')).$castTo<Exif>().as('exifInfo'));
.select((eb) => jsonObjectFrom(eb.table('asset_exif')).as('exifInfo'))
.$narrowType<{ exifInfo: NotNull }>();
}
export function withSmartSearch<O>(qb: SelectQueryBuilder<DB, 'asset', O>) {
return qb
.leftJoin('smart_search', 'asset.id', 'smart_search.assetId')
.select((eb) => toJson(eb, 'smart_search').as('smartSearch'));
.select((eb) => jsonObjectFrom(eb.table('smart_search')).as('smartSearch'));
}
export function withFaces(eb: ExpressionBuilder<DB, 'asset'>, withHidden?: boolean, withDeletedFace?: boolean) {
@ -164,7 +147,7 @@ export function withFacesAndPeople(
(join) => join.onTrue(),
)
.selectAll('asset_face')
.select((eb) => eb.table('person').$castTo<Person>().as('person'))
.select((eb) => eb.table('person').$castTo<ShallowDehydrateObject<Person>>().as('person'))
.whereRef('asset_face.assetId', '=', 'asset.id')
.$if(!withDeletedFace, (qb) => qb.where('asset_face.deletedAt', 'is', null))
.$if(!withHidden, (qb) => qb.where('asset_face.isVisible', 'is', true)),

View File

@ -0,0 +1,45 @@
import { Selectable } from 'kysely';
import { MemoryType } from 'src/enum';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { AssetFactory } from 'test/factories/asset.factory';
import { build } from 'test/factories/builder.factory';
import { AssetLike, FactoryBuilder, MemoryLike } from 'test/factories/types';
import { newDate, newUuid, newUuidV7 } from 'test/small.factory';
export class MemoryFactory {
#assets: AssetFactory[] = [];
private constructor(private readonly value: Selectable<MemoryTable>) {}
static create(dto: MemoryLike = {}) {
return MemoryFactory.from(dto).build();
}
static from(dto: MemoryLike = {}) {
return new MemoryFactory({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUuidV7(),
deletedAt: null,
ownerId: newUuid(),
type: MemoryType.OnThisDay,
data: { year: 2024 },
isSaved: false,
memoryAt: newDate(),
seenAt: null,
showAt: newDate(),
hideAt: newDate(),
...dto,
});
}
asset(asset: AssetLike, builder?: FactoryBuilder<AssetFactory>) {
this.#assets.push(build(AssetFactory.from(asset), builder));
return this;
}
build() {
return { ...this.value, assets: this.#assets.map((asset) => asset.build()) };
}
}

View File

@ -51,12 +51,14 @@ export class SharedLinkFactory {
album(dto: AlbumLike = {}, builder?: FactoryBuilder<AlbumFactory>) {
this.#album = build(AlbumFactory.from(dto), builder);
this.value.type = SharedLinkType.Album;
return this;
}
asset(dto: AssetLike = {}, builder?: FactoryBuilder<AssetFactory>) {
const asset = build(AssetFactory.from(dto), builder);
this.#assets.push(asset);
this.value.type = SharedLinkType.Individual;
return this;
}

View File

@ -6,6 +6,7 @@ import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { MemoryTable } from 'src/schema/tables/memory.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { StackTable } from 'src/schema/tables/stack.table';
@ -24,3 +25,4 @@ export type UserLike = Partial<Selectable<UserTable>>;
export type AssetFaceLike = Partial<Selectable<AssetFaceTable>>;
export type PersonLike = Partial<Selectable<PersonTable>>;
export type StackLike = Partial<Selectable<StackTable>>;
export type MemoryLike = Partial<Selectable<MemoryTable>>;

View File

@ -1,7 +1,6 @@
import { UserAdmin } from 'src/database';
import { MapAsset } from 'src/dtos/asset-response.dto';
import { SharedLinkResponseDto } from 'src/dtos/shared-link.dto';
import { AssetStatus, AssetType, AssetVisibility, SharedLinkType } from 'src/enum';
import { SharedLinkType } from 'src/enum';
import { AssetFactory } from 'test/factories/asset.factory';
import { authStub } from 'test/fixtures/auth.stub';
import { userStub } from 'test/fixtures/user.stub';
@ -83,86 +82,7 @@ export const sharedLinkStub = {
showExif: false,
description: null,
password: null,
assets: [
{
id: 'id_1',
status: AssetStatus.Active,
owner: undefined as unknown as UserAdmin,
ownerId: 'user_id_1',
deviceAssetId: 'device_asset_id_1',
deviceId: 'device_id_1',
type: AssetType.Video,
originalPath: 'fake_path/jpeg',
checksum: Buffer.from('file hash', 'utf8'),
fileModifiedAt: today,
fileCreatedAt: today,
localDateTime: today,
createdAt: today,
updatedAt: today,
isFavorite: false,
isArchived: false,
isExternal: false,
isOffline: false,
files: [],
thumbhash: null,
encodedVideoPath: '',
duration: null,
livePhotoVideo: null,
livePhotoVideoId: null,
originalFileName: 'asset_1.jpeg',
exifInfo: {
projectionType: null,
livePhotoCID: null,
assetId: 'id_1',
description: 'description',
exifImageWidth: 500,
exifImageHeight: 500,
fileSizeInByte: 100,
orientation: 'orientation',
dateTimeOriginal: today,
modifyDate: today,
timeZone: 'America/Los_Angeles',
latitude: 100,
longitude: 100,
city: 'city',
state: 'state',
country: 'country',
make: 'camera-make',
model: 'camera-model',
lensModel: 'fancy',
fNumber: 100,
focalLength: 100,
iso: 100,
exposureTime: '1/16',
fps: 100,
profileDescription: 'sRGB',
bitsPerSample: 8,
colorspace: 'sRGB',
autoStackId: null,
rating: 3,
updatedAt: today,
updateId: '42',
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: 500,
height: 500,
tags: [],
},
sharedLinks: [],
faces: [],
sidecarPath: null,
deletedAt: null,
duplicateId: null,
updateId: '42',
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: 500,
height: 500,
isEdited: false,
},
],
assets: [],
albumId: null,
album: null,
slug: null,

View File

@ -1,7 +1,22 @@
import { Selectable } from 'kysely';
import { Selectable, ShallowDehydrateObject } from 'kysely';
import { AssetEditActionItem } from 'src/dtos/editing.dto';
import { ActivityTable } from 'src/schema/tables/activity.table';
import { AlbumTable } from 'src/schema/tables/album.table';
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
import { AssetFaceTable } from 'src/schema/tables/asset-face.table';
import { AssetFileTable } from 'src/schema/tables/asset-file.table';
import { AssetTable } from 'src/schema/tables/asset.table';
import { PartnerTable } from 'src/schema/tables/partner.table';
import { PersonTable } from 'src/schema/tables/person.table';
import { SharedLinkTable } from 'src/schema/tables/shared-link.table';
import { StackTable } from 'src/schema/tables/stack.table';
import { AlbumFactory } from 'test/factories/album.factory';
import { AssetFaceFactory } from 'test/factories/asset-face.factory';
import { AssetFactory } from 'test/factories/asset.factory';
import { MemoryFactory } from 'test/factories/memory.factory';
import { SharedLinkFactory } from 'test/factories/shared-link.factory';
import { StackFactory } from 'test/factories/stack.factory';
import { UserFactory } from 'test/factories/user.factory';
export const getForStorageTemplate = (asset: ReturnType<AssetFactory['build']>) => {
return {
@ -47,6 +62,222 @@ export const getForFacialRecognitionJob = (
asset: Pick<Selectable<AssetTable>, 'ownerId' | 'visibility' | 'fileCreatedAt'> | null,
) => ({
...face,
asset,
asset: asset
? { ownerId: asset.ownerId, visibility: asset.visibility, fileCreatedAt: asset.fileCreatedAt.toISOString() }
: null,
faceSearch: { faceId: face.id, embedding: '[1, 2, 3, 4]' },
});
export const getDehydratedUser = (user: ReturnType<UserFactory['build']>) => ({
...user,
profileChangedAt: user.profileChangedAt.toISOString(),
});
export const getDehydratedAsset = (asset: Selectable<AssetTable>): ShallowDehydrateObject<Selectable<AssetTable>> => ({
...asset,
checksum: asset.checksum.toString(),
createdAt: asset.createdAt.toISOString(),
deletedAt: asset.deletedAt?.toISOString() ?? null,
fileCreatedAt: asset.fileCreatedAt.toISOString(),
fileModifiedAt: asset.fileModifiedAt.toISOString(),
localDateTime: asset.localDateTime.toISOString(),
thumbhash: asset.thumbhash?.toString() ?? null,
updatedAt: asset.updatedAt.toISOString(),
});
const getDehydratedExif = (exif: Selectable<AssetExifTable>): ShallowDehydrateObject<Selectable<AssetExifTable>> => ({
...exif,
dateTimeOriginal: exif.dateTimeOriginal?.toISOString() ?? null,
modifyDate: exif.modifyDate?.toISOString() ?? null,
updatedAt: exif.updatedAt.toISOString(),
});
const getDehydratedSharedLink = (
sharedLink: Selectable<SharedLinkTable>,
): ShallowDehydrateObject<Selectable<SharedLinkTable>> => ({
...sharedLink,
createdAt: sharedLink.createdAt.toISOString(),
expiresAt: sharedLink.expiresAt?.toISOString() ?? null,
key: sharedLink.key.toString(),
});
const getDehydratedPerson = (person: Selectable<PersonTable>): ShallowDehydrateObject<Selectable<PersonTable>> => ({
...person,
birthDate: person.birthDate?.toISOString() ?? null,
createdAt: person.createdAt.toISOString(),
updatedAt: person.updatedAt.toISOString(),
});
const getDehydratedFace = (face: Selectable<AssetFaceTable>): ShallowDehydrateObject<Selectable<AssetFaceTable>> => ({
...face,
deletedAt: face.deletedAt?.toISOString() ?? null,
updatedAt: face.updatedAt.toISOString(),
});
const getDehydratedStack = (stack: Selectable<StackTable>): ShallowDehydrateObject<Selectable<StackTable>> => ({
...stack,
createdAt: stack.createdAt.toISOString(),
updatedAt: stack.updatedAt.toISOString(),
});
const getDehydratedFile = (file: Selectable<AssetFileTable>): ShallowDehydrateObject<Selectable<AssetFileTable>> => ({
...file,
createdAt: file.createdAt.toISOString(),
updatedAt: file.updatedAt.toISOString(),
});
const getDehydratedAlbum = (album: Selectable<AlbumTable>): ShallowDehydrateObject<Selectable<AlbumTable>> => ({
...album,
createdAt: album.createdAt.toISOString(),
deletedAt: album.deletedAt ? album.deletedAt.toISOString() : null,
updatedAt: album.updatedAt.toISOString(),
});
export const getForAlbum = (album: ReturnType<AlbumFactory['build']>) => ({
...album,
assets: album.assets.map((asset) => ({ ...getDehydratedAsset(asset), exifInfo: getDehydratedExif(asset.exifInfo) })),
albumUsers: album.albumUsers.map((albumUser) => ({
...albumUser,
createdAt: albumUser.createdAt.toISOString(),
user: getDehydratedUser(albumUser.user),
})),
owner: getDehydratedUser(album.owner),
sharedLinks: album.sharedLinks.map((sharedLink) => getDehydratedSharedLink(sharedLink)),
});
export const getForActivity = (activity: Selectable<ActivityTable> & { user: ReturnType<UserFactory['build']> }) => ({
...activity,
user: getDehydratedUser(activity.user),
});
export const getForAsset = (asset: ReturnType<AssetFactory['build']>) => {
return {
...asset,
faces: asset.faces.map((face) => ({
...getDehydratedFace(face),
person: face.person ? getDehydratedPerson(face.person) : null,
})),
owner: getDehydratedUser(asset.owner),
stack: asset.stack
? { ...getDehydratedStack(asset.stack), assets: asset.stack.assets.map((asset) => getDehydratedAsset(asset)) }
: null,
files: asset.files.map((file) => getDehydratedFile(file)),
exifInfo: asset.exifInfo ? getDehydratedExif(asset.exifInfo) : null,
edits: asset.edits.map(({ action, parameters }) => ({ action, parameters })) as AssetEditActionItem[],
};
};
export const getForPartner = (
partner: Selectable<PartnerTable> & Record<'sharedWith' | 'sharedBy', ReturnType<UserFactory['build']>>,
) => ({
...partner,
sharedBy: getDehydratedUser(partner.sharedBy),
sharedWith: getDehydratedUser(partner.sharedWith),
});
export const getForMemory = (memory: ReturnType<MemoryFactory['build']>) => ({
...memory,
assets: memory.assets.map((asset) => getDehydratedAsset(asset)),
});
export const getForMetadataExtraction = (asset: ReturnType<AssetFactory['build']>) => ({
id: asset.id,
checksum: asset.checksum,
deviceAssetId: asset.deviceAssetId,
deviceId: asset.deviceId,
fileCreatedAt: asset.fileCreatedAt,
fileModifiedAt: asset.fileModifiedAt,
isExternal: asset.isExternal,
visibility: asset.visibility,
libraryId: asset.libraryId,
livePhotoVideoId: asset.livePhotoVideoId,
localDateTime: asset.localDateTime,
originalFileName: asset.originalFileName,
originalPath: asset.originalPath,
ownerId: asset.ownerId,
type: asset.type,
width: asset.width,
height: asset.height,
faces: asset.faces.map((face) => getDehydratedFace(face)),
files: asset.files.map((file) => getDehydratedFile(file)),
});
export const getForGenerateThumbnail = (asset: ReturnType<AssetFactory['build']>) => ({
id: asset.id,
visibility: asset.visibility,
originalFileName: asset.originalFileName,
originalPath: asset.originalPath,
ownerId: asset.ownerId,
thumbhash: asset.thumbhash,
type: asset.type,
files: asset.files.map((file) => getDehydratedFile(file)),
exifInfo: getDehydratedExif(asset.exifInfo),
edits: asset.edits.map(({ action, parameters }) => ({ action, parameters })) as AssetEditActionItem[],
});
export const getForAssetFace = (face: ReturnType<AssetFaceFactory['build']>) => ({
...face,
person: face.person ? getDehydratedPerson(face.person) : null,
});
export const getForDetectedFaces = (asset: ReturnType<AssetFactory['build']>) => ({
id: asset.id,
visibility: asset.visibility,
exifInfo: getDehydratedExif(asset.exifInfo),
faces: asset.faces.map((face) => getDehydratedFace(face)),
files: asset.files.map((file) => getDehydratedFile(file)),
});
export const getForSidecarWrite = (asset: ReturnType<AssetFactory['build']>) => ({
id: asset.id,
originalPath: asset.originalPath,
files: asset.files.map((file) => getDehydratedFile(file)),
exifInfo: getDehydratedExif(asset.exifInfo),
});
export const getForAssetDeletion = (asset: ReturnType<AssetFactory['build']>) => ({
id: asset.id,
visibility: asset.visibility,
libraryId: asset.libraryId,
ownerId: asset.ownerId,
livePhotoVideoId: asset.livePhotoVideoId,
encodedVideoPath: asset.encodedVideoPath,
originalPath: asset.originalPath,
isOffline: asset.isOffline,
exifInfo: asset.exifInfo ? getDehydratedExif(asset.exifInfo) : null,
files: asset.files.map((file) => getDehydratedFile(file)),
stack: asset.stack
? {
...getDehydratedStack(asset.stack),
assets: asset.stack.assets.filter(({ id }) => id !== asset.stack?.primaryAssetId).map(({ id }) => ({ id })),
}
: null,
});
export const getForStack = (stack: ReturnType<StackFactory['build']>) => ({
...stack,
assets: stack.assets.map((asset) => ({
...getDehydratedAsset(asset),
exifInfo: getDehydratedExif(asset.exifInfo),
})),
});
export const getForDuplicate = (asset: ReturnType<AssetFactory['build']>) => ({
...getDehydratedAsset(asset),
exifInfo: getDehydratedExif(asset.exifInfo),
});
export const getForSharedLink = (sharedLink: ReturnType<SharedLinkFactory['build']>) => ({
...sharedLink,
assets: sharedLink.assets.map((asset) => ({
...getDehydratedAsset(asset),
exifInfo: getDehydratedExif(asset.exifInfo),
})),
album: sharedLink.album
? {
...getDehydratedAlbum(sharedLink.album),
owner: getDehydratedUser(sharedLink.album.owner),
assets: sharedLink.album.assets.map((asset) => getDehydratedAsset(asset)),
}
: null,
});

View File

@ -1,19 +1,17 @@
import { ShallowDehydrateObject } from 'kysely';
import {
Activity,
Album,
ApiKey,
AssetFace,
AssetFile,
AuthApiKey,
AuthSharedLink,
AuthUser,
Exif,
Library,
Memory,
Partner,
Person,
Session,
Stack,
Tag,
User,
UserAdmin,
@ -28,13 +26,12 @@ import {
AssetStatus,
AssetType,
AssetVisibility,
MemoryType,
Permission,
SourceType,
UserMetadataKey,
UserStatus,
} from 'src/enum';
import { DeepPartial, OnThisDayData, UserMetadataItem } from 'src/types';
import { DeepPartial, UserMetadataItem } from 'src/types';
import { UserFactory } from 'test/factories/user.factory';
import { v4, v7 } from 'uuid';
export const newUuid = () => v4();
@ -123,9 +120,17 @@ const authUserFactory = (authUser: Partial<AuthUser> = {}) => {
return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes };
};
const partnerFactory = (partner: Partial<Partner> = {}) => {
const sharedBy = userFactory(partner.sharedBy || {});
const sharedWith = userFactory(partner.sharedWith || {});
const partnerFactory = ({
sharedBy: sharedByProvided,
sharedWith: sharedWithProvided,
...partner
}: Partial<Partner> = {}) => {
const hydrateUser = (user: Partial<ShallowDehydrateObject<User>>) => ({
...user,
profileChangedAt: user.profileChangedAt ? new Date(user.profileChangedAt) : undefined,
});
const sharedBy = UserFactory.create(sharedByProvided ? hydrateUser(sharedByProvided) : {});
const sharedWith = UserFactory.create(sharedWithProvided ? hydrateUser(sharedWithProvided) : {});
return {
sharedById: sharedBy.id,
@ -168,19 +173,6 @@ const queueStatisticsFactory = (dto?: Partial<QueueStatisticsDto>) => ({
...dto,
});
const stackFactory = ({ owner, assets, ...stack }: DeepPartial<Stack> = {}): Stack => {
const ownerId = newUuid();
return {
id: newUuid(),
primaryAssetId: assets?.[0].id ?? newUuid(),
ownerId,
owner: userFactory(owner ?? { id: ownerId }),
assets: assets?.map((asset) => assetFactory(asset)) ?? [],
...stack,
};
};
const userFactory = (user: Partial<User> = {}) => ({
id: newUuid(),
name: 'Test User',
@ -276,14 +268,14 @@ const assetFactory = (
};
};
const activityFactory = (activity: Partial<Activity> = {}) => {
const activityFactory = (activity: Partial<Omit<Activity, 'user'>> = {}) => {
const userId = activity.userId || newUuid();
return {
id: newUuid(),
comment: null,
isLiked: false,
userId,
user: userFactory({ id: userId }),
user: UserFactory.create({ id: userId }),
assetId: newUuid(),
albumId: newUuid(),
createdAt: newDate(),
@ -319,24 +311,6 @@ const libraryFactory = (library: Partial<Library> = {}) => ({
...library,
});
const memoryFactory = (memory: Partial<Memory> = {}) => ({
id: newUuid(),
createdAt: newDate(),
updatedAt: newDate(),
updateId: newUuidV7(),
deletedAt: null,
ownerId: newUuid(),
type: MemoryType.OnThisDay,
data: { year: 2024 } as OnThisDayData,
isSaved: false,
memoryAt: newDate(),
seenAt: null,
showAt: newDate(),
hideAt: newDate(),
assets: [],
...memory,
});
const versionHistoryFactory = () => ({
id: newUuid(),
createdAt: newDate(),
@ -456,25 +430,6 @@ const tagFactory = (tag: Partial<Tag>): Tag => ({
...tag,
});
const faceFactory = ({ person, ...face }: DeepPartial<AssetFace> = {}): AssetFace => ({
assetId: newUuid(),
boundingBoxX1: 1,
boundingBoxX2: 2,
boundingBoxY1: 1,
boundingBoxY2: 2,
deletedAt: null,
id: newUuid(),
imageHeight: 420,
imageWidth: 42,
isVisible: true,
personId: null,
sourceType: SourceType.MachineLearning,
updatedAt: newDate(),
updateId: newUuidV7(),
person: person === null ? null : personFactory(person),
...face,
});
const assetEditFactory = (edit?: Partial<AssetEditActionItem>): AssetEditActionItem => {
switch (edit?.action) {
case AssetEditAction.Crop: {
@ -536,11 +491,9 @@ export const factory = {
authApiKey: authApiKeyFactory,
authUser: authUserFactory,
library: libraryFactory,
memory: memoryFactory,
partner: partnerFactory,
queueStatistics: queueStatisticsFactory,
session: sessionFactory,
stack: stackFactory,
user: userFactory,
userAdmin: userAdminFactory,
versionHistory: versionHistoryFactory,
@ -548,7 +501,6 @@ export const factory = {
sidecarWrite: assetSidecarWriteFactory,
},
exif: exifFactory,
face: faceFactory,
person: personFactory,
assetEdit: assetEditFactory,
tag: tagFactory,