diff --git a/server/src/constants.ts b/server/src/constants.ts index 20ce7dd4979fc..3e946578ab336 100644 --- a/server/src/constants.ts +++ b/server/src/constants.ts @@ -7,10 +7,6 @@ export const POSTGRES_VERSION_RANGE = '>=14.0.0'; export const VECTORS_VERSION_RANGE = '>=0.2 <0.4'; export const VECTOR_VERSION_RANGE = '>=0.5 <1'; -export const ASSET_FILE_CONFLICT_KEYS = ['assetId', 'type'] as const; -export const EXIF_CONFLICT_KEYS = ['assetId'] as const; -export const JOB_STATUS_CONFLICT_KEYS = ['assetId'] as const; - export const NEXT_RELEASE = 'NEXT_RELEASE'; export const LIFECYCLE_EXTENSION = 'x-immich-lifecycle'; export const DEPRECATED_IN_PREFIX = 'This property was deprecated in '; diff --git a/server/src/db.d.ts b/server/src/db.d.ts index 7fb073d8ce35d..0443a8a717004 100644 --- a/server/src/db.d.ts +++ b/server/src/db.d.ts @@ -4,7 +4,7 @@ */ import type { ColumnType } from 'kysely'; -import { Permission, SyncEntityType } from 'src/enum'; +import { AssetType, Permission, SyncEntityType } from 'src/enum'; export type ArrayType = ArrayTypeImpl extends (infer U)[] ? U[] : ArrayTypeImpl; @@ -145,7 +145,7 @@ export interface Assets { stackId: string | null; status: Generated; thumbhash: Buffer | null; - type: string; + type: AssetType; updatedAt: Generated; updateId: Generated; } @@ -316,7 +316,6 @@ export interface SessionSyncCheckpoints { updateId: Generated; } - export interface SharedLinkAsset { assetsId: string; sharedLinksId: string; diff --git a/server/src/repositories/asset.repository.ts b/server/src/repositories/asset.repository.ts index daefacef096f9..953c2049f0b5f 100644 --- a/server/src/repositories/asset.repository.ts +++ b/server/src/repositories/asset.repository.ts @@ -2,7 +2,6 @@ import { Injectable } from '@nestjs/common'; import { Insertable, Kysely, Updateable, sql } from 'kysely'; import { isEmpty, isUndefined, omitBy } from 'lodash'; import { InjectKysely } from 'nestjs-kysely'; -import { ASSET_FILE_CONFLICT_KEYS, EXIF_CONFLICT_KEYS, JOB_STATUS_CONFLICT_KEYS } from 'src/constants'; import { AssetFiles, AssetJobStatus, Assets, DB, Exif } from 'src/db'; import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { @@ -24,7 +23,7 @@ import { } from 'src/entities/asset.entity'; import { AssetFileType, AssetOrder, AssetStatus, AssetType } from 'src/enum'; import { AssetSearchOptions, SearchExploreItem, SearchExploreItemSet } from 'src/repositories/search.repository'; -import { anyUuid, asUuid, mapUpsertColumns } from 'src/utils/database'; +import { anyUuid, asUuid } from 'src/utils/database'; import { Paginated, PaginationOptions, paginationHelper } from 'src/utils/pagination'; export type AssetStats = Record; @@ -146,11 +145,6 @@ export interface DuplicateGroup { assets: AssetEntity[]; } -export interface DayOfYearAssets { - yearsAgo: number; - assets: AssetEntity[]; -} - @Injectable() export class AssetRepository { constructor(@InjectKysely() private db: Kysely) {} @@ -161,7 +155,36 @@ export class AssetRepository { .insertInto('exif') .values(value) .onConflict((oc) => - oc.columns(EXIF_CONFLICT_KEYS).doUpdateSet(() => mapUpsertColumns('exif', value, EXIF_CONFLICT_KEYS)), + oc.column('assetId').doUpdateSet((eb) => ({ + description: eb.ref('excluded.description'), + exifImageWidth: eb.ref('excluded.exifImageWidth'), + exifImageHeight: eb.ref('excluded.exifImageHeight'), + fileSizeInByte: eb.ref('excluded.fileSizeInByte'), + orientation: eb.ref('excluded.orientation'), + dateTimeOriginal: eb.ref('excluded.dateTimeOriginal'), + modifyDate: eb.ref('excluded.modifyDate'), + timeZone: eb.ref('excluded.timeZone'), + latitude: eb.ref('excluded.latitude'), + longitude: eb.ref('excluded.longitude'), + projectionType: eb.ref('excluded.projectionType'), + city: eb.ref('excluded.city'), + livePhotoCID: eb.ref('excluded.livePhotoCID'), + autoStackId: eb.ref('excluded.autoStackId'), + state: eb.ref('excluded.state'), + country: eb.ref('excluded.country'), + make: eb.ref('excluded.make'), + model: eb.ref('excluded.model'), + lensModel: eb.ref('excluded.lensModel'), + fNumber: eb.ref('excluded.fNumber'), + focalLength: eb.ref('excluded.focalLength'), + iso: eb.ref('excluded.iso'), + exposureTime: eb.ref('excluded.exposureTime'), + profileDescription: eb.ref('excluded.profileDescription'), + colorspace: eb.ref('excluded.colorspace'), + bitsPerSample: eb.ref('excluded.bitsPerSample'), + rating: eb.ref('excluded.rating'), + fps: eb.ref('excluded.fps'), + })), ) .execute(); } @@ -176,9 +199,13 @@ export class AssetRepository { .insertInto('asset_job_status') .values(values) .onConflict((oc) => - oc - .columns(JOB_STATUS_CONFLICT_KEYS) - .doUpdateSet(() => mapUpsertColumns('asset_job_status', values[0], JOB_STATUS_CONFLICT_KEYS)), + oc.column('assetId').doUpdateSet((eb) => ({ + duplicatesDetectedAt: eb.ref('excluded.duplicatesDetectedAt'), + facesRecognizedAt: eb.ref('excluded.facesRecognizedAt'), + metadataExtractedAt: eb.ref('excluded.metadataExtractedAt'), + previewAt: eb.ref('excluded.previewAt'), + thumbnailAt: eb.ref('excluded.thumbnailAt'), + })), ) .execute(); } @@ -192,7 +219,7 @@ export class AssetRepository { } @GenerateSql({ params: [DummyValue.UUID, { day: 1, month: 1 }] }) - getByDayOfYear(ownerIds: string[], { day, month }: MonthDay): Promise { + getByDayOfYear(ownerIds: string[], { day, month }: MonthDay) { return this.db .with('res', (qb) => qb @@ -248,7 +275,7 @@ export class AssetRepository { .groupBy(sql`("localDateTime" at time zone 'UTC')::date`) .orderBy(sql`("localDateTime" at time zone 'UTC')::date`, 'desc') .limit(10) - .execute() as any as Promise; + .execute(); } @GenerateSql({ params: [[DummyValue.UUID]] }) @@ -914,15 +941,16 @@ export class AssetRepository { .execute() as any as Promise; } + // TODO remove in favor of `upsertFiles` async upsertFile(file: Pick, 'assetId' | 'path' | 'type'>): Promise { const value = { ...file, assetId: asUuid(file.assetId) }; await this.db .insertInto('asset_files') .values(value) .onConflict((oc) => - oc - .columns(ASSET_FILE_CONFLICT_KEYS) - .doUpdateSet(() => mapUpsertColumns('asset_files', value, ASSET_FILE_CONFLICT_KEYS)), + oc.columns(['assetId', 'type']).doUpdateSet((eb) => ({ + path: eb.ref('excluded.path'), + })), ) .execute(); } @@ -937,9 +965,9 @@ export class AssetRepository { .insertInto('asset_files') .values(values) .onConflict((oc) => - oc - .columns(ASSET_FILE_CONFLICT_KEYS) - .doUpdateSet(() => mapUpsertColumns('asset_files', values[0], ASSET_FILE_CONFLICT_KEYS)), + oc.columns(['assetId', 'type']).doUpdateSet((eb) => ({ + path: eb.ref('excluded.path'), + })), ) .execute(); } diff --git a/server/src/repositories/database.repository.ts b/server/src/repositories/database.repository.ts index ef97147c61cfc..abd47f1167213 100644 --- a/server/src/repositories/database.repository.ts +++ b/server/src/repositories/database.repository.ts @@ -1,7 +1,7 @@ import { Injectable } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import AsyncLock from 'async-lock'; -import { Kysely, sql } from 'kysely'; +import { Kysely } from 'kysely'; import { InjectKysely } from 'nestjs-kysely'; import semver from 'semver'; import { EXTENSION_NAMES, POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants'; @@ -10,9 +10,8 @@ import { DatabaseExtension, DatabaseLock, VectorIndex } from 'src/enum'; import { ConfigRepository } from 'src/repositories/config.repository'; import { LoggingRepository } from 'src/repositories/logging.repository'; import { ExtensionVersion, VectorExtension, VectorUpdateResult } from 'src/types'; -import { UPSERT_COLUMNS } from 'src/utils/database'; import { isValidInteger } from 'src/validation'; -import { DataSource, EntityManager, EntityMetadata, QueryRunner } from 'typeorm'; +import { DataSource, EntityManager, QueryRunner } from 'typeorm'; @Injectable() export class DatabaseRepository { @@ -33,13 +32,6 @@ export class DatabaseRepository { await this.db.destroy(); } - init() { - for (const metadata of this.dataSource.entityMetadatas) { - const table = metadata.tableName as keyof DB; - UPSERT_COLUMNS[table] = this.getUpsertColumns(metadata); - } - } - async reconnect() { try { if (this.dataSource.isInitialized) { @@ -257,10 +249,4 @@ export class DatabaseRepository { private async releaseLock(lock: DatabaseLock, queryRunner: QueryRunner): Promise { return queryRunner.query('SELECT pg_advisory_unlock($1)', [lock]); } - - private getUpsertColumns(metadata: EntityMetadata) { - return Object.fromEntries( - metadata.ownColumns.map((column) => [column.propertyName, sql`excluded.${sql.ref(column.propertyName)}`]), - ) as any; - } } diff --git a/server/src/repositories/person.repository.ts b/server/src/repositories/person.repository.ts index 2ac2a59cf14a4..d09dd61248670 100644 --- a/server/src/repositories/person.repository.ts +++ b/server/src/repositories/person.repository.ts @@ -7,7 +7,6 @@ import { ChunkedArray, DummyValue, GenerateSql } from 'src/decorators'; import { AssetFaceEntity } from 'src/entities/asset-face.entity'; import { PersonEntity } from 'src/entities/person.entity'; import { SourceType } from 'src/enum'; -import { mapUpsertColumns } from 'src/utils/database'; import { Paginated, PaginationOptions } from 'src/utils/pagination'; import { FindOptionsRelations } from 'typeorm'; @@ -417,7 +416,17 @@ export class PersonRepository { await this.db .insertInto('person') .values(people) - .onConflict((oc) => oc.column('id').doUpdateSet(() => mapUpsertColumns('person', people[0], ['id']))) + .onConflict((oc) => + oc.column('id').doUpdateSet((eb) => ({ + birthDate: eb.ref('excluded.birthDate'), + color: eb.ref('excluded.color'), + faceAssetId: eb.ref('excluded.faceAssetId'), + isFavorite: eb.ref('excluded.isFavorite'), + isHidden: eb.ref('excluded.isHidden'), + name: eb.ref('excluded.name'), + thumbnailPath: eb.ref('excluded.thumbnailPath'), + })), + ) .execute(); } diff --git a/server/src/repositories/search.repository.ts b/server/src/repositories/search.repository.ts index 46f38db55f84d..e2e389f47c95f 100644 --- a/server/src/repositories/search.repository.ts +++ b/server/src/repositories/search.repository.ts @@ -404,7 +404,7 @@ export class SearchRepository { .where('assets.ownerId', '=', anyUuid(userIds)) .where('assets.isVisible', '=', true) .where('assets.isArchived', '=', false) - .where('assets.type', '=', 'IMAGE') + .where('assets.type', '=', AssetType.IMAGE) .where('assets.deletedAt', 'is', null) .orderBy('city') .limit(1); @@ -421,7 +421,7 @@ export class SearchRepository { .where('assets.ownerId', '=', anyUuid(userIds)) .where('assets.isVisible', '=', true) .where('assets.isArchived', '=', false) - .where('assets.type', '=', 'IMAGE') + .where('assets.type', '=', AssetType.IMAGE) .where('assets.deletedAt', 'is', null) .whereRef('exif.city', '>', 'cte.city') .orderBy('city') diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 302f868971553..1387828be6637 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -5,7 +5,7 @@ import { DB, UserMetadata as DbUserMetadata, Users } from 'src/db'; import { DummyValue, GenerateSql } from 'src/decorators'; import { UserMetadata, UserMetadataItem } from 'src/entities/user-metadata.entity'; import { UserEntity, withMetadata } from 'src/entities/user.entity'; -import { UserStatus } from 'src/enum'; +import { AssetType, UserStatus } from 'src/enum'; import { asUuid } from 'src/utils/database'; const columns = [ @@ -209,11 +209,11 @@ export class UserRepository { .select((eb) => [ eb.fn .countAll() - .filterWhere((eb) => eb.and([eb('assets.type', '=', 'IMAGE'), eb('assets.isVisible', '=', true)])) + .filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.IMAGE), eb('assets.isVisible', '=', true)])) .as('photos'), eb.fn .countAll() - .filterWhere((eb) => eb.and([eb('assets.type', '=', 'VIDEO'), eb('assets.isVisible', '=', true)])) + .filterWhere((eb) => eb.and([eb('assets.type', '=', AssetType.VIDEO), eb('assets.isVisible', '=', true)])) .as('videos'), eb.fn .coalesce(eb.fn.sum('exif.fileSizeInByte').filterWhere('assets.libraryId', 'is', null), eb.lit(0)) @@ -222,7 +222,9 @@ export class UserRepository { .coalesce( eb.fn .sum('exif.fileSizeInByte') - .filterWhere((eb) => eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', 'IMAGE')])), + .filterWhere((eb) => + eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', AssetType.IMAGE)]), + ), eb.lit(0), ) .as('usagePhotos'), @@ -230,7 +232,9 @@ export class UserRepository { .coalesce( eb.fn .sum('exif.fileSizeInByte') - .filterWhere((eb) => eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', 'VIDEO')])), + .filterWhere((eb) => + eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', AssetType.VIDEO)]), + ), eb.lit(0), ) .as('usageVideos'), diff --git a/server/src/services/asset.service.spec.ts b/server/src/services/asset.service.spec.ts index 336c3ac8f0acd..917e15ec90970 100755 --- a/server/src/services/asset.service.spec.ts +++ b/server/src/services/asset.service.spec.ts @@ -75,7 +75,7 @@ describe(AssetService.name, () => { yearsAgo: 15, assets: [image4], }, - ]); + ] as any); await expect(sut.getMemoryLane(authStub.admin, { day: 15, month: 1 })).resolves.toEqual([ { yearsAgo: 1, title: '1 year ago', assets: [mapAsset(image1), mapAsset(image2)] }, diff --git a/server/src/services/asset.service.ts b/server/src/services/asset.service.ts index a9b723c9f91a9..77f8ce8120c0a 100644 --- a/server/src/services/asset.service.ts +++ b/server/src/services/asset.service.ts @@ -42,7 +42,7 @@ export class AssetService extends BaseService { yearsAgo, // TODO move this to clients title: `${yearsAgo} year${yearsAgo > 1 ? 's' : ''} ago`, - assets: assets.map((asset) => mapAsset(asset, { auth })), + assets: assets.map((asset) => mapAsset(asset as AssetEntity, { auth })), })); } diff --git a/server/src/services/database.service.ts b/server/src/services/database.service.ts index 36f4c0917719e..6ec3d0e2e4a63 100644 --- a/server/src/services/database.service.ts +++ b/server/src/services/database.service.ts @@ -108,7 +108,6 @@ export class DatabaseService extends BaseService { if (!database.skipMigrations) { await this.databaseRepository.runMigrations(); } - this.databaseRepository.init(); }); } diff --git a/server/src/services/memory.service.ts b/server/src/services/memory.service.ts index 10b8cee2feab5..be4d6dfc76238 100644 --- a/server/src/services/memory.service.ts +++ b/server/src/services/memory.service.ts @@ -30,20 +30,18 @@ export class MemoryService extends BaseService { const start = DateTime.utc().startOf('day').minus({ days: DAYS }); const state = await this.systemMetadataRepository.get(SystemMetadataKey.MEMORIES_STATE); - let lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state?.lastOnThisDayDate) : start; + const lastOnThisDayDate = state?.lastOnThisDayDate ? DateTime.fromISO(state.lastOnThisDayDate) : start; // generate a memory +/- X days from today - for (let i = 0; i <= DAYS * 2 + 1; i++) { + for (let i = 0; i <= DAYS * 2; i++) { const target = start.plus({ days: i }); - if (lastOnThisDayDate > target) { + if (lastOnThisDayDate >= target) { continue; } const showAt = target.startOf('day').toISO(); const hideAt = target.endOf('day').toISO(); - this.logger.log(`Creating memories for month=${target.month}, day=${target.day}`); - for (const [userId, userIds] of Object.entries(userMap)) { const memories = await this.assetRepository.getByDayOfYear(userIds, target); @@ -67,8 +65,6 @@ export class MemoryService extends BaseService { ...state, lastOnThisDayDate: target.toISO(), }); - - lastOnThisDayDate = target; } } diff --git a/server/src/utils/database.ts b/server/src/utils/database.ts index 7483ef6f9281b..74787aac181ae 100644 --- a/server/src/utils/database.ts +++ b/server/src/utils/database.ts @@ -1,6 +1,4 @@ -import { Expression, RawBuilder, sql, ValueExpression } from 'kysely'; -import { InsertObject } from 'node_modules/kysely/dist/cjs'; -import { DB } from 'src/db'; +import { Expression, sql } from 'kysely'; import { Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; /** @@ -17,27 +15,6 @@ export function OptionalBetween(from?: T, to?: T) { } } -// populated by the database repository at bootstrap -export const UPSERT_COLUMNS = {} as { [T in keyof DB]: { [K in keyof DB[T]]: RawBuilder } }; - -/** Generates the columns for an upsert statement, excluding the conflict keys. - * Assumes that all entries have the same keys. */ -export function mapUpsertColumns( - table: T, - entry: InsertObject, - conflictKeys: readonly (keyof DB[T])[], -) { - const columns = UPSERT_COLUMNS[table] as { [K in keyof DB[T]]: RawBuilder }; - const upsertColumns: Partial>> = {}; - for (const entryColumn in entry) { - if (!conflictKeys.includes(entryColumn as keyof DB[T])) { - upsertColumns[entryColumn as keyof typeof entry] = columns[entryColumn as keyof DB[T]]; - } - } - - return upsertColumns as Expand>>; -} - export const asUuid = (id: string | Expression) => sql`${id}::uuid`; export const anyUuid = (ids: string[]) => sql`any(${`{${ids}}`}::uuid[])`; diff --git a/server/test/factory.ts b/server/test/factory.ts index 983b7cbb77ee6..aeda9ab707aac 100644 --- a/server/test/factory.ts +++ b/server/test/factory.ts @@ -1,13 +1,16 @@ import { Insertable, Kysely } from 'kysely'; +import { DateTime } from 'luxon'; import { randomBytes, randomUUID } from 'node:crypto'; import { Writable } from 'node:stream'; -import { Assets, DB, Sessions, Users } from 'src/db'; +import { AssetJobStatus, Assets, DB, Exif, Sessions, Users } from 'src/db'; import { AuthDto } from 'src/dtos/auth.dto'; -import { AssetType } from 'src/enum'; +import { AssetFileType, AssetType } from 'src/enum'; import { AlbumRepository } from 'src/repositories/album.repository'; import { AssetRepository } from 'src/repositories/asset.repository'; +import { MemoryRepository } from 'src/repositories/memory.repository'; import { SessionRepository } from 'src/repositories/session.repository'; import { SyncRepository } from 'src/repositories/sync.repository'; +import { SystemMetadataRepository } from 'src/repositories/system-metadata.repository'; import { UserRepository } from 'src/repositories/user.repository'; class CustomWritable extends Writable { @@ -26,20 +29,54 @@ class CustomWritable extends Writable { .map((x) => JSON.parse(x)); } } - -type Asset = Insertable; +// deviceAssetId, deviceId, originalFileName, originalPath, type +type Asset = Omit< + Insertable, + 'checksum' | 'deviceAssetId' | 'deviceId' | 'originalFileName' | 'originalPath' | 'type' +> & { + checksum?: Buffer; + deviceAssetId?: string; + deviceId?: string; + originalFileName?: string; + originalPath?: string; + type?: AssetType; +}; type User = Partial>; type Session = Omit, 'token'> & { token?: string }; +type JobStatus = Omit>, 'assetId'> & { assetId: string }; +type AssetFile = { assetId: string; type: AssetFileType; path: string }; +type AssetExif = Insertable; +type FactoryOptions = { assetJobStatus?: boolean; assetFiles?: boolean; assetExif?: boolean }; + +const DEFAULT_OPTIONS = { + assetJobStatus: false, + assetFiles: false, + assetExif: false, +}; export const newUuid = () => randomUUID() as string; export class TestFactory { private assets: Asset[] = []; + private assetJobs: JobStatus[] = []; + private assetFiles: AssetFile[] = []; + private assetExif: AssetExif[] = []; + private options: FactoryOptions = DEFAULT_OPTIONS; private sessions: Session[] = []; private users: User[] = []; private constructor(private context: TestContext) {} + private clear() { + this.assets = []; + this.assetJobs = []; + this.assetFiles = []; + this.assetExif = []; + this.options = DEFAULT_OPTIONS; + this.sessions = []; + this.users = []; + } + static create(context: TestContext) { return new TestFactory(context); } @@ -50,14 +87,13 @@ export class TestFactory { static asset(asset: Asset) { const assetId = asset.id || newUuid(); - const defaults: Insertable = { - deviceAssetId: '', - deviceId: '', - originalFileName: '', + const defaults: Omit, 'ownerId'> = { + deviceAssetId: newUuid(), + deviceId: newUuid(), + originalFileName: `${assetId}-file.jpg`, checksum: randomBytes(32), type: AssetType.IMAGE, originalPath: '/path/to/something.jpg', - ownerId: '@immich.cloud', isVisible: true, }; @@ -68,6 +104,22 @@ export class TestFactory { }; } + static assetJobStatus(job: JobStatus): Insertable { + const date = DateTime.now().minus({ days: 15 }).toISO(); + const defaults: Omit, 'assetId'> = { + duplicatesDetectedAt: date, + facesRecognizedAt: date, + metadataExtractedAt: date, + previewAt: date, + thumbnailAt: date, + }; + + return { + ...defaults, + ...job, + }; + } + static auth(auth: { user: User; session?: Session }) { return auth as AuthDto; } @@ -100,11 +152,31 @@ export class TestFactory { }; } + withOptions(options: Partial) { + this.options = { ...this.options, ...options }; + return this; + } + withAsset(asset: Asset) { this.assets.push(asset); return this; } + withAssets(assets: Asset[]) { + this.assets.push(...assets); + return this; + } + + withAssetJob(job: JobStatus) { + this.assetJobs.push(job); + return this; + } + + withAssetJobs(jobs: JobStatus[]) { + this.assetJobs.push(...jobs); + return this; + } + withSession(session: Session) { this.sessions.push(session); return this; @@ -116,18 +188,46 @@ export class TestFactory { } async create() { + for (const user of this.users) { + await this.context.createUser(user); + } + for (const asset of this.assets) { - await this.context.createAsset(asset); + const entity = await this.context.createAsset(asset); + if (this.options.assetJobStatus) { + this.assetJobs.push({ assetId: entity.id }); + } + + if (this.options.assetFiles) { + this.assetFiles.push( + { assetId: entity.id, type: AssetFileType.PREVIEW, path: '/path/to/preview.jpg' }, + { assetId: entity.id, type: AssetFileType.THUMBNAIL, path: '/path/to/thumbnail.jpg' }, + ); + } + + if (this.options.assetExif) { + this.assetExif.push({ assetId: entity.id, make: 'Cannon' }); + } } - for (const user of this.users) { - await this.context.createUser(user); + for (const assetJob of this.assetJobs) { + await this.context.createAssetJobStatus(assetJob); + } + + for (const assetFile of this.assetFiles) { + await this.context.createAssetFile(assetFile); + } + + for (const assetExif of this.assetExif) { + await this.context.createAssetExif(assetExif); } for (const session of this.sessions) { await this.context.createSession(session); } + this.clear(); + return this.context; } } @@ -136,15 +236,19 @@ export class TestContext { userRepository: UserRepository; assetRepository: AssetRepository; albumRepository: AlbumRepository; + memoryRepository: MemoryRepository; sessionRepository: SessionRepository; syncRepository: SyncRepository; + systemMetadataRepository: SystemMetadataRepository; private constructor(private db: Kysely) { this.userRepository = new UserRepository(this.db); this.assetRepository = new AssetRepository(this.db); this.albumRepository = new AlbumRepository(this.db); + this.memoryRepository = new MemoryRepository(this.db); this.sessionRepository = new SessionRepository(this.db); this.syncRepository = new SyncRepository(this.db); + this.systemMetadataRepository = new SystemMetadataRepository(this.db); } static from(db: Kysely) { @@ -163,6 +267,18 @@ export class TestContext { return this.assetRepository.create(TestFactory.asset(asset)); } + createAssetJobStatus(jobStatus: JobStatus) { + return this.assetRepository.upsertJobStatus(TestFactory.assetJobStatus(jobStatus)); + } + + createAssetFile(assetFile: AssetFile) { + return this.assetRepository.upsertFile(assetFile); + } + + createAssetExif(assetExif: AssetExif) { + return this.assetRepository.upsertExif(assetExif); + } + createSession(session: Session) { return this.sessionRepository.create(TestFactory.session(session)); } diff --git a/server/test/medium/specs/memory.service.spec.ts b/server/test/medium/specs/memory.service.spec.ts new file mode 100644 index 0000000000000..242677a57b2a7 --- /dev/null +++ b/server/test/medium/specs/memory.service.spec.ts @@ -0,0 +1,114 @@ +import { DateTime } from 'luxon'; +import { MemoryService } from 'src/services/memory.service'; +import { TestContext, TestFactory } from 'test/factory'; +import { getKyselyDB, newTestService } from 'test/utils'; + +const setup = async () => { + const db = await getKyselyDB(); + + const context = await TestContext.from(db).withUser({ isAdmin: true }).create(); + const { sut } = newTestService(MemoryService, context); + + return { sut, context }; +}; + +describe(MemoryService.name, () => { + describe('onMemoryCreate', () => { + it('should work on an empty database', async () => { + const { sut } = await setup(); + await expect(sut.onMemoriesCreate()).resolves.not.toThrow(); + }); + + it('should create a memory from an asset', async () => { + const { sut, context } = await setup(); + + const now = DateTime.fromObject({ year: 2025, month: 2, day: 25 }, { zone: 'utc' }); + const userDto = TestFactory.user(); + const assetDto = TestFactory.asset({ ownerId: userDto.id, localDateTime: now.minus({ years: 1 }).toISO() }); + + await context + .getFactory() + .withAsset(assetDto) + .withUser(userDto) + .withOptions({ + assetFiles: true, + assetJobStatus: true, + assetExif: true, + }) + .create(); + + vi.setSystemTime(now.toJSDate()); + + await sut.onMemoriesCreate(); + + const memories = await context.memoryRepository.search(userDto.id, {}); + expect(memories.length).toBe(1); + expect(memories[0]).toEqual( + expect.objectContaining({ + id: expect.any(String), + createdAt: expect.any(Date), + memoryAt: expect.any(Date), + updatedAt: expect.any(Date), + deletedAt: null, + ownerId: userDto.id, + assets: expect.arrayContaining([expect.objectContaining({ id: assetDto.id })]), + isSaved: false, + showAt: now.startOf('day').toJSDate(), + hideAt: now.endOf('day').toJSDate(), + seenAt: null, + type: 'on_this_day', + data: { year: 2024 }, + }), + ); + }); + + it('should not generate a memory twice for the same day', async () => { + const { sut, context } = await setup(); + + const now = DateTime.fromObject({ year: 2025, month: 2, day: 20 }, { zone: 'utc' }); + const userDto = TestFactory.user(); + + await context + .getFactory() + .withAssets([ + { + ownerId: userDto.id, + localDateTime: now.minus({ year: 1 }).plus({ days: 3 }).toISO(), + }, + { + ownerId: userDto.id, + localDateTime: now.minus({ year: 1 }).plus({ days: 4 }).toISO(), + }, + { + ownerId: userDto.id, + localDateTime: now.minus({ year: 1 }).plus({ days: 5 }).toISO(), + }, + ]) + .withUser(userDto) + .withOptions({ + assetFiles: true, + assetJobStatus: true, + assetExif: true, + }) + .create(); + + vi.setSystemTime(now.toJSDate()); + + await sut.onMemoriesCreate(); + + const memories = await context.memoryRepository.search(userDto.id, {}); + expect(memories.length).toBe(1); + + await sut.onMemoriesCreate(); + const memoriesAfter = await context.memoryRepository.search(userDto.id, {}); + expect(memoriesAfter.length).toBe(1); + }); + }); + + describe('onMemoriesCleanup', () => { + it('should run without error', async () => { + const { sut } = await setup(); + await expect(sut.onMemoriesCleanup()).resolves.not.toThrow(); + }); + }); +}); diff --git a/server/test/repositories/database.repository.mock.ts b/server/test/repositories/database.repository.mock.ts index fe954c725b3de..b9134850e2bbb 100644 --- a/server/test/repositories/database.repository.mock.ts +++ b/server/test/repositories/database.repository.mock.ts @@ -4,7 +4,6 @@ import { Mocked, vitest } from 'vitest'; export const newDatabaseRepositoryMock = (): Mocked> => { return { - init: vitest.fn(), shutdown: vitest.fn(), reconnect: vitest.fn(), getExtensionVersion: vitest.fn(), diff --git a/server/test/utils.ts b/server/test/utils.ts index 8b3798b8b1676..45a2f7fc6bafc 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -94,9 +94,12 @@ import { Mocked, vitest } from 'vitest'; type Overrides = { worker?: ImmichWorker; + assetRepository?: AssetRepository; + systemMetadataRepository?: SystemMetadataRepository; metadataRepository?: MetadataRepository; syncRepository?: SyncRepository; userRepository?: UserRepository; + memoryRepository?: MemoryRepository; }; type BaseServiceArgs = ConstructorParameters; type Constructor> = { @@ -151,7 +154,14 @@ export const newTestService = ( Service: Constructor, overrides?: Overrides, ) => { - const { metadataRepository, userRepository, syncRepository } = overrides || {}; + const { + assetRepository, + systemMetadataRepository, + metadataRepository, + memoryRepository, + userRepository, + syncRepository, + } = overrides || {}; const accessMock = newAccessRepositoryMock(); const loggerMock = newLoggingRepositoryMock(); @@ -172,9 +182,7 @@ export const newTestService = ( const mapMock = newMapRepositoryMock(); const mediaMock = newMediaRepositoryMock(); const memoryMock = newMemoryRepositoryMock(); - const metadataMock = (metadataRepository || newMetadataRepositoryMock()) as Mocked< - RepositoryInterface - >; + const metadataMock = newMetadataRepositoryMock(); const moveMock = newMoveRepositoryMock(); const notificationMock = newNotificationRepositoryMock(); const oauthMock = newOAuthRepositoryMock(); @@ -187,12 +195,12 @@ export const newTestService = ( const sharedLinkMock = newSharedLinkRepositoryMock(); const stackMock = newStackRepositoryMock(); const storageMock = newStorageRepositoryMock(); - const syncMock = (syncRepository || newSyncRepositoryMock()) as Mocked>; + const syncMock = newSyncRepositoryMock(); const systemMock = newSystemMetadataRepositoryMock(); const tagMock = newTagRepositoryMock(); const telemetryMock = newTelemetryRepositoryMock(); const trashMock = newTrashRepositoryMock(); - const userMock = (userRepository || newUserRepositoryMock()) as Mocked>; + const userMock = newUserRepositoryMock(); const versionHistoryMock = newVersionHistoryRepositoryMock(); const viewMock = newViewRepositoryMock(); @@ -203,7 +211,7 @@ export const newTestService = ( auditMock as RepositoryInterface as AuditRepository, albumMock as RepositoryInterface as AlbumRepository, albumUserMock as RepositoryInterface as AlbumUserRepository, - assetMock as RepositoryInterface as AssetRepository, + assetRepository || (assetMock as RepositoryInterface as AssetRepository), configMock as RepositoryInterface as ConfigRepository, cronMock as RepositoryInterface as CronRepository, cryptoMock as RepositoryInterface as CryptoRepository, @@ -215,8 +223,8 @@ export const newTestService = ( machineLearningMock as RepositoryInterface as MachineLearningRepository, mapMock as RepositoryInterface as MapRepository, mediaMock as RepositoryInterface as MediaRepository, - memoryMock as RepositoryInterface as MemoryRepository, - metadataMock as RepositoryInterface as MetadataRepository, + memoryRepository || (memoryMock as RepositoryInterface as MemoryRepository), + metadataRepository || (metadataMock as RepositoryInterface as MetadataRepository), moveMock as RepositoryInterface as MoveRepository, notificationMock as RepositoryInterface as NotificationRepository, oauthMock as RepositoryInterface as OAuthRepository, @@ -229,12 +237,13 @@ export const newTestService = ( sharedLinkMock as RepositoryInterface as SharedLinkRepository, stackMock as RepositoryInterface as StackRepository, storageMock as RepositoryInterface as StorageRepository, - syncMock as RepositoryInterface as SyncRepository, - systemMock as RepositoryInterface as SystemMetadataRepository, + syncRepository || (syncMock as RepositoryInterface as SyncRepository), + systemMetadataRepository || + (systemMock as RepositoryInterface as SystemMetadataRepository), tagMock as RepositoryInterface as TagRepository, telemetryMock as unknown as TelemetryRepository, trashMock as RepositoryInterface as TrashRepository, - userMock as RepositoryInterface as UserRepository, + userRepository || (userMock as RepositoryInterface as UserRepository), versionHistoryMock as RepositoryInterface as VersionHistoryRepository, viewMock as RepositoryInterface as ViewRepository, );