refactor: medium tests (#19537)

This commit is contained in:
Jason Rasmussen
2025-06-26 15:32:06 -04:00
committed by GitHub
parent b96c95beda
commit 3105094a3d
19 changed files with 1579 additions and 2253 deletions

View File

@@ -1,12 +1,12 @@
import { ClassConstructor } from 'class-transformer';
/* eslint-disable @typescript-eslint/no-unsafe-function-type */
import { Insertable, Kysely } from 'kysely';
import { DateTime } from 'luxon';
import { createHash, randomBytes } from 'node:crypto';
import { Writable } from 'node:stream';
import { AssetFace } from 'src/database';
import { Albums, AssetJobStatus, Assets, DB, FaceSearch, Person, Sessions } from 'src/db';
import { Albums, AssetJobStatus, Assets, DB, Exif, FaceSearch, Person, Sessions } from 'src/db';
import { AuthDto } from 'src/dtos/auth.dto';
import { AssetType, AssetVisibility, SourceType, SyncRequestType } from 'src/enum';
import { AlbumUserRole, AssetType, AssetVisibility, SourceType, SyncRequestType } from 'src/enum';
import { AccessRepository } from 'src/repositories/access.repository';
import { ActivityRepository } from 'src/repositories/activity.repository';
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
@@ -31,298 +31,263 @@ import { SystemMetadataRepository } from 'src/repositories/system-metadata.repos
import { UserRepository } from 'src/repositories/user.repository';
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
import { UserTable } from 'src/schema/tables/user.table';
import { BaseService } from 'src/services/base.service';
import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service';
import { SyncService } from 'src/services/sync.service';
import { RepositoryInterface } from 'src/types';
import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory';
import { automock, ServiceOverrides, wait } from 'test/utils';
import { automock, wait } from 'test/utils';
import { Mocked } from 'vitest';
const sha256 = (value: string) => createHash('sha256').update(value).digest('base64');
interface ClassConstructor<T = any> extends Function {
new (...args: any[]): T;
}
// type Repositories = Omit<ServiceOverrides, 'access' | 'telemetry'>;
type RepositoriesTypes = {
access: AccessRepository;
activity: ActivityRepository;
album: AlbumRepository;
albumUser: AlbumUserRepository;
asset: AssetRepository;
assetJob: AssetJobRepository;
config: ConfigRepository;
crypto: CryptoRepository;
database: DatabaseRepository;
email: EmailRepository;
job: JobRepository;
user: UserRepository;
logger: LoggingRepository;
memory: MemoryRepository;
notification: NotificationRepository;
partner: PartnerRepository;
person: PersonRepository;
search: SearchRepository;
session: SessionRepository;
storage: StorageRepository;
sync: SyncRepository;
systemMetadata: SystemMetadataRepository;
versionHistory: VersionHistoryRepository;
};
type RepositoryMocks = { [K in keyof RepositoriesTypes]: Mocked<RepositoryInterface<RepositoriesTypes[K]>> };
type RepositoryOptions = Partial<{ [K in keyof RepositoriesTypes]: 'mock' | 'real' }>;
type ContextRepositoryMocks<R extends RepositoryOptions> = {
[K in keyof RepositoriesTypes as R[K] extends 'mock' ? K : never]: Mocked<RepositoryInterface<RepositoriesTypes[K]>>;
type MediumTestOptions = {
mock: ClassConstructor<any>[];
real: ClassConstructor<any>[];
database: Kysely<DB>;
};
type ContextRepositories<R extends RepositoryOptions> = {
[K in keyof RepositoriesTypes as R[K] extends 'real' ? K : never]: RepositoriesTypes[K];
export const newMediumService = <S extends BaseService>(Service: ClassConstructor<S>, options: MediumTestOptions) => {
const ctx = new MediumTestContext(Service, options);
return { sut: ctx.sut, ctx };
};
export type Context<R extends RepositoryOptions, S extends BaseService> = {
export class MediumTestContext<S extends BaseService = BaseService> {
private repoCache: Record<string, any> = {};
private sutDeps: any[];
sut: S;
mocks: ContextRepositoryMocks<R>;
repos: ContextRepositories<R>;
getRepository<T extends keyof RepositoriesTypes>(key: T): RepositoriesTypes[T];
};
database: Kysely<DB>;
export type SyncTestOptions = {
db: Kysely<DB>;
};
constructor(
Service: ClassConstructor<S>,
private options: MediumTestOptions,
) {
this.sutDeps = this.makeDeps(options);
this.sut = new Service(...this.sutDeps);
this.database = options.database;
}
export const newSyncAuthUser = () => {
const user = mediumFactory.userInsert();
const session = mediumFactory.sessionInsert({ userId: user.id });
private makeDeps(options: MediumTestOptions) {
const deps = BASE_SERVICE_DEPENDENCIES;
const auth = factory.auth({
session,
user: {
id: user.id,
name: user.name,
email: user.email,
},
});
for (const dep of options.mock) {
if (!deps.includes(dep)) {
throw new Error(`Mocked repository ${dep.name} is not a valid dependency`);
}
}
return {
auth,
session,
user,
create: async (db: Kysely<DB>) => {
await new UserRepository(db).create(user);
await new SessionRepository(db).create(session);
},
};
};
for (const dep of options.real) {
if (!deps.includes(dep)) {
throw new Error(`Real repository ${dep.name} is not a valid dependency`);
}
}
return (deps as ClassConstructor<any>[]).map((dep) => {
if (options.real.includes(dep)) {
return this.get(dep);
}
export const newSyncTest = (options: SyncTestOptions) => {
const { sut, mocks, repos, getRepository } = newMediumService(SyncService, {
database: options.db,
repos: {
sync: 'real',
session: 'real',
},
});
if (options.mock.includes(dep)) {
return newMockRepository(dep);
}
});
}
const testSync = async (auth: AuthDto, types: SyncRequestType[]) => {
get<T>(key: ClassConstructor<T>): T {
if (!this.repoCache[key.name]) {
const real = newRealRepository(key, this.options.database);
this.repoCache[key.name] = real;
}
return this.repoCache[key.name];
}
getMock<T, R = Mocked<T>>(key: ClassConstructor<T>): R {
const index = BASE_SERVICE_DEPENDENCIES.indexOf(key as any);
if (index === -1 || !this.options.mock.includes(key)) {
throw new Error(`getMock called with a key that is not a mock: ${key.name}`);
}
return this.sutDeps[index] as R;
}
async newUser(dto: Partial<Insertable<UserTable>> = {}) {
const user = mediumFactory.userInsert(dto);
const result = await this.get(UserRepository).create(user);
return { user, result };
}
async newPartner(dto: { sharedById: string; sharedWithId: string; inTimeline?: boolean }) {
const partner = { inTimeline: true, ...dto };
const result = await this.get(PartnerRepository).create(partner);
return { partner, result };
}
async newAsset(dto: Partial<Insertable<Assets>> = {}) {
const asset = mediumFactory.assetInsert(dto);
const result = await this.get(AssetRepository).create(asset);
return { asset, result };
}
async newExif(dto: Insertable<Exif>) {
const result = await this.get(AssetRepository).upsertExif(dto);
return { result };
}
async newAlbum(dto: Insertable<Albums>) {
const album = mediumFactory.albumInsert(dto);
const result = await this.get(AlbumRepository).create(album, [], []);
return { album, result };
}
async newAlbumAsset(albumAsset: { albumId: string; assetId: string }) {
const result = await this.get(AlbumRepository).addAssetIds(albumAsset.albumId, [albumAsset.assetId]);
return { albumAsset, result };
}
async newAlbumUser(dto: { albumId: string; userId: string; role?: AlbumUserRole }) {
const { albumId, userId, role = AlbumUserRole.EDITOR } = dto;
const result = await this.get(AlbumUserRepository).create({ albumsId: albumId, usersId: userId, role });
return { albumUser: { albumId, userId, role }, result };
}
async newJobStatus(dto: Partial<Insertable<AssetJobStatus>> & { assetId: string }) {
const jobStatus = mediumFactory.assetJobStatusInsert({ assetId: dto.assetId });
const result = await this.get(AssetRepository).upsertJobStatus(jobStatus);
return { jobStatus, result };
}
async newPerson(dto: Partial<Insertable<Person>> & { ownerId: string }) {
const person = mediumFactory.personInsert(dto);
const result = await this.get(PersonRepository).create(person);
return { person, result };
}
async newSession(dto: Partial<Insertable<Sessions>> & { userId: string }) {
const session = mediumFactory.sessionInsert(dto);
const result = await this.get(SessionRepository).create(session);
return { session, result };
}
async newSyncAuthUser() {
const { user } = await this.newUser();
const { session } = await this.newSession({ userId: user.id });
const auth = factory.auth({
session,
user: {
id: user.id,
name: user.name,
email: user.email,
},
});
return {
auth,
session,
user,
};
}
}
export class SyncTestContext extends MediumTestContext<SyncService> {
constructor(database: Kysely<DB>) {
super(SyncService, { database, real: [SyncRepository, SessionRepository], mock: [LoggingRepository] });
}
async syncStream(auth: AuthDto, types: SyncRequestType[]) {
const stream = mediumFactory.syncStream();
// Wait for 2ms to ensure all updates are available and account for setTimeout inaccuracy
await wait(2);
await sut.stream(auth, stream, { types });
await this.sut.stream(auth, stream, { types });
return stream.getResponse();
};
return {
sut,
mocks,
repos,
getRepository,
testSync,
};
};
export const newMediumService = <R extends RepositoryOptions, S extends BaseService>(
Service: ClassConstructor<S>,
options: {
database: Kysely<DB>;
repos: R;
},
): Context<R, S> => {
const repos: Partial<RepositoriesTypes> = {};
const mocks: Partial<RepositoryMocks> = {};
const loggerMock = getRepositoryMock('logger') as Mocked<LoggingRepository>;
loggerMock.setContext.mockImplementation(() => {});
repos.logger = loggerMock;
for (const [_key, type] of Object.entries(options.repos)) {
if (type === 'real') {
const key = _key as keyof RepositoriesTypes;
repos[key] = getRepository(key, options.database) as any;
continue;
}
if (type === 'mock') {
const key = _key as keyof RepositoryMocks;
mocks[key] = getRepositoryMock(key) as any;
continue;
}
}
const makeRepository = <K extends keyof RepositoriesTypes>(key: K) => {
return repos[key] || getRepository(key, options.database);
};
async syncAckAll(auth: AuthDto, response: Array<{ type: string; ack: string }>) {
const acks: Record<string, string> = {};
for (const { type, ack } of response) {
acks[type] = ack;
}
const deps = asDeps({ ...mocks, ...repos } as ServiceOverrides);
const sut = new Service(...deps);
await this.sut.setAcks(auth, { acks: Object.values(acks) });
}
}
return {
sut,
mocks,
repos,
getRepository: makeRepository,
} as Context<R, S>;
};
export const getRepository = <K extends keyof RepositoriesTypes>(key: K, db: Kysely<DB>) => {
const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
switch (key) {
case 'access': {
return new AccessRepository(db);
case AccessRepository:
case AlbumRepository:
case AlbumUserRepository:
case ActivityRepository:
case AssetRepository:
case AssetJobRepository:
case MemoryRepository:
case NotificationRepository:
case PartnerRepository:
case PersonRepository:
case SearchRepository:
case SessionRepository:
case SyncRepository:
case SystemMetadataRepository:
case UserRepository:
case VersionHistoryRepository: {
return new key(db);
}
case 'activity': {
return new ActivityRepository(db);
case ConfigRepository:
case CryptoRepository: {
return new key();
}
case 'album': {
return new AlbumRepository(db);
case DatabaseRepository: {
return new key(db, LoggingRepository.create(), new ConfigRepository());
}
case 'albumUser': {
return new AlbumUserRepository(db);
case EmailRepository: {
return new key(LoggingRepository.create());
}
case 'asset': {
return new AssetRepository(db);
}
case 'assetJob': {
return new AssetJobRepository(db);
}
case 'config': {
return new ConfigRepository();
}
case 'crypto': {
return new CryptoRepository();
}
case 'database': {
return new DatabaseRepository(db, LoggingRepository.create(), new ConfigRepository());
}
case 'email': {
return new EmailRepository(LoggingRepository.create());
}
case 'logger': {
return LoggingRepository.create();
}
case 'memory': {
return new MemoryRepository(db);
}
case 'notification': {
return new NotificationRepository(db);
}
case 'partner': {
return new PartnerRepository(db);
}
case 'person': {
return new PersonRepository(db);
}
case 'search': {
return new SearchRepository(db);
}
case 'session': {
return new SessionRepository(db);
}
case 'sync': {
return new SyncRepository(db);
}
case 'systemMetadata': {
return new SystemMetadataRepository(db);
}
case 'user': {
return new UserRepository(db);
}
case 'versionHistory': {
return new VersionHistoryRepository(db);
case LoggingRepository as unknown as ClassConstructor<LoggingRepository>: {
return new key() as unknown as T;
}
default: {
throw new Error(`Invalid repository key: ${key}`);
throw new Error(`Unable to create repository instance for key: ${key?.name || key}`);
}
}
};
const getRepositoryMock = <K extends keyof RepositoryMocks>(key: K) => {
const newMockRepository = <T>(key: ClassConstructor<T>) => {
switch (key) {
case 'activity': {
return automock(ActivityRepository) as Mocked<RepositoryInterface<ActivityRepository>>;
case ActivityRepository:
case AlbumRepository:
case AssetRepository:
case AssetJobRepository:
case ConfigRepository:
case CryptoRepository:
case MemoryRepository:
case NotificationRepository:
case PartnerRepository:
case PersonRepository:
case SessionRepository:
case SyncRepository:
case SystemMetadataRepository:
case UserRepository:
case VersionHistoryRepository: {
return automock(key);
}
case 'album': {
return automock(AlbumRepository);
}
case 'asset': {
return automock(AssetRepository);
}
case 'assetJob': {
return automock(AssetJobRepository);
}
case 'config': {
return automock(ConfigRepository);
}
case 'crypto': {
return automock(CryptoRepository);
}
case 'database': {
case DatabaseRepository: {
return automock(DatabaseRepository, {
args: [
undefined,
{
setContext: () => {},
},
{ getEnv: () => ({ database: { vectorExtension: '' } }) },
],
args: [undefined, { setContext: () => {} }, { getEnv: () => ({ database: { vectorExtension: '' } }) }],
});
}
case 'email': {
return automock(EmailRepository, {
args: [
{
setContext: () => {},
},
],
});
case EmailRepository: {
return automock(EmailRepository, { args: [{ setContext: () => {} }] });
}
case 'job': {
case JobRepository: {
return automock(JobRepository, {
args: [
undefined,
@@ -335,106 +300,21 @@ const getRepositoryMock = <K extends keyof RepositoryMocks>(key: K) => {
});
}
case 'logger': {
case LoggingRepository as unknown as ClassConstructor<T>: {
const configMock = { getEnv: () => ({ noColor: false }) };
return automock(LoggingRepository, { args: [undefined, configMock], strict: false });
}
case 'memory': {
return automock(MemoryRepository);
}
case 'notification': {
return automock(NotificationRepository);
}
case 'partner': {
return automock(PartnerRepository);
}
case 'person': {
return automock(PersonRepository);
}
case 'session': {
return automock(SessionRepository);
}
case 'storage': {
case StorageRepository: {
return automock(StorageRepository, { args: [{ setContext: () => {} }] });
}
case 'sync': {
return automock(SyncRepository);
}
case 'systemMetadata': {
return automock(SystemMetadataRepository);
}
case 'user': {
return automock(UserRepository);
}
case 'versionHistory': {
return automock(VersionHistoryRepository);
}
default: {
throw new Error(`Invalid repository key: ${key}`);
}
}
};
export const asDeps = (repositories: ServiceOverrides) => {
return [
repositories.logger || getRepositoryMock('logger'), // logger
repositories.access, // access
repositories.activity || getRepositoryMock('activity'),
repositories.album || getRepositoryMock('album'),
repositories.albumUser,
repositories.apiKey,
repositories.asset || getRepositoryMock('asset'),
repositories.assetJob || getRepositoryMock('assetJob'),
repositories.audit,
repositories.config || getRepositoryMock('config'),
repositories.cron,
repositories.crypto || getRepositoryMock('crypto'),
repositories.database || getRepositoryMock('database'),
repositories.downloadRepository,
repositories.duplicateRepository,
repositories.email || getRepositoryMock('email'),
repositories.event,
repositories.job || getRepositoryMock('job'),
repositories.library,
repositories.machineLearning,
repositories.map,
repositories.media,
repositories.memory || getRepositoryMock('memory'),
repositories.metadata,
repositories.move,
repositories.notification || getRepositoryMock('notification'),
repositories.oauth,
repositories.partner || getRepositoryMock('partner'),
repositories.person || getRepositoryMock('person'),
repositories.process,
repositories.search,
repositories.serverInfo,
repositories.session || getRepositoryMock('session'),
repositories.sharedLink,
repositories.stack,
repositories.storage || getRepositoryMock('storage'),
repositories.sync || getRepositoryMock('sync'),
repositories.systemMetadata || getRepositoryMock('systemMetadata'),
repositories.tag,
repositories.telemetry,
repositories.trash,
repositories.user,
repositories.versionHistory || getRepositoryMock('versionHistory'),
repositories.view,
];
};
const assetInsert = (asset: Partial<Insertable<Assets>> = {}) => {
const id = asset.id || newUuid();
const now = newDate();
@@ -544,6 +424,8 @@ const personInsert = (person: Partial<Insertable<Person>> & { ownerId: string })
};
};
const sha256 = (value: string) => createHash('sha256').update(value).digest('base64');
const sessionInsert = ({ id = newUuid(), userId, ...session }: Partial<Insertable<Sessions>> & { userId: string }) => {
const defaults: Insertable<Sessions> = {
id,