From 5dcee8d754bfb91862e29485bbd29cc1477fd9fa Mon Sep 17 00:00:00 2001 From: Daniel Dietzler Date: Tue, 20 Jan 2026 17:24:09 +0100 Subject: [PATCH] feat: more factories for small tests --- server/src/config.ts | 4 +- server/src/services/metadata.service.spec.ts | 37 ++-- server/src/services/tag.service.spec.ts | 11 +- server/src/types.ts | 27 +-- server/test/small.factory.ts | 196 +++++++++++++++---- 5 files changed, 210 insertions(+), 65 deletions(-) diff --git a/server/src/config.ts b/server/src/config.ts index 9b5fafd605..62f7841b4a 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -15,7 +15,7 @@ import { } from 'src/enum'; import { ConcurrentQueueName, FullsizeImageOptions, ImageOptions } from 'src/types'; -export interface SystemConfig { +export type SystemConfig = { backup: { database: { enabled: boolean; @@ -187,7 +187,7 @@ export interface SystemConfig { user: { deleteDelay: number; }; -} +}; export type MachineLearningConfig = SystemConfig['machineLearning']; diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index 6395e66e31..942817a213 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -387,7 +387,7 @@ describe(MetadataService.name, () => { it('should extract tags from TagsList', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any); + mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) }); mockReadTags({ TagsList: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -398,7 +398,7 @@ describe(MetadataService.name, () => { it('should extract hierarchy from TagsList', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child'] } } as any); + mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) }); mockReadTags({ TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); @@ -419,7 +419,7 @@ describe(MetadataService.name, () => { it('should extract tags from Keywords as a string', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any); + mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) }); mockReadTags({ Keywords: 'Parent' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -430,7 +430,7 @@ describe(MetadataService.name, () => { it('should extract tags from Keywords as a list', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent'] } } as any); + mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent'] }) }); mockReadTags({ Keywords: ['Parent'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -441,7 +441,10 @@ describe(MetadataService.name, () => { it('should extract tags from Keywords as a list with a number', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent', '2024'] } } as any); + mocks.asset.getById.mockResolvedValue({ + ...factory.asset(), + exifInfo: factory.exif({ tags: ['Parent', '2024'] }), + }); mockReadTags({ Keywords: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -453,7 +456,7 @@ describe(MetadataService.name, () => { it('should extract hierarchal tags from Keywords', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child'] } } as any); + mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Parent/Child'] }) }); mockReadTags({ Keywords: 'Parent/Child' }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -473,7 +476,10 @@ describe(MetadataService.name, () => { it('should ignore Keywords when TagsList is present', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'Child'] } } as any); + mocks.asset.getById.mockResolvedValue({ + ...factory.asset(), + exifInfo: factory.exif({ tags: ['Parent/Child', 'Child'] }), + }); mockReadTags({ Keywords: 'Child', TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -493,7 +499,10 @@ describe(MetadataService.name, () => { it('should extract hierarchy from HierarchicalSubject', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'TagA'] } } as any); + mocks.asset.getById.mockResolvedValue({ + ...factory.asset(), + exifInfo: factory.exif({ tags: ['Parent/Child', 'TagA'] }), + }); mockReadTags({ HierarchicalSubject: ['Parent|Child', 'TagA'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.childUpsert); @@ -515,7 +524,10 @@ describe(MetadataService.name, () => { it('should extract tags from HierarchicalSubject as a list with a number', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(removeNonSidecarFiles(assetStub.image)); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent', '2024'] } } as any); + mocks.asset.getById.mockResolvedValue({ + ...factory.asset(), + exifInfo: factory.exif({ tags: ['Parent', '2024'] }), + }); mockReadTags({ HierarchicalSubject: ['Parent', 2024] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); @@ -527,7 +539,7 @@ describe(MetadataService.name, () => { it('should extract ignore / characters in a HierarchicalSubject tag', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Mom|Dad'] } } as any); + mocks.asset.getById.mockResolvedValue({ ...factory.asset(), exifInfo: factory.exif({ tags: ['Mom|Dad'] }) }); mockReadTags({ HierarchicalSubject: ['Mom/Dad'] }); mocks.tag.upsertValue.mockResolvedValueOnce(tagStub.parentUpsert); @@ -542,7 +554,10 @@ describe(MetadataService.name, () => { it('should ignore HierarchicalSubject when TagsList is present', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image); - mocks.asset.getById.mockResolvedValue({ exifInfo: { tags: ['Parent/Child', 'Parent2/Child2'] } } as any); + mocks.asset.getById.mockResolvedValue({ + ...factory.asset(), + exifInfo: factory.exif({ tags: ['Parent/Child', 'Parent2/Child2'] }), + }); mockReadTags({ HierarchicalSubject: ['Parent2|Child2'], TagsList: ['Parent/Child'] }); mocks.tag.upsertValue.mockResolvedValue(tagStub.parentUpsert); diff --git a/server/src/services/tag.service.spec.ts b/server/src/services/tag.service.spec.ts index a80e6d508b..f42f40940d 100644 --- a/server/src/services/tag.service.spec.ts +++ b/server/src/services/tag.service.spec.ts @@ -4,6 +4,7 @@ import { JobStatus } from 'src/enum'; import { TagService } from 'src/services/tag.service'; import { authStub } from 'test/fixtures/auth.stub'; import { tagResponseStub, tagStub } from 'test/fixtures/tag.stub'; +import { factory } from 'test/small.factory'; import { newTestService, ServiceMocks } from 'test/utils'; describe(TagService.name, () => { @@ -191,7 +192,10 @@ describe(TagService.name, () => { it('should upsert records', async () => { mocks.access.tag.checkOwnerAccess.mockResolvedValue(new Set(['tag-1', 'tag-2'])); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-1', 'asset-2', 'asset-3'])); - mocks.asset.getById.mockResolvedValue({ tags: [{ value: 'tag-1' }, { value: 'tag-2' }] } as any); + mocks.asset.getById.mockResolvedValue({ + ...factory.asset(), + tags: [factory.tag({ value: 'tag-1' }), factory.tag({ value: 'tag-2' })], + }); mocks.tag.upsertAssetIds.mockResolvedValue([ { tagId: 'tag-1', assetId: 'asset-1' }, { tagId: 'tag-1', assetId: 'asset-2' }, @@ -242,7 +246,10 @@ describe(TagService.name, () => { mocks.tag.get.mockResolvedValue(tagStub.tag); mocks.tag.getAssetIds.mockResolvedValue(new Set(['asset-1'])); mocks.tag.addAssetIds.mockResolvedValue(); - mocks.asset.getById.mockResolvedValue({ tags: [{ value: 'tag-1' }] } as any); + mocks.asset.getById.mockResolvedValue({ + ...factory.asset(), + tags: [factory.tag({ value: 'tag-1' })], + }); mocks.access.asset.checkOwnerAccess.mockResolvedValue(new Set(['asset-2'])); await expect( diff --git a/server/src/types.ts b/server/src/types.ts index f3b647f08f..afcaa6509b 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -23,34 +23,39 @@ import { VideoCodec, } from 'src/enum'; -export type DeepPartial = T extends object ? { [K in keyof T]?: DeepPartial } : T; +export type DeepPartial = + T extends Record + ? { [K in keyof T]?: DeepPartial } + : T extends Array + ? DeepPartial[] + : T; export type RepositoryInterface = Pick; -export interface FullsizeImageOptions { +export type FullsizeImageOptions = { format: ImageFormat; quality: number; enabled: boolean; -} +}; -export interface ImageOptions { +export type ImageOptions = { format: ImageFormat; quality: number; size: number; -} +}; -export interface RawImageInfo { +export type RawImageInfo = { width: number; height: number; channels: 1 | 2 | 3 | 4; -} +}; -interface DecodeImageOptions { +type DecodeImageOptions = { colorspace: string; processInvalidImages: boolean; raw?: RawImageInfo; edits?: AssetEditActionItem[]; -} +}; export interface DecodeToBufferOptions extends DecodeImageOptions { size?: number; @@ -504,7 +509,7 @@ export interface SystemMetadata extends Record = { key: T; diff --git a/server/test/small.factory.ts b/server/test/small.factory.ts index 900296d040..37c5400e58 100644 --- a/server/test/small.factory.ts +++ b/server/test/small.factory.ts @@ -1,6 +1,7 @@ import { Activity, ApiKey, + AssetFace, AssetFile, AuthApiKey, AuthSharedLink, @@ -9,12 +10,16 @@ import { Library, Memory, Partner, + Person, Session, + Stack, + Tag, User, UserAdmin, } from 'src/database'; import { MapAsset } from 'src/dtos/asset-response.dto'; import { AuthDto } from 'src/dtos/auth.dto'; +import { AssetEditAction, AssetEditActionItem, MirrorAxis } from 'src/dtos/editing.dto'; import { QueueStatisticsDto } from 'src/dtos/queue.dto'; import { AssetFileType, @@ -23,10 +28,11 @@ import { AssetVisibility, MemoryType, Permission, + SourceType, UserMetadataKey, UserStatus, } from 'src/enum'; -import { OnThisDayData, UserMetadataItem } from 'src/types'; +import { DeepPartial, OnThisDayData, UserMetadataItem } from 'src/types'; import { v4, v7 } from 'uuid'; export const newUuid = () => v4(); @@ -160,11 +166,18 @@ const queueStatisticsFactory = (dto?: Partial) => ({ ...dto, }); -const stackFactory = () => ({ - id: newUuid(), - ownerId: newUuid(), - primaryAssetId: newUuid(), -}); +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(), @@ -223,39 +236,43 @@ const userAdminFactory = (user: Partial = {}) => { }; }; -const assetFactory = (asset: Partial = {}) => ({ - id: newUuid(), - createdAt: newDate(), - updatedAt: newDate(), - deletedAt: null, - updateId: newUuidV7(), - status: AssetStatus.Active, - checksum: newSha1(), - deviceAssetId: '', - deviceId: '', - duplicateId: null, - duration: null, - encodedVideoPath: null, - fileCreatedAt: newDate(), - fileModifiedAt: newDate(), - isExternal: false, - isFavorite: false, - isOffline: false, - libraryId: null, - livePhotoVideoId: null, - localDateTime: newDate(), - originalFileName: 'IMG_123.jpg', - originalPath: `/data/12/34/IMG_123.jpg`, - ownerId: newUuid(), - stackId: null, - thumbhash: null, - type: AssetType.Image, - visibility: AssetVisibility.Timeline, - width: null, - height: null, - isEdited: false, - ...asset, -}); +const assetFactory = ( + asset: Omit, 'exifInfo' | 'owner' | 'stack' | 'tags' | 'faces' | 'files' | 'edits'> = {}, +) => { + return { + id: newUuid(), + createdAt: newDate(), + updatedAt: newDate(), + deletedAt: null, + updateId: newUuidV7(), + status: AssetStatus.Active, + checksum: newSha1(), + deviceAssetId: '', + deviceId: '', + duplicateId: null, + duration: null, + encodedVideoPath: null, + fileCreatedAt: newDate(), + fileModifiedAt: newDate(), + isExternal: false, + isFavorite: false, + isOffline: false, + libraryId: null, + livePhotoVideoId: null, + localDateTime: newDate(), + originalFileName: 'IMG_123.jpg', + originalPath: `/data/12/34/IMG_123.jpg`, + ownerId: newUuid(), + stackId: null, + thumbhash: null, + type: AssetType.Image, + visibility: AssetVisibility.Timeline, + width: null, + height: null, + isEdited: false, + ...asset, + }; +}; const activityFactory = (activity: Partial = {}) => { const userId = activity.userId || newUuid(); @@ -391,6 +408,102 @@ const assetFileFactory = (file: Partial = {}): AssetFile => ({ ...file, }); +const exifFactory = (exif: Partial = {}) => ({ + assetId: newUuid(), + autoStackId: null, + bitsPerSample: null, + city: 'Austin', + colorspace: null, + country: 'United States of America', + dateTimeOriginal: newDate(), + description: '', + exifImageHeight: 420, + exifImageWidth: 42, + exposureTime: null, + fileSizeInByte: 69, + fNumber: 1.7, + focalLength: 4.38, + fps: null, + iso: 947, + latitude: 30.267_334_570_570_195, + longitude: -97.789_833_534_282_07, + lensModel: null, + livePhotoCID: null, + make: 'Google', + model: 'Pixel 7', + modifyDate: newDate(), + orientation: '1', + profileDescription: null, + projectionType: null, + rating: 4, + state: 'Texas', + tags: ['parent/child'], + timeZone: 'UTC-6', + ...exif, +}); + +const tagFactory = (tag: Partial): Tag => ({ + id: newUuid(), + color: null, + createdAt: newDate(), + parentId: null, + updatedAt: newDate(), + value: `tag-${newUuid()}`, + ...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: { + return { action: AssetEditAction.Crop, parameters: { height: 42, width: 42, x: 0, y: 10 }, ...edit }; + } + case AssetEditAction.Mirror: { + return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Horizontal }, ...edit }; + } + case AssetEditAction.Rotate: { + return { action: AssetEditAction.Rotate, parameters: { angle: 90 }, ...edit }; + } + default: { + return { action: AssetEditAction.Mirror, parameters: { axis: MirrorAxis.Vertical } }; + } + } +}; + +const personFactory = (person?: Partial): Person => ({ + birthDate: newDate(), + color: null, + createdAt: newDate(), + faceAssetId: null, + id: newUuid(), + isFavorite: false, + isHidden: false, + name: 'person', + ownerId: newUuid(), + thumbnailPath: '/path/to/person/thumbnail.jpg', + updatedAt: newDate(), + updateId: newUuidV7(), + ...person, +}); + export const factory = { activity: activityFactory, apiKey: apiKeyFactory, @@ -412,6 +525,11 @@ export const factory = { jobAssets: { sidecarWrite: assetSidecarWriteFactory, }, + exif: exifFactory, + face: faceFactory, + person: personFactory, + assetEdit: assetEditFactory, + tag: tagFactory, uuid: newUuid, date: newDate, responses: {