refactor: migrate user repository to kysely (#15296)

* refactor: migrate user repository to kysely

* refactor: migrate user repository to kysely

* refactor: migrate user repository to kysely

* refactor: migrate user repository to kysely

* fix: test

* clean up

* fix: metadata retrieval bug

* use correct typeing for upsert metadata

* pr feedback

* pr feedback

* fix: add deletedAt check

* fix: get non deleted user by default

* remove console.log

* fix: stop kysely after command finishes

* final clean up

---------

Co-authored-by: Jason Rasmussen <jason@rasm.me>
This commit is contained in:
Alex 2025-01-13 19:30:34 -06:00 committed by GitHub
parent a6c8eb57f1
commit 3da750117f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 447 additions and 312 deletions

View File

@ -24,6 +24,7 @@ import { repositories } from 'src/repositories';
import { ConfigRepository } from 'src/repositories/config.repository'; import { ConfigRepository } from 'src/repositories/config.repository';
import { teardownTelemetry } from 'src/repositories/telemetry.repository'; import { teardownTelemetry } from 'src/repositories/telemetry.repository';
import { services } from 'src/services'; import { services } from 'src/services';
import { CliService } from 'src/services/cli.service';
import { DatabaseService } from 'src/services/database.service'; import { DatabaseService } from 'src/services/database.service';
const common = [...services, ...repositories]; const common = [...services, ...repositories];
@ -106,4 +107,10 @@ export class MicroservicesModule extends BaseModule {}
imports: [...imports], imports: [...imports],
providers: [...common, ...commands, SchedulerRegistry], providers: [...common, ...commands, SchedulerRegistry],
}) })
export class ImmichAdminModule {} export class ImmichAdminModule implements OnModuleDestroy {
constructor(private service: CliService) {}
async onModuleDestroy() {
await this.service.cleanup();
}
}

View File

@ -99,6 +99,7 @@ export const DummyValue = {
BUFFER: Buffer.from('abcdefghi'), BUFFER: Buffer.from('abcdefghi'),
DATE: new Date(), DATE: new Date(),
TIME_BUCKET: '2024-01-01T00:00:00.000Z', TIME_BUCKET: '2024-01-01T00:00:00.000Z',
BOOLEAN: true,
}; };
export const GENERATE_SQL_KEY = 'generate-sql-key'; export const GENERATE_SQL_KEY = 'generate-sql-key';

View File

@ -1,3 +1,6 @@
import { ExpressionBuilder } from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { DB } from 'src/db';
import { AssetEntity } from 'src/entities/asset.entity'; import { AssetEntity } from 'src/entities/asset.entity';
import { TagEntity } from 'src/entities/tag.entity'; import { TagEntity } from 'src/entities/tag.entity';
import { UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserMetadataEntity } from 'src/entities/user-metadata.entity';
@ -71,3 +74,9 @@ export class UserEntity {
@Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' }) @Column({ type: 'timestamptz', default: () => 'CURRENT_TIMESTAMP' })
profileChangedAt!: Date; profileChangedAt!: Date;
} }
export const withMetadata = (eb: ExpressionBuilder<DB, 'users'>) => {
return jsonArrayFrom(
eb.selectFrom('user_metadata').selectAll('user_metadata').whereRef('users.id', '=', 'user_metadata.userId'),
).as('metadata');
};

View File

@ -61,6 +61,7 @@ export const IDatabaseRepository = 'IDatabaseRepository';
export interface IDatabaseRepository { export interface IDatabaseRepository {
init(): void; init(): void;
reconnect(): Promise<boolean>; reconnect(): Promise<boolean>;
shutdown(): Promise<void>;
getExtensionVersion(extension: DatabaseExtension): Promise<ExtensionVersion>; getExtensionVersion(extension: DatabaseExtension): Promise<ExtensionVersion>;
getExtensionVersionRange(extension: VectorExtension): string; getExtensionVersionRange(extension: VectorExtension): string;
getPostgresVersion(): Promise<string>; getPostgresVersion(): Promise<string>;

View File

@ -1,3 +1,5 @@
import { Insertable, Updateable } from 'kysely';
import { Users } from 'src/db';
import { UserMetadata } from 'src/entities/user-metadata.entity'; import { UserMetadata } from 'src/entities/user-metadata.entity';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
@ -23,17 +25,17 @@ export interface UserFindOptions {
export const IUserRepository = 'IUserRepository'; export const IUserRepository = 'IUserRepository';
export interface IUserRepository { export interface IUserRepository {
get(id: string, options: UserFindOptions): Promise<UserEntity | null>; get(id: string, options: UserFindOptions): Promise<UserEntity | undefined>;
getAdmin(): Promise<UserEntity | null>; getAdmin(): Promise<UserEntity | undefined>;
hasAdmin(): Promise<boolean>; hasAdmin(): Promise<boolean>;
getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null>; getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | undefined>;
getByStorageLabel(storageLabel: string): Promise<UserEntity | null>; getByStorageLabel(storageLabel: string): Promise<UserEntity | undefined>;
getByOAuthId(oauthId: string): Promise<UserEntity | null>; getByOAuthId(oauthId: string): Promise<UserEntity | undefined>;
getDeletedUsers(): Promise<UserEntity[]>; getDeletedUsers(): Promise<UserEntity[]>;
getList(filter?: UserListFilter): Promise<UserEntity[]>; getList(filter?: UserListFilter): Promise<UserEntity[]>;
getUserStats(): Promise<UserStatsQueryResponse[]>; getUserStats(): Promise<UserStatsQueryResponse[]>;
create(user: Partial<UserEntity>): Promise<UserEntity>; create(user: Insertable<Users>): Promise<UserEntity>;
update(id: string, user: Partial<UserEntity>): Promise<UserEntity>; update(id: string, user: Updateable<Users>): Promise<UserEntity>;
upsertMetadata<T extends keyof UserMetadata>(id: string, item: { key: T; value: UserMetadata[T] }): Promise<void>; upsertMetadata<T extends keyof UserMetadata>(id: string, item: { key: T; value: UserMetadata[T] }): Promise<void>;
deleteMetadata<T extends keyof UserMetadata>(id: string, key: T): Promise<void>; deleteMetadata<T extends keyof UserMetadata>(id: string, key: T): Promise<void>;
delete(user: UserEntity, hard?: boolean): Promise<UserEntity>; delete(user: UserEntity, hard?: boolean): Promise<UserEntity>;

View File

@ -1,195 +1,222 @@
-- NOTE: This file is auto generated by ./sql-generator -- NOTE: This file is auto generated by ./sql-generator
-- UserRepository.get
select
"id",
"email",
"createdAt",
"profileImagePath",
"isAdmin",
"shouldChangePassword",
"deletedAt",
"oauthId",
"updatedAt",
"storageLabel",
"name",
"quotaSizeInBytes",
"quotaUsageInBytes",
"status",
"profileChangedAt",
(
select
coalesce(json_agg(agg), '[]')
from
(
select
"user_metadata".*
from
"user_metadata"
where
"users"."id" = "user_metadata"."userId"
) as agg
) as "metadata"
from
"users"
where
"users"."id" = $1
and "users"."deletedAt" is null
-- UserRepository.getAdmin -- UserRepository.getAdmin
SELECT select
"UserEntity"."id" AS "UserEntity_id", "id",
"UserEntity"."name" AS "UserEntity_name", "email",
"UserEntity"."isAdmin" AS "UserEntity_isAdmin", "createdAt",
"UserEntity"."email" AS "UserEntity_email", "profileImagePath",
"UserEntity"."storageLabel" AS "UserEntity_storageLabel", "isAdmin",
"UserEntity"."oauthId" AS "UserEntity_oauthId", "shouldChangePassword",
"UserEntity"."profileImagePath" AS "UserEntity_profileImagePath", "deletedAt",
"UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword", "oauthId",
"UserEntity"."createdAt" AS "UserEntity_createdAt", "updatedAt",
"UserEntity"."deletedAt" AS "UserEntity_deletedAt", "storageLabel",
"UserEntity"."status" AS "UserEntity_status", "name",
"UserEntity"."updatedAt" AS "UserEntity_updatedAt", "quotaSizeInBytes",
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes", "quotaUsageInBytes",
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes", "status",
"UserEntity"."profileChangedAt" AS "UserEntity_profileChangedAt" "profileChangedAt"
FROM from
"users" "UserEntity" "users"
WHERE where
((("UserEntity"."isAdmin" = $1))) "users"."isAdmin" = $1
AND ("UserEntity"."deletedAt" IS NULL) and "users"."deletedAt" is null
LIMIT
1
-- UserRepository.hasAdmin -- UserRepository.hasAdmin
SELECT select
1 AS "row_exists" "users"."id"
FROM from
( "users"
SELECT where
1 AS dummy_column "users"."isAdmin" = $1
) "dummy_table" and "users"."deletedAt" is null
WHERE
EXISTS (
SELECT
1
FROM
"users" "UserEntity"
WHERE
((("UserEntity"."isAdmin" = $1)))
AND ("UserEntity"."deletedAt" IS NULL)
)
LIMIT
1
-- UserRepository.getByEmail -- UserRepository.getByEmail
SELECT select
"user"."id" AS "user_id", "id",
"user"."name" AS "user_name", "email",
"user"."isAdmin" AS "user_isAdmin", "createdAt",
"user"."email" AS "user_email", "profileImagePath",
"user"."storageLabel" AS "user_storageLabel", "isAdmin",
"user"."oauthId" AS "user_oauthId", "shouldChangePassword",
"user"."profileImagePath" AS "user_profileImagePath", "deletedAt",
"user"."shouldChangePassword" AS "user_shouldChangePassword", "oauthId",
"user"."createdAt" AS "user_createdAt", "updatedAt",
"user"."deletedAt" AS "user_deletedAt", "storageLabel",
"user"."status" AS "user_status", "name",
"user"."updatedAt" AS "user_updatedAt", "quotaSizeInBytes",
"user"."quotaSizeInBytes" AS "user_quotaSizeInBytes", "quotaUsageInBytes",
"user"."quotaUsageInBytes" AS "user_quotaUsageInBytes", "status",
"user"."profileChangedAt" AS "user_profileChangedAt" "profileChangedAt"
FROM from
"users" "user" "users"
WHERE where
("user"."email" = $1) "email" = $1
AND ("user"."deletedAt" IS NULL) and "users"."deletedAt" is null
-- UserRepository.getByStorageLabel -- UserRepository.getByStorageLabel
SELECT select
"UserEntity"."id" AS "UserEntity_id", "id",
"UserEntity"."name" AS "UserEntity_name", "email",
"UserEntity"."isAdmin" AS "UserEntity_isAdmin", "createdAt",
"UserEntity"."email" AS "UserEntity_email", "profileImagePath",
"UserEntity"."storageLabel" AS "UserEntity_storageLabel", "isAdmin",
"UserEntity"."oauthId" AS "UserEntity_oauthId", "shouldChangePassword",
"UserEntity"."profileImagePath" AS "UserEntity_profileImagePath", "deletedAt",
"UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword", "oauthId",
"UserEntity"."createdAt" AS "UserEntity_createdAt", "updatedAt",
"UserEntity"."deletedAt" AS "UserEntity_deletedAt", "storageLabel",
"UserEntity"."status" AS "UserEntity_status", "name",
"UserEntity"."updatedAt" AS "UserEntity_updatedAt", "quotaSizeInBytes",
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes", "quotaUsageInBytes",
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes", "status",
"UserEntity"."profileChangedAt" AS "UserEntity_profileChangedAt" "profileChangedAt"
FROM from
"users" "UserEntity" "users"
WHERE where
((("UserEntity"."storageLabel" = $1))) "users"."storageLabel" = $1
AND ("UserEntity"."deletedAt" IS NULL) and "users"."deletedAt" is null
LIMIT
1
-- UserRepository.getByOAuthId -- UserRepository.getByOAuthId
SELECT select
"UserEntity"."id" AS "UserEntity_id", "id",
"UserEntity"."name" AS "UserEntity_name", "email",
"UserEntity"."isAdmin" AS "UserEntity_isAdmin", "createdAt",
"UserEntity"."email" AS "UserEntity_email", "profileImagePath",
"UserEntity"."storageLabel" AS "UserEntity_storageLabel", "isAdmin",
"UserEntity"."oauthId" AS "UserEntity_oauthId", "shouldChangePassword",
"UserEntity"."profileImagePath" AS "UserEntity_profileImagePath", "deletedAt",
"UserEntity"."shouldChangePassword" AS "UserEntity_shouldChangePassword", "oauthId",
"UserEntity"."createdAt" AS "UserEntity_createdAt", "updatedAt",
"UserEntity"."deletedAt" AS "UserEntity_deletedAt", "storageLabel",
"UserEntity"."status" AS "UserEntity_status", "name",
"UserEntity"."updatedAt" AS "UserEntity_updatedAt", "quotaSizeInBytes",
"UserEntity"."quotaSizeInBytes" AS "UserEntity_quotaSizeInBytes", "quotaUsageInBytes",
"UserEntity"."quotaUsageInBytes" AS "UserEntity_quotaUsageInBytes", "status",
"UserEntity"."profileChangedAt" AS "UserEntity_profileChangedAt" "profileChangedAt"
FROM from
"users" "UserEntity" "users"
WHERE where
((("UserEntity"."oauthId" = $1))) "users"."oauthId" = $1
AND ("UserEntity"."deletedAt" IS NULL) and "users"."deletedAt" is null
LIMIT
1
-- UserRepository.getUserStats -- UserRepository.getUserStats
SELECT select
"users"."id" AS "userId", "users"."id" as "userId",
"users"."name" AS "userName", "users"."name" as "userName",
"users"."quotaSizeInBytes" AS "quotaSizeInBytes", "users"."quotaSizeInBytes" as "quotaSizeInBytes",
COUNT("assets"."id") FILTER ( count(*) filter (
WHERE where
"assets"."type" = 'IMAGE' (
AND "assets"."isVisible" "assets"."type" = $1
) AS "photos", and "assets"."isVisible" = $2
COUNT("assets"."id") FILTER ( )
WHERE ) as "photos",
"assets"."type" = 'VIDEO' count(*) filter (
AND "assets"."isVisible" where
) AS "videos", (
COALESCE( "assets"."type" = $3
SUM("exif"."fileSizeInByte") FILTER ( and "assets"."isVisible" = $4
WHERE )
"assets"."libraryId" IS NULL ) as "videos",
coalesce(
sum("exif"."fileSizeInByte") filter (
where
"assets"."libraryId" is null
), ),
0 0
) AS "usage", ) as "usage",
COALESCE( coalesce(
SUM("exif"."fileSizeInByte") FILTER ( sum("exif"."fileSizeInByte") filter (
WHERE where
"assets"."libraryId" IS NULL (
AND "assets"."type" = 'IMAGE' "assets"."libraryId" is null
and "assets"."type" = $5
)
), ),
0 0
) AS "usagePhotos", ) as "usagePhotos",
COALESCE( coalesce(
SUM("exif"."fileSizeInByte") FILTER ( sum("exif"."fileSizeInByte") filter (
WHERE where
"assets"."libraryId" IS NULL (
AND "assets"."type" = 'VIDEO' "assets"."libraryId" is null
and "assets"."type" = $6
)
), ),
0 0
) AS "usageVideos" ) as "usageVideos"
FROM from
"users" "users" "users"
LEFT JOIN "assets" "assets" ON "assets"."ownerId" = "users"."id" left join "assets" on "assets"."ownerId" = "users"."id"
AND ("assets"."deletedAt" IS NULL) left join "exif" on "exif"."assetId" = "assets"."id"
LEFT JOIN "exif" "exif" ON "exif"."assetId" = "assets"."id" where
WHERE "assets"."deletedAt" is null
"users"."deletedAt" IS NULL group by
GROUP BY
"users"."id" "users"."id"
ORDER BY order by
"users"."createdAt" ASC "users"."createdAt" asc
-- UserRepository.updateUsage -- UserRepository.updateUsage
UPDATE "users" update "users"
SET set
"quotaUsageInBytes" = "quotaUsageInBytes" + 50, "quotaUsageInBytes" = "quotaUsageInBytes" + $1,
"updatedAt" = CURRENT_TIMESTAMP "updatedAt" = $2
WHERE where
"id" = $1 "id" = $3::uuid
and "users"."deletedAt" is null
-- UserRepository.syncUsage -- UserRepository.syncUsage
UPDATE "users" update "users"
SET set
"quotaUsageInBytes" = ( "quotaUsageInBytes" = (
SELECT select
COALESCE(SUM(exif."fileSizeInByte"), 0) coalesce(sum("exif"."fileSizeInByte"), 0) as "usage"
FROM from
"assets" "assets" "assets"
LEFT JOIN "exif" "exif" ON "exif"."assetId" = "assets"."id" left join "exif" on "exif"."assetId" = "assets"."id"
WHERE where
"assets"."ownerId" = users.id "assets"."libraryId" is null
AND "assets"."libraryId" IS NULL and "assets"."ownerId" = "users"."id"
), ),
"updatedAt" = CURRENT_TIMESTAMP "updatedAt" = $1
WHERE where
users.id = $1 "users"."deletedAt" is null
and "users"."id" = $2::uuid

View File

@ -1,7 +1,8 @@
import { Inject, Injectable } from '@nestjs/common'; import { Inject, Injectable } from '@nestjs/common';
import { InjectDataSource } from '@nestjs/typeorm'; import { InjectDataSource } from '@nestjs/typeorm';
import AsyncLock from 'async-lock'; import AsyncLock from 'async-lock';
import { sql } from 'kysely'; import { Kysely, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import semver from 'semver'; import semver from 'semver';
import { POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants'; import { POSTGRES_VERSION_RANGE, VECTOR_VERSION_RANGE, VECTORS_VERSION_RANGE } from 'src/constants';
import { DB } from 'src/db'; import { DB } from 'src/db';
@ -27,6 +28,7 @@ export class DatabaseRepository implements IDatabaseRepository {
private readonly asyncLock = new AsyncLock(); private readonly asyncLock = new AsyncLock();
constructor( constructor(
@InjectKysely() private db: Kysely<DB>,
@InjectDataSource() private dataSource: DataSource, @InjectDataSource() private dataSource: DataSource,
@Inject(ILoggerRepository) private logger: ILoggerRepository, @Inject(ILoggerRepository) private logger: ILoggerRepository,
@Inject(IConfigRepository) configRepository: IConfigRepository, @Inject(IConfigRepository) configRepository: IConfigRepository,
@ -35,6 +37,10 @@ export class DatabaseRepository implements IDatabaseRepository {
this.logger.setContext(DatabaseRepository.name); this.logger.setContext(DatabaseRepository.name);
} }
async shutdown() {
await this.db.destroy();
}
init() { init() {
for (const metadata of this.dataSource.entityMetadatas) { for (const metadata of this.dataSource.entityMetadatas) {
const table = metadata.tableName as keyof DB; const table = metadata.tableName as keyof DB;

View File

@ -1,127 +1,212 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm'; import { Insertable, Kysely, sql, Updateable } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { DB, UserMetadata as DbUserMetadata, Users } from 'src/db';
import { DummyValue, GenerateSql } from 'src/decorators'; import { DummyValue, GenerateSql } from 'src/decorators';
import { AssetEntity } from 'src/entities/asset.entity'; import { UserMetadata } from 'src/entities/user-metadata.entity';
import { UserMetadata, UserMetadataEntity } from 'src/entities/user-metadata.entity'; import { UserEntity, withMetadata } from 'src/entities/user.entity';
import { UserEntity } from 'src/entities/user.entity';
import { import {
IUserRepository, IUserRepository,
UserFindOptions, UserFindOptions,
UserListFilter, UserListFilter,
UserStatsQueryResponse, UserStatsQueryResponse,
} from 'src/interfaces/user.interface'; } from 'src/interfaces/user.interface';
import { IsNull, Not, Repository } from 'typeorm'; import { asUuid } from 'src/utils/database';
const columns = [
'id',
'email',
'createdAt',
'profileImagePath',
'isAdmin',
'shouldChangePassword',
'deletedAt',
'oauthId',
'updatedAt',
'storageLabel',
'name',
'quotaSizeInBytes',
'quotaUsageInBytes',
'status',
'profileChangedAt',
] as const;
type Upsert = Insertable<DbUserMetadata>;
@Injectable() @Injectable()
export class UserRepository implements IUserRepository { export class UserRepository implements IUserRepository {
constructor( constructor(@InjectKysely() private db: Kysely<DB>) {}
@InjectRepository(AssetEntity) private assetRepository: Repository<AssetEntity>,
@InjectRepository(UserEntity) private userRepository: Repository<UserEntity>,
@InjectRepository(UserMetadataEntity) private metadataRepository: Repository<UserMetadataEntity>,
) {}
async get(userId: string, options: UserFindOptions): Promise<UserEntity | null> { @GenerateSql({ params: [DummyValue.UUID, DummyValue.BOOLEAN] })
get(userId: string, options: UserFindOptions): Promise<UserEntity | undefined> {
options = options || {}; options = options || {};
return this.userRepository.findOne({
where: { id: userId }, return this.db
withDeleted: options.withDeleted, .selectFrom('users')
relations: { .select(columns)
metadata: true, .select(withMetadata)
}, .where('users.id', '=', userId)
}); .$if(!options.withDeleted, (eb) => eb.where('users.deletedAt', 'is', null))
.executeTakeFirst() as Promise<UserEntity | undefined>;
} }
@GenerateSql() @GenerateSql()
async getAdmin(): Promise<UserEntity | null> { getAdmin(): Promise<UserEntity | undefined> {
return this.userRepository.findOne({ where: { isAdmin: true } }); return this.db
.selectFrom('users')
.select(columns)
.where('users.isAdmin', '=', true)
.where('users.deletedAt', 'is', null)
.executeTakeFirst() as Promise<UserEntity | undefined>;
} }
@GenerateSql() @GenerateSql()
async hasAdmin(): Promise<boolean> { async hasAdmin(): Promise<boolean> {
return this.userRepository.exists({ where: { isAdmin: true } }); const admin = await this.db
.selectFrom('users')
.select('users.id')
.where('users.isAdmin', '=', true)
.where('users.deletedAt', 'is', null)
.executeTakeFirst();
return !!admin;
} }
@GenerateSql({ params: [DummyValue.EMAIL] }) @GenerateSql({ params: [DummyValue.EMAIL] })
async getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | null> { getByEmail(email: string, withPassword?: boolean): Promise<UserEntity | undefined> {
const builder = this.userRepository.createQueryBuilder('user').where({ email }); return this.db
.selectFrom('users')
if (withPassword) { .select(columns)
builder.addSelect('user.password'); .$if(!!withPassword, (eb) => eb.select('password'))
} .where('email', '=', email)
.where('users.deletedAt', 'is', null)
return builder.getOne(); .executeTakeFirst() as Promise<UserEntity | undefined>;
} }
@GenerateSql({ params: [DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.STRING] })
async getByStorageLabel(storageLabel: string): Promise<UserEntity | null> { getByStorageLabel(storageLabel: string): Promise<UserEntity | undefined> {
return this.userRepository.findOne({ where: { storageLabel } }); return this.db
.selectFrom('users')
.select(columns)
.where('users.storageLabel', '=', storageLabel)
.where('users.deletedAt', 'is', null)
.executeTakeFirst() as Promise<UserEntity | undefined>;
} }
@GenerateSql({ params: [DummyValue.STRING] }) @GenerateSql({ params: [DummyValue.STRING] })
async getByOAuthId(oauthId: string): Promise<UserEntity | null> { getByOAuthId(oauthId: string): Promise<UserEntity | undefined> {
return this.userRepository.findOne({ where: { oauthId } }); return this.db
.selectFrom('users')
.select(columns)
.where('users.oauthId', '=', oauthId)
.where('users.deletedAt', 'is', null)
.executeTakeFirst() as Promise<UserEntity | undefined>;
} }
async getDeletedUsers(): Promise<UserEntity[]> { getDeletedUsers(): Promise<UserEntity[]> {
return this.userRepository.find({ withDeleted: true, where: { deletedAt: Not(IsNull()) } }); return this.db
.selectFrom('users')
.select(columns)
.where('users.deletedAt', 'is not', null)
.execute() as unknown as Promise<UserEntity[]>;
} }
async getList({ withDeleted }: UserListFilter = {}): Promise<UserEntity[]> { getList({ withDeleted }: UserListFilter = {}): Promise<UserEntity[]> {
return this.userRepository.find({ return this.db
withDeleted, .selectFrom('users')
order: { .select(columns)
createdAt: 'DESC', .select(withMetadata)
}, .$if(!withDeleted, (eb) => eb.where('users.deletedAt', 'is', null))
relations: { .orderBy('createdAt', 'desc')
metadata: true, .execute() as unknown as Promise<UserEntity[]>;
},
});
} }
create(user: Partial<UserEntity>): Promise<UserEntity> { async create(dto: Insertable<Users>): Promise<UserEntity> {
return this.save(user); return this.db
.insertInto('users')
.values(dto)
.returning(columns)
.executeTakeFirst() as unknown as Promise<UserEntity>;
} }
// TODO change to (user: Partial<UserEntity>) update(id: string, dto: Updateable<Users>): Promise<UserEntity> {
update(id: string, user: Partial<UserEntity>): Promise<UserEntity> { return this.db
return this.save({ ...user, id }); .updateTable('users')
.set(dto)
.where('users.id', '=', asUuid(id))
.where('users.deletedAt', 'is', null)
.returning(columns)
.returning(withMetadata)
.executeTakeFirst() as unknown as Promise<UserEntity>;
} }
async upsertMetadata<T extends keyof UserMetadata>(id: string, { key, value }: { key: T; value: UserMetadata[T] }) { async upsertMetadata<T extends keyof UserMetadata>(id: string, { key, value }: { key: T; value: UserMetadata[T] }) {
await this.metadataRepository.upsert({ userId: id, key, value }, { conflictPaths: { userId: true, key: true } }); await this.db
.insertInto('user_metadata')
.values({ userId: id, key, value } as Upsert)
.onConflict((oc) =>
oc.columns(['userId', 'key']).doUpdateSet({
key,
value,
} as Upsert),
)
.execute();
} }
async deleteMetadata<T extends keyof UserMetadata>(id: string, key: T) { async deleteMetadata<T extends keyof UserMetadata>(id: string, key: T) {
await this.metadataRepository.delete({ userId: id, key }); await this.db.deleteFrom('user_metadata').where('userId', '=', id).where('key', '=', key).execute();
} }
async delete(user: UserEntity, hard?: boolean): Promise<UserEntity> { delete(user: UserEntity, hard?: boolean): Promise<UserEntity> {
return hard ? this.userRepository.remove(user) : this.userRepository.softRemove(user); return hard
? (this.db.deleteFrom('users').where('id', '=', user.id).execute() as unknown as Promise<UserEntity>)
: (this.db
.updateTable('users')
.set({ deletedAt: new Date() })
.where('id', '=', user.id)
.execute() as unknown as Promise<UserEntity>);
} }
@GenerateSql() @GenerateSql()
async getUserStats(): Promise<UserStatsQueryResponse[]> { async getUserStats(): Promise<UserStatsQueryResponse[]> {
const stats = await this.userRepository const stats = (await this.db
.createQueryBuilder('users') .selectFrom('users')
.select('users.id', 'userId') .leftJoin('assets', 'assets.ownerId', 'users.id')
.addSelect('users.name', 'userName') .leftJoin('exif', 'exif.assetId', 'assets.id')
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'IMAGE' AND assets.isVisible)`, 'photos') .select(['users.id as userId', 'users.name as userName', 'users.quotaSizeInBytes as quotaSizeInBytes'])
.addSelect(`COUNT(assets.id) FILTER (WHERE assets.type = 'VIDEO' AND assets.isVisible)`, 'videos') .select((eb) => [
.addSelect('COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL), 0)', 'usage') eb.fn
.addSelect( .countAll()
`COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL AND assets.type = 'IMAGE'), 0)`, .filterWhere((eb) => eb.and([eb('assets.type', '=', 'IMAGE'), eb('assets.isVisible', '=', true)]))
'usagePhotos', .as('photos'),
) eb.fn
.addSelect( .countAll()
`COALESCE(SUM(exif.fileSizeInByte) FILTER (WHERE assets.libraryId IS NULL AND assets.type = 'VIDEO'), 0)`, .filterWhere((eb) => eb.and([eb('assets.type', '=', 'VIDEO'), eb('assets.isVisible', '=', true)]))
'usageVideos', .as('videos'),
) eb.fn
.addSelect('users.quotaSizeInBytes', 'quotaSizeInBytes') .coalesce(eb.fn.sum('exif.fileSizeInByte').filterWhere('assets.libraryId', 'is', null), eb.lit(0))
.leftJoin('users.assets', 'assets') .as('usage'),
.leftJoin('assets.exifInfo', 'exif') eb.fn
.coalesce(
eb.fn
.sum('exif.fileSizeInByte')
.filterWhere((eb) => eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', 'IMAGE')])),
eb.lit(0),
)
.as('usagePhotos'),
eb.fn
.coalesce(
eb.fn
.sum('exif.fileSizeInByte')
.filterWhere((eb) => eb.and([eb('assets.libraryId', 'is', null), eb('assets.type', '=', 'VIDEO')])),
eb.lit(0),
)
.as('usageVideos'),
])
.where('assets.deletedAt', 'is', null)
.groupBy('users.id') .groupBy('users.id')
.orderBy('users.createdAt', 'ASC') .orderBy('users.createdAt', 'asc')
.getRawMany(); .execute()) as UserStatsQueryResponse[];
for (const stat of stats) { for (const stat of stats) {
stat.photos = Number(stat.photos); stat.photos = Number(stat.photos);
@ -137,41 +222,31 @@ export class UserRepository implements IUserRepository {
@GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] }) @GenerateSql({ params: [DummyValue.UUID, DummyValue.NUMBER] })
async updateUsage(id: string, delta: number): Promise<void> { async updateUsage(id: string, delta: number): Promise<void> {
await this.userRepository.increment({ id }, 'quotaUsageInBytes', delta); await this.db
.updateTable('users')
.set({ quotaUsageInBytes: sql`"quotaUsageInBytes" + ${delta}`, updatedAt: new Date() })
.where('id', '=', asUuid(id))
.where('users.deletedAt', 'is', null)
.execute();
} }
@GenerateSql({ params: [DummyValue.UUID] }) @GenerateSql({ params: [DummyValue.UUID] })
async syncUsage(id?: string) { async syncUsage(id?: string) {
// we can't use parameters with getQuery, hence the template string const query = this.db
const subQuery = this.assetRepository .updateTable('users')
.createQueryBuilder('assets') .set({
.select('COALESCE(SUM(exif."fileSizeInByte"), 0)') quotaUsageInBytes: (eb) =>
.leftJoin('assets.exifInfo', 'exif') eb
.where('assets.ownerId = users.id') .selectFrom('assets')
.andWhere(`assets.libraryId IS NULL`) .leftJoin('exif', 'exif.assetId', 'assets.id')
.withDeleted(); .select((eb) => eb.fn.coalesce(eb.fn.sum('exif.fileSizeInByte'), eb.lit(0)).as('usage'))
.where('assets.libraryId', 'is', null)
const query = this.userRepository .where('assets.ownerId', '=', eb.ref('users.id')),
.createQueryBuilder('users') updatedAt: new Date(),
.leftJoin('users.assets', 'assets') })
.update() .where('users.deletedAt', 'is', null)
.set({ quotaUsageInBytes: () => `(${subQuery.getQuery()})` }); .$if(id != undefined, (eb) => eb.where('users.id', '=', asUuid(id!)));
if (id) {
query.where('users.id = :id', { id });
}
await query.execute(); await query.execute();
} }
private async save(user: Partial<UserEntity>) {
const { id } = await this.userRepository.save(user);
return this.userRepository.findOneOrFail({
where: { id },
withDeleted: true,
relations: {
metadata: true,
},
});
}
} }

View File

@ -153,7 +153,7 @@ describe(AlbumService.name, () => {
}); });
it('should require valid userIds', async () => { it('should require valid userIds', async () => {
userMock.get.mockResolvedValue(null); userMock.get.mockResolvedValue(void 0);
await expect( await expect(
sut.create(authStub.admin, { sut.create(authStub.admin, {
albumName: 'Empty album', albumName: 'Empty album',
@ -299,7 +299,7 @@ describe(AlbumService.name, () => {
it('should throw an error if the userId does not exist', async () => { it('should throw an error if the userId does not exist', async () => {
accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id])); accessMock.album.checkOwnerAccess.mockResolvedValue(new Set([albumStub.sharedWithAdmin.id]));
albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin); albumMock.getById.mockResolvedValue(albumStub.sharedWithAdmin);
userMock.get.mockResolvedValue(null); userMock.get.mockResolvedValue(void 0);
await expect( await expect(
sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-3' }] }), sut.addUsers(authStub.user1, albumStub.sharedWithAdmin.id, { albumUsers: [{ userId: 'user-3' }] }),
).rejects.toBeInstanceOf(BadRequestException); ).rejects.toBeInstanceOf(BadRequestException);

View File

@ -96,7 +96,7 @@ describe('AuthService', () => {
}); });
it('should check the user exists', async () => { it('should check the user exists', async () => {
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(void 0);
await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException); await expect(sut.login(fixtures.login, loginDetails)).rejects.toBeInstanceOf(UnauthorizedException);
expect(userMock.getByEmail).toHaveBeenCalledTimes(1); expect(userMock.getByEmail).toHaveBeenCalledTimes(1);
}); });
@ -144,7 +144,7 @@ describe('AuthService', () => {
const auth = { user: { email: 'test@imimch.com' } } as AuthDto; const auth = { user: { email: 'test@imimch.com' } } as AuthDto;
const dto = { password: 'old-password', newPassword: 'new-password' }; const dto = { password: 'old-password', newPassword: 'new-password' };
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(void 0);
await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(UnauthorizedException); await expect(sut.changePassword(auth, dto)).rejects.toBeInstanceOf(UnauthorizedException);
}); });
@ -227,7 +227,7 @@ describe('AuthService', () => {
}); });
it('should sign up the admin', async () => { it('should sign up the admin', async () => {
userMock.getAdmin.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(void 0);
userMock.create.mockResolvedValue({ userMock.create.mockResolvedValue({
...dto, ...dto,
id: 'admin', id: 'admin',
@ -309,7 +309,7 @@ describe('AuthService', () => {
it('should not accept a key without a user', async () => { it('should not accept a key without a user', async () => {
sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired); sharedLinkMock.getByKey.mockResolvedValue(sharedLinkStub.expired);
userMock.get.mockResolvedValue(null); userMock.get.mockResolvedValue(void 0);
await expect( await expect(
sut.authenticate({ sut.authenticate({
headers: { 'x-immich-share-key': 'key' }, headers: { 'x-immich-share-key': 'key' },
@ -473,7 +473,7 @@ describe('AuthService', () => {
it('should not allow auto registering', async () => { it('should not allow auto registering', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.oauthEnabled); systemMock.get.mockResolvedValue(systemConfigStub.oauthEnabled);
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(void 0);
await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf( await expect(sut.callback({ url: 'http://immich/auth/login?code=abc123' }, loginDetails)).rejects.toBeInstanceOf(
BadRequestException, BadRequestException,
); );
@ -510,7 +510,7 @@ describe('AuthService', () => {
it('should allow auto registering by default', async () => { it('should allow auto registering by default', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.enabled); systemMock.get.mockResolvedValue(systemConfigStub.enabled);
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(void 0);
userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
sessionMock.create.mockResolvedValue(sessionStub.valid); sessionMock.create.mockResolvedValue(sessionStub.valid);
@ -525,7 +525,7 @@ describe('AuthService', () => {
it('should throw an error if user should be auto registered but the email claim does not exist', async () => { it('should throw an error if user should be auto registered but the email claim does not exist', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.enabled); systemMock.get.mockResolvedValue(systemConfigStub.enabled);
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(void 0);
userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
sessionMock.create.mockResolvedValue(sessionStub.valid); sessionMock.create.mockResolvedValue(sessionStub.valid);
@ -559,7 +559,7 @@ describe('AuthService', () => {
it('should use the default quota', async () => { it('should use the default quota', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(void 0);
userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
@ -572,7 +572,7 @@ describe('AuthService', () => {
it('should ignore an invalid storage quota', async () => { it('should ignore an invalid storage quota', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(void 0);
userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' }); oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 'abc' });
@ -586,7 +586,7 @@ describe('AuthService', () => {
it('should ignore a negative quota', async () => { it('should ignore a negative quota', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(void 0);
userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 }); oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: -5 });
@ -600,7 +600,7 @@ describe('AuthService', () => {
it('should not set quota for 0 quota', async () => { it('should not set quota for 0 quota', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(void 0);
userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 }); oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 0 });
@ -620,7 +620,7 @@ describe('AuthService', () => {
it('should use a valid storage quota', async () => { it('should use a valid storage quota', async () => {
systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota); systemMock.get.mockResolvedValue(systemConfigStub.oauthWithStorageQuota);
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(void 0);
userMock.getAdmin.mockResolvedValue(userStub.user1); userMock.getAdmin.mockResolvedValue(userStub.user1);
userMock.create.mockResolvedValue(userStub.user1); userMock.create.mockResolvedValue(userStub.user1);
oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 }); oauthMock.getProfile.mockResolvedValue({ sub, email, immich_quota: 5 });

View File

@ -65,7 +65,7 @@ export class AuthService extends BaseService {
if (user) { if (user) {
const isAuthenticated = this.validatePassword(dto.password, user); const isAuthenticated = this.validatePassword(dto.password, user);
if (!isAuthenticated) { if (!isAuthenticated) {
user = null; user = undefined;
} }
} }

View File

@ -1,8 +1,10 @@
import { BadRequestException, Inject } from '@nestjs/common'; import { BadRequestException, Inject } from '@nestjs/common';
import { Insertable } from 'kysely';
import sanitize from 'sanitize-filename'; import sanitize from 'sanitize-filename';
import { SystemConfig } from 'src/config'; import { SystemConfig } from 'src/config';
import { SALT_ROUNDS } from 'src/constants'; import { SALT_ROUNDS } from 'src/constants';
import { StorageCore } from 'src/cores/storage.core'; import { StorageCore } from 'src/cores/storage.core';
import { Users } from 'src/db';
import { UserEntity } from 'src/entities/user.entity'; import { UserEntity } from 'src/entities/user.entity';
import { IAccessRepository } from 'src/interfaces/access.interface'; import { IAccessRepository } from 'src/interfaces/access.interface';
import { IActivityRepository } from 'src/interfaces/activity.interface'; import { IActivityRepository } from 'src/interfaces/activity.interface';
@ -131,7 +133,7 @@ export class BaseService {
return checkAccess(this.accessRepository, request); return checkAccess(this.accessRepository, request);
} }
async createUser(dto: Partial<UserEntity> & { email: string }): Promise<UserEntity> { async createUser(dto: Insertable<Users> & { email: string }): Promise<UserEntity> {
const user = await this.userRepository.getByEmail(dto.email); const user = await this.userRepository.getByEmail(dto.email);
if (user) { if (user) {
throw new BadRequestException('User exists'); throw new BadRequestException('User exists');
@ -144,7 +146,7 @@ export class BaseService {
} }
} }
const payload: Partial<UserEntity> = { ...dto }; const payload: Insertable<Users> = { ...dto };
if (payload.password) { if (payload.password) {
payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS); payload.password = await this.cryptoRepository.hashBcrypt(payload.password, SALT_ROUNDS);
} }

View File

@ -25,7 +25,7 @@ describe(CliService.name, () => {
describe('resetAdminPassword', () => { describe('resetAdminPassword', () => {
it('should only work when there is an admin account', async () => { it('should only work when there is an admin account', async () => {
userMock.getAdmin.mockResolvedValue(null); userMock.getAdmin.mockResolvedValue(void 0);
const ask = vitest.fn().mockResolvedValue('new-password'); const ask = vitest.fn().mockResolvedValue('new-password');
await expect(sut.resetAdminPassword(ask)).rejects.toThrowError('Admin account does not exist'); await expect(sut.resetAdminPassword(ask)).rejects.toThrowError('Admin account does not exist');

View File

@ -48,4 +48,8 @@ export class CliService extends BaseService {
config.oauth.enabled = true; config.oauth.enabled = true;
await this.updateConfig(config); await this.updateConfig(config);
} }
cleanup() {
return this.databaseRepository.shutdown();
}
} }

View File

@ -19,13 +19,13 @@ describe(UserAdminService.name, () => {
({ sut, jobMock, userMock } = newTestService(UserAdminService)); ({ sut, jobMock, userMock } = newTestService(UserAdminService));
userMock.get.mockImplementation((userId) => userMock.get.mockImplementation((userId) =>
Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? null), Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? undefined),
); );
}); });
describe('create', () => { describe('create', () => {
it('should not create a user if there is no local admin account', async () => { it('should not create a user if there is no local admin account', async () => {
userMock.getAdmin.mockResolvedValueOnce(null); userMock.getAdmin.mockResolvedValueOnce(void 0);
await expect( await expect(
sut.create({ sut.create({
@ -66,8 +66,8 @@ describe(UserAdminService.name, () => {
email: 'immich@test.com', email: 'immich@test.com',
storageLabel: 'storage_label', storageLabel: 'storage_label',
}; };
userMock.getByEmail.mockResolvedValue(null); userMock.getByEmail.mockResolvedValue(void 0);
userMock.getByStorageLabel.mockResolvedValue(null); userMock.getByStorageLabel.mockResolvedValue(void 0);
userMock.update.mockResolvedValue(userStub.user1); userMock.update.mockResolvedValue(userStub.user1);
await sut.update(authStub.user1, userStub.user1.id, update); await sut.update(authStub.user1, userStub.user1.id, update);
@ -108,7 +108,7 @@ describe(UserAdminService.name, () => {
}); });
it('update user information should throw error if user not found', async () => { it('update user information should throw error if user not found', async () => {
userMock.get.mockResolvedValueOnce(null); userMock.get.mockResolvedValueOnce(void 0);
await expect( await expect(
sut.update(authStub.admin, userStub.user1.id, { shouldChangePassword: true }), sut.update(authStub.admin, userStub.user1.id, { shouldChangePassword: true }),
@ -118,7 +118,7 @@ describe(UserAdminService.name, () => {
describe('delete', () => { describe('delete', () => {
it('should throw error if user could not be found', async () => { it('should throw error if user could not be found', async () => {
userMock.get.mockResolvedValue(null); userMock.get.mockResolvedValue(void 0);
await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException); await expect(sut.delete(authStub.admin, userStub.admin.id, {})).rejects.toThrowError(BadRequestException);
expect(userMock.delete).not.toHaveBeenCalled(); expect(userMock.delete).not.toHaveBeenCalled();
@ -166,7 +166,7 @@ describe(UserAdminService.name, () => {
describe('restore', () => { describe('restore', () => {
it('should throw error if user could not be found', async () => { it('should throw error if user could not be found', async () => {
userMock.get.mockResolvedValue(null); userMock.get.mockResolvedValue(void 0);
await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException); await expect(sut.restore(authStub.admin, userStub.admin.id)).rejects.toThrowError(BadRequestException);
expect(userMock.update).not.toHaveBeenCalled(); expect(userMock.update).not.toHaveBeenCalled();
}); });

View File

@ -33,7 +33,7 @@ describe(UserService.name, () => {
({ sut, albumMock, jobMock, storageMock, systemMock, userMock } = newTestService(UserService)); ({ sut, albumMock, jobMock, storageMock, systemMock, userMock } = newTestService(UserService));
userMock.get.mockImplementation((userId) => userMock.get.mockImplementation((userId) =>
Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? null), Promise.resolve([userStub.admin, userStub.user1].find((user) => user.id === userId) ?? undefined),
); );
}); });
@ -81,7 +81,7 @@ describe(UserService.name, () => {
}); });
it('should throw an error if a user is not found', async () => { it('should throw an error if a user is not found', async () => {
userMock.get.mockResolvedValue(null); userMock.get.mockResolvedValue(void 0);
await expect(sut.get(authStub.admin.user.id)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.get(authStub.admin.user.id)).rejects.toBeInstanceOf(BadRequestException);
expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false }); expect(userMock.get).toHaveBeenCalledWith(authStub.admin.user.id, { withDeleted: false });
}); });
@ -100,7 +100,7 @@ describe(UserService.name, () => {
describe('createProfileImage', () => { describe('createProfileImage', () => {
it('should throw an error if the user does not exist', async () => { it('should throw an error if the user does not exist', async () => {
const file = { path: '/profile/path' } as Express.Multer.File; const file = { path: '/profile/path' } as Express.Multer.File;
userMock.get.mockResolvedValue(null); userMock.get.mockResolvedValue(void 0);
userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path }); userMock.update.mockResolvedValue({ ...userStub.admin, profileImagePath: file.path });
await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(BadRequestException); await expect(sut.createProfileImage(authStub.admin, file)).rejects.toThrowError(BadRequestException);
@ -155,7 +155,7 @@ describe(UserService.name, () => {
describe('getUserProfileImage', () => { describe('getUserProfileImage', () => {
it('should throw an error if the user does not exist', async () => { it('should throw an error if the user does not exist', async () => {
userMock.get.mockResolvedValue(null); userMock.get.mockResolvedValue(void 0);
await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(BadRequestException); await expect(sut.getProfileImage(userStub.admin.id)).rejects.toBeInstanceOf(BadRequestException);

View File

@ -4,6 +4,7 @@ import { Mocked, vitest } from 'vitest';
export const newDatabaseRepositoryMock = (): Mocked<IDatabaseRepository> => { export const newDatabaseRepositoryMock = (): Mocked<IDatabaseRepository> => {
return { return {
init: vitest.fn(), init: vitest.fn(),
shutdown: vitest.fn(),
reconnect: vitest.fn(), reconnect: vitest.fn(),
getExtensionVersion: vitest.fn(), getExtensionVersion: vitest.fn(),
getExtensionVersionRange: vitest.fn(), getExtensionVersionRange: vitest.fn(),

2
web/package-lock.json generated
View File

@ -80,7 +80,7 @@
"@oazapfts/runtime": "^1.0.2" "@oazapfts/runtime": "^1.0.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.10.2", "@types/node": "^22.10.5",
"typescript": "^5.3.3" "typescript": "^5.3.3"
} }
}, },