diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 63ad290f7a..9d5368209c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/server/package.json b/server/package.json index 943f630687..865f9e86f8 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/database.ts b/server/src/database.ts index ec614df9e0..fc790259d1 100644 --- a/server/src/database.ts +++ b/server/src/database.ts @@ -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; role: AlbumUserRole; }; @@ -67,7 +68,7 @@ export type Activity = { updatedAt: Date; albumId: string; userId: string; - user: User; + user: ShallowDehydrateObject; 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[]; }; export type Asset = { @@ -159,9 +160,9 @@ export type StorageAsset = { export type Stack = { id: string; primaryAssetId: string; - owner?: User; + owner?: ShallowDehydrateObject; ownerId: string; - assets: MapAsset[]; + assets: ShallowDehydrateObject[]; assetCount?: number; }; @@ -177,11 +178,11 @@ export type AuthSharedLink = { export type SharedLink = { id: string; - album?: Album | null; + album?: ShallowDehydrateObject | null; albumId: string | null; allowDownload: boolean; allowUpload: boolean; - assets: MapAsset[]; + assets: ShallowDehydrateObject[]; createdAt: Date; description: string | null; expiresAt: Date | null; @@ -194,8 +195,8 @@ export type SharedLink = { }; export type Album = Selectable & { - owner: User; - assets: MapAsset[]; + owner: ShallowDehydrateObject; + assets: ShallowDehydrateObject>[]; }; export type AuthSession = { @@ -205,9 +206,9 @@ export type AuthSession = { export type Partner = { sharedById: string; - sharedBy: User; + sharedBy: ShallowDehydrateObject; sharedWithId: string; - sharedWith: User; + sharedWith: ShallowDehydrateObject; 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 | null; updatedAt: Date; updateId: string; isVisible: boolean; diff --git a/server/src/dtos/activity.dto.ts b/server/src/dtos/activity.dto.ts index 6464d88508..3b81940657 100644 --- a/server/src/dtos/activity.dto.ts +++ b/server/src/dtos/activity.dto.ts @@ -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) }), }; }; diff --git a/server/src/dtos/album-response.dto.spec.ts b/server/src/dtos/album-response.dto.spec.ts index d3536a3482..447e769f29 100644 --- a/server/src/dtos/album-response.dto.spec.ts +++ b/server/src/dtos/album-response.dto.spec.ts @@ -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(); }); diff --git a/server/src/dtos/album.dto.ts b/server/src/dtos/album.dto.ts index 62013fbd92..ab865c35f5 100644 --- a/server/src/dtos/album.dto.ts +++ b/server/src/dtos/album.dto.ts @@ -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[]; + sharedLinks?: ShallowDehydrateObject[]; albumName: string; description: string; albumThumbnailAssetId: string | null; @@ -200,7 +201,7 @@ export type MapAlbumDto = { updatedAt: Date; id: string; ownerId: string; - owner: User; + owner: ShallowDehydrateObject; 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, diff --git a/server/src/dtos/asset-response.dto.spec.ts b/server/src/dtos/asset-response.dto.spec.ts index ff3b3f6acd..8e85b983c3 100644 --- a/server/src/dtos/asset-response.dto.spec.ts +++ b/server/src/dtos/asset-response.dto.spec.ts @@ -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); diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index a76df4abaa..15cc6c9c80 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -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[]; encodedVideoPath: string | null; - exifInfo?: Selectable | null; - faces?: AssetFace[]; + exifInfo?: ShallowDehydrateObject> | null; + faces?: ShallowDehydrateObject[]; fileCreatedAt: Date; fileModifiedAt: Date; - files?: AssetFile[]; + files?: ShallowDehydrateObject[]; isExternal: boolean; isFavorite: boolean; isOffline: boolean; @@ -167,11 +167,11 @@ export type MapAsset = { localDateTime: Date; originalFileName: string; originalPath: string; - owner?: User | null; + owner?: ShallowDehydrateObject | null; ownerId: string; - stack?: Stack | null; + stack?: (ShallowDehydrateObject & { assets: Stack['assets'] }) | null; stackId: string | null; - tags?: Tag[]; + tags?: ShallowDehydrateObject[]; thumbhash: Buffer | 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) => ({ + ...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), +}); diff --git a/server/src/dtos/memory.dto.ts b/server/src/dtos/memory.dto.ts index edf65ef583..d38fc5df01 100644 --- a/server/src/dtos/memory.dto.ts +++ b/server/src/dtos/memory.dto.ts @@ -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 })), }; }; diff --git a/server/src/dtos/person.dto.ts b/server/src/dtos/person.dto.ts index 983062afcf..da257a2ace 100644 --- a/server/src/dtos/person.dto.ts +++ b/server/src/dtos/person.dto.ts @@ -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, }; } diff --git a/server/src/dtos/shared-link.dto.ts b/server/src/dtos/shared-link.dto.ts index b2ecc70a3a..f81ab37feb 100644 --- a/server/src/dtos/shared-link.dto.ts +++ b/server/src/dtos/shared-link.dto.ts @@ -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, diff --git a/server/src/dtos/stack.dto.ts b/server/src/dtos/stack.dto.ts index a76b35e08e..ca8194f705 100644 --- a/server/src/dtos/stack.dto.ts +++ b/server/src/dtos/stack.dto.ts @@ -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 })), }; }; diff --git a/server/src/repositories/album.repository.ts b/server/src/repositories/album.repository.ts index cf132a023d..5ab6d62b41 100644 --- a/server/src/repositories/album.repository.ts +++ b/server/src/repositories/album.repository.ts @@ -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) => { .selectFrom('asset') .selectAll('asset') .leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId') - .select((eb) => eb.table('asset_exif').$castTo().as('exifInfo')) + .select((eb) => + jsonObjectFrom(eb.table('asset_exif')) + .$castTo>>() + .as('exifInfo'), + ) .innerJoin('album_asset', 'album_asset.assetId', 'asset.id') .whereRef('album_asset.albumId', '=', 'album.id') .where('asset.deletedAt', 'is', null) diff --git a/server/src/repositories/asset-job.repository.ts b/server/src/repositories/asset-job.repository.ts index df9b50791f..96313c0528 100644 --- a/server/src/repositories/asset-job.repository.ts +++ b/server/src/repositories/asset-job.repository.ts @@ -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(); } diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index e971a995e6..3da10c1eea 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -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().as('stack')), + qb.select((eb) => jsonObjectFrom(eb.table('stack')).$castTo().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().as('stack')), + .select((eb) => jsonObjectFrom(eb.table('stacked_assets')).as('stack')), ), ) .$if(!!files, (qb) => qb.select(withFiles)) diff --git a/server/src/repositories/duplicate.repository.ts b/server/src/repositories/duplicate.repository.ts index 95ccbea63d..3768ac34d2 100644 --- a/server/src/repositories/duplicate.repository.ts +++ b/server/src/repositories/duplicate.repository.ts @@ -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().as('assets'), + jsonArrayFrom(eb.selectFrom('asset2').selectAll().orderBy('asset.localDateTime', 'asc')).as('assets'), ) .where('asset.ownerId', '=', asUuid(userId)) .where('asset.duplicateId', 'is not', null) diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 615b35c417..13ac254654 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -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>() + .$castTo>>() .as('exifInfo'), ) .orderBy('asset_exif.city') diff --git a/server/src/repositories/shared-link.repository.ts b/server/src/repositories/shared-link.repository.ts index 2a8acd6377..a201f028fd 100644 --- a/server/src/repositories/shared-link.repository.ts +++ b/server/src/repositories/shared-link.repository.ts @@ -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() + .$castTo< + (ShallowDehydrateObject> & { + exifInfo: ShallowDehydrateObject>; + })[] + >() .as('assets'), ) .groupBy(['shared_link.id', sql`"album".*`]) - .select((eb) => eb.fn.toJson('album').$castTo().as('album')) + .select((eb) => jsonObjectFrom(eb.table('album')).$castTo | 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() - .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().as('album')) + .select((eb) => eb.fn.toJson('album').$castTo | 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() + .coalesce(jsonArrayFrom(eb.selectFrom('assets').selectAll().where('assets.id', 'is not', null)), sql`'[]'`) + .$castTo< + (ShallowDehydrateObject> & { + exifInfo: ShallowDehydrateObject>; + })[] + >() .as('assets'), ) .groupBy('shared_link.id') diff --git a/server/src/services/activity.service.spec.ts b/server/src/services/activity.service.spec.ts index aea547e6db..d1a9f53a20 100644 --- a/server/src/services/activity.service.spec.ts +++ b/server/src/services/activity.service.spec.ts @@ -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 }); diff --git a/server/src/services/album.service.spec.ts b/server/src/services/album.service.spec.ts index d21185bd35..47646d0c6d 100644 --- a/server/src/services/album.service.spec.ts +++ b/server/src/services/album.service.spec.ts @@ -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([ diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index 5fb45690cf..f49dd3cb50 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -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'); diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index cc8603cc5a..6e352c4151 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -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); }); diff --git a/server/src/services/duplicate.service.spec.ts b/server/src/services/duplicate.service.spec.ts index 0b216e8b8a..38c6833105 100644 --- a/server/src/services/duplicate.service.spec.ts +++ b/server/src/services/duplicate.service.spec.ts @@ -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([ diff --git a/server/src/services/duplicate.service.ts b/server/src/services/duplicate.service.ts index 618754ff74..f76d7655f3 100644 --- a/server/src/services/duplicate.service.ts +++ b/server/src/services/duplicate.service.ts @@ -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 })), })); } diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 7c9581ff9a..98f369c31a 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -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, diff --git a/server/src/services/map.service.spec.ts b/server/src/services/map.service.spec.ts index d58ae67140..287c5c7c63 100644 --- a/server/src/services/map.service.spec.ts +++ b/server/src/services/map.service.spec.ts @@ -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 }); diff --git a/server/src/services/media.service.spec.ts b/server/src/services/media.service.spec.ts index fc825fb273..4c3571d142 100644 --- a/server/src/services/media.service.spec.ts +++ b/server/src/services/media.service.spec.ts @@ -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)).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)).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)).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)).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)).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)).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)).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)).toEqual( + true, + ); }); }); diff --git a/server/src/services/media.service.ts b/server/src/services/media.service.ts index 3555d7d108..e4bb1d84bc 100644 --- a/server/src/services/media.service.ts +++ b/server/src/services/media.service.ts @@ -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) { diff --git a/server/src/services/memory.service.spec.ts b/server/src/services/memory.service.spec.ts index 44929f2bbf..0445cf892b 100644 --- a/server/src/services/memory.service.spec.ts +++ b/server/src/services/memory.service.spec.ts @@ -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 }, diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 92ec13bea5..ae27f9bd1a 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -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']); diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index f22d4682fa..26568152a7 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -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) { diff --git a/server/src/services/notification.service.spec.ts b/server/src/services/notification.service.spec.ts index ee4b4ec05f..c7bea2b440 100644 --- a/server/src/services/notification.service.spec.ts +++ b/server/src/services/notification.service.spec.ts @@ -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: '' }); diff --git a/server/src/services/partner.service.spec.ts b/server/src/services/partner.service.spec.ts index db057a453a..971b129484 100644 --- a/server/src/services/partner.service.spec.ts +++ b/server/src/services/partner.service.spec.ts @@ -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( diff --git a/server/src/services/partner.service.ts b/server/src/services/partner.service.ts index 628efa9d49..2bd31528ea 100644 --- a/server/src/services/partner.service.ts +++ b/server/src/services/partner.service.ts @@ -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 }; } diff --git a/server/src/services/person.service.spec.ts b/server/src/services/person.service.spec.ts index c22fd65a1a..cbeb56302f 100644 --- a/server/src/services/person.service.spec.ts +++ b/server/src/services/person.service.spec.ts @@ -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(); }); }); }); diff --git a/server/src/services/person.service.ts b/server/src/services/person.service.ts index 8a902590e3..fb04ace4f2 100644 --- a/server/src/services/person.service.ts +++ b/server/src/services/person.service.ts @@ -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) { diff --git a/server/src/services/search.service.spec.ts b/server/src/services/search.service.spec.ts index 62575d0f07..f1cbccb7ec 100644 --- a/server/src/services/search.service.spec.ts +++ b/server/src/services/search.service.spec.ts @@ -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); diff --git a/server/src/services/shared-link.service.spec.ts b/server/src/services/shared-link.service.spec.ts index 07f31db4da..684d15bf7c 100644 --- a/server/src/services/shared-link.service.spec.ts +++ b/server/src/services/shared-link.service.spec.ts @@ -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', }); diff --git a/server/src/services/stack.service.spec.ts b/server/src/services/stack.service.spec.ts index 93f84e28e1..d47d634f4f 100644 --- a/server/src/services/stack.service.spec.ts +++ b/server/src/services/stack.service.spec.ts @@ -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 }); diff --git a/server/src/services/storage-template.service.spec.ts b/server/src/services/storage-template.service.spec.ts index 57343bb622..9d7262246c 100644 --- a/server/src/services/storage-template.service.spec.ts +++ b/server/src/services/storage-template.service.spec.ts @@ -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', diff --git a/server/src/services/sync.service.spec.ts b/server/src/services/sync.service.spec.ts index 395ff86099..3b7fbfcd95 100644 --- a/server/src/services/sync.service.spec.ts +++ b/server/src/services/sync.service.spec.ts @@ -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({ length: 10_000 }).fill(asset)); + mocks.asset.getChangedDeltaSync.mockResolvedValue( + Array.from>({ 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); diff --git a/server/src/services/view.service.spec.ts b/server/src/services/view.service.spec.ts index 7b26fb5eb3..a4bc51b0cc 100644 --- a/server/src/services/view.service.spec.ts +++ b/server/src/services/view.service.spec.ts @@ -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); diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 4a57cd1a98..0be0317885 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -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 = (update: T, template: unkno return update; }; -/** Modifies toJson return type to not set all properties as nullable */ -export function toJson>( - eb: ExpressionBuilder, - table: T, -) { - return eb.fn.toJson(table) as ExpressionWrapper< - DB, - TB, - Simplify< - T extends TB - ? Selectable extends Nullable - ? N | null - : Selectable - : T extends Expression - ? O extends Nullable - ? 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(qb: SelectQueryBuilder) export function withExif(qb: SelectQueryBuilder) { return qb .leftJoin('asset_exif', 'asset.id', 'asset_exif.assetId') - .select((eb) => eb.fn.toJson(eb.table('asset_exif')).$castTo().as('exifInfo')); + .select((eb) => + jsonObjectFrom(eb.table('asset_exif')) + .$castTo> | null>() + .as('exifInfo'), + ); } export function withExifInner(qb: SelectQueryBuilder) { return qb .innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId') - .select((eb) => eb.fn.toJson(eb.table('asset_exif')).$castTo().as('exifInfo')); + .select((eb) => jsonObjectFrom(eb.table('asset_exif')).as('exifInfo')) + .$narrowType<{ exifInfo: NotNull }>(); } export function withSmartSearch(qb: SelectQueryBuilder) { 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, withHidden?: boolean, withDeletedFace?: boolean) { @@ -164,7 +147,7 @@ export function withFacesAndPeople( (join) => join.onTrue(), ) .selectAll('asset_face') - .select((eb) => eb.table('person').$castTo().as('person')) + .select((eb) => eb.table('person').$castTo>().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)), diff --git a/server/test/factories/memory.factory.ts b/server/test/factories/memory.factory.ts new file mode 100644 index 0000000000..bda1d15c25 --- /dev/null +++ b/server/test/factories/memory.factory.ts @@ -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) {} + + 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) { + this.#assets.push(build(AssetFactory.from(asset), builder)); + return this; + } + + build() { + return { ...this.value, assets: this.#assets.map((asset) => asset.build()) }; + } +} diff --git a/server/test/factories/shared-link.factory.ts b/server/test/factories/shared-link.factory.ts index 5ac5f1756b..a37283df75 100644 --- a/server/test/factories/shared-link.factory.ts +++ b/server/test/factories/shared-link.factory.ts @@ -51,12 +51,14 @@ export class SharedLinkFactory { album(dto: AlbumLike = {}, builder?: FactoryBuilder) { this.#album = build(AlbumFactory.from(dto), builder); + this.value.type = SharedLinkType.Album; return this; } asset(dto: AssetLike = {}, builder?: FactoryBuilder) { const asset = build(AssetFactory.from(dto), builder); this.#assets.push(asset); + this.value.type = SharedLinkType.Individual; return this; } diff --git a/server/test/factories/types.ts b/server/test/factories/types.ts index c5a327a624..0e070c1bcc 100644 --- a/server/test/factories/types.ts +++ b/server/test/factories/types.ts @@ -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>; export type AssetFaceLike = Partial>; export type PersonLike = Partial>; export type StackLike = Partial>; +export type MemoryLike = Partial>; diff --git a/server/test/fixtures/shared-link.stub.ts b/server/test/fixtures/shared-link.stub.ts index a42ff743bc..ac073c299d 100644 --- a/server/test/fixtures/shared-link.stub.ts +++ b/server/test/fixtures/shared-link.stub.ts @@ -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, diff --git a/server/test/mappers.ts b/server/test/mappers.ts index 7ccd61a48c..662afdb19f 100644 --- a/server/test/mappers.ts +++ b/server/test/mappers.ts @@ -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) => { return { @@ -47,6 +62,222 @@ export const getForFacialRecognitionJob = ( asset: Pick, '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) => ({ + ...user, + profileChangedAt: user.profileChangedAt.toISOString(), +}); + +export const getDehydratedAsset = (asset: Selectable): ShallowDehydrateObject> => ({ + ...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): ShallowDehydrateObject> => ({ + ...exif, + dateTimeOriginal: exif.dateTimeOriginal?.toISOString() ?? null, + modifyDate: exif.modifyDate?.toISOString() ?? null, + updatedAt: exif.updatedAt.toISOString(), +}); + +const getDehydratedSharedLink = ( + sharedLink: Selectable, +): ShallowDehydrateObject> => ({ + ...sharedLink, + createdAt: sharedLink.createdAt.toISOString(), + expiresAt: sharedLink.expiresAt?.toISOString() ?? null, + key: sharedLink.key.toString(), +}); + +const getDehydratedPerson = (person: Selectable): ShallowDehydrateObject> => ({ + ...person, + birthDate: person.birthDate?.toISOString() ?? null, + createdAt: person.createdAt.toISOString(), + updatedAt: person.updatedAt.toISOString(), +}); + +const getDehydratedFace = (face: Selectable): ShallowDehydrateObject> => ({ + ...face, + deletedAt: face.deletedAt?.toISOString() ?? null, + updatedAt: face.updatedAt.toISOString(), +}); + +const getDehydratedStack = (stack: Selectable): ShallowDehydrateObject> => ({ + ...stack, + createdAt: stack.createdAt.toISOString(), + updatedAt: stack.updatedAt.toISOString(), +}); + +const getDehydratedFile = (file: Selectable): ShallowDehydrateObject> => ({ + ...file, + createdAt: file.createdAt.toISOString(), + updatedAt: file.updatedAt.toISOString(), +}); + +const getDehydratedAlbum = (album: Selectable): ShallowDehydrateObject> => ({ + ...album, + createdAt: album.createdAt.toISOString(), + deletedAt: album.deletedAt ? album.deletedAt.toISOString() : null, + updatedAt: album.updatedAt.toISOString(), +}); + +export const getForAlbum = (album: ReturnType) => ({ + ...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 & { user: ReturnType }) => ({ + ...activity, + user: getDehydratedUser(activity.user), +}); + +export const getForAsset = (asset: ReturnType) => { + 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 & Record<'sharedWith' | 'sharedBy', ReturnType>, +) => ({ + ...partner, + sharedBy: getDehydratedUser(partner.sharedBy), + sharedWith: getDehydratedUser(partner.sharedWith), +}); + +export const getForMemory = (memory: ReturnType) => ({ + ...memory, + assets: memory.assets.map((asset) => getDehydratedAsset(asset)), +}); + +export const getForMetadataExtraction = (asset: ReturnType) => ({ + 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) => ({ + 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) => ({ + ...face, + person: face.person ? getDehydratedPerson(face.person) : null, +}); + +export const getForDetectedFaces = (asset: ReturnType) => ({ + 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) => ({ + id: asset.id, + originalPath: asset.originalPath, + files: asset.files.map((file) => getDehydratedFile(file)), + exifInfo: getDehydratedExif(asset.exifInfo), +}); + +export const getForAssetDeletion = (asset: ReturnType) => ({ + 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) => ({ + ...stack, + assets: stack.assets.map((asset) => ({ + ...getDehydratedAsset(asset), + exifInfo: getDehydratedExif(asset.exifInfo), + })), +}); + +export const getForDuplicate = (asset: ReturnType) => ({ + ...getDehydratedAsset(asset), + exifInfo: getDehydratedExif(asset.exifInfo), +}); + +export const getForSharedLink = (sharedLink: ReturnType) => ({ + ...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, +}); diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 06a5798405..fb7c917688 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -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 = {}) => { return { id, isAdmin, name, email, quotaUsageInBytes, quotaSizeInBytes }; }; -const partnerFactory = (partner: Partial = {}) => { - const sharedBy = userFactory(partner.sharedBy || {}); - const sharedWith = userFactory(partner.sharedWith || {}); +const partnerFactory = ({ + sharedBy: sharedByProvided, + sharedWith: sharedWithProvided, + ...partner +}: Partial = {}) => { + const hydrateUser = (user: Partial>) => ({ + ...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) => ({ ...dto, }); -const stackFactory = ({ owner, assets, ...stack }: DeepPartial = {}): 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 = {}) => ({ id: newUuid(), name: 'Test User', @@ -276,14 +268,14 @@ const assetFactory = ( }; }; -const activityFactory = (activity: Partial = {}) => { +const activityFactory = (activity: Partial> = {}) => { 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, }); -const memoryFactory = (memory: Partial = {}) => ({ - 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, }); -const faceFactory = ({ person, ...face }: DeepPartial = {}): 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 => { 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,