mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
Merge branch 'main' of https://github.com/immich-app/immich into feat/sidecar-asset-files
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
import { Insertable, Kysely } from 'kysely';
|
||||
import { DateTime } from 'luxon';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { Stats } from 'node:fs';
|
||||
import { Writable } from 'node:stream';
|
||||
import { AssetFace } from 'src/database';
|
||||
import { AuthDto, LoginResponseDto } from 'src/dtos/auth.dto';
|
||||
@@ -28,11 +29,14 @@ import { EventRepository } from 'src/repositories/event.repository';
|
||||
import { JobRepository } from 'src/repositories/job.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { MachineLearningRepository } from 'src/repositories/machine-learning.repository';
|
||||
import { MapRepository } from 'src/repositories/map.repository';
|
||||
import { MemoryRepository } from 'src/repositories/memory.repository';
|
||||
import { MetadataRepository } from 'src/repositories/metadata.repository';
|
||||
import { NotificationRepository } from 'src/repositories/notification.repository';
|
||||
import { OcrRepository } from 'src/repositories/ocr.repository';
|
||||
import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||
import { PersonRepository } from 'src/repositories/person.repository';
|
||||
import { PluginRepository } from 'src/repositories/plugin.repository';
|
||||
import { SearchRepository } from 'src/repositories/search.repository';
|
||||
import { SessionRepository } from 'src/repositories/session.repository';
|
||||
import { SharedLinkAssetRepository } from 'src/repositories/shared-link-asset.repository';
|
||||
@@ -46,6 +50,7 @@ import { TagRepository } from 'src/repositories/tag.repository';
|
||||
import { TelemetryRepository } from 'src/repositories/telemetry.repository';
|
||||
import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
import { WorkflowRepository } from 'src/repositories/workflow.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { AlbumTable } from 'src/schema/tables/album.table';
|
||||
import { AssetExifTable } from 'src/schema/tables/asset-exif.table';
|
||||
@@ -61,7 +66,9 @@ import { TagAssetTable } from 'src/schema/tables/tag-asset.table';
|
||||
import { TagTable } from 'src/schema/tables/tag.table';
|
||||
import { UserTable } from 'src/schema/tables/user.table';
|
||||
import { BASE_SERVICE_DEPENDENCIES, BaseService } from 'src/services/base.service';
|
||||
import { MetadataService } from 'src/services/metadata.service';
|
||||
import { SyncService } from 'src/services/sync.service';
|
||||
import { mockEnvData } from 'test/repositories/config.repository.mock';
|
||||
import { newTelemetryRepositoryMock } from 'test/repositories/telemetry.repository.mock';
|
||||
import { factory, newDate, newEmbedding, newUuid } from 'test/small.factory';
|
||||
import { automock, wait } from 'test/utils';
|
||||
@@ -212,7 +219,7 @@ export class MediumTestContext<S extends BaseService = BaseService> {
|
||||
|
||||
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 });
|
||||
const result = await this.get(AlbumUserRepository).create({ albumId, userId, role });
|
||||
return { albumUser: { albumId, userId, role }, result };
|
||||
}
|
||||
|
||||
@@ -255,9 +262,9 @@ export class MediumTestContext<S extends BaseService = BaseService> {
|
||||
|
||||
async newTagAsset(tagBulkAssets: { tagIds: string[]; assetIds: string[] }) {
|
||||
const tagsAssets: Insertable<TagAssetTable>[] = [];
|
||||
for (const tagsId of tagBulkAssets.tagIds) {
|
||||
for (const assetsId of tagBulkAssets.assetIds) {
|
||||
tagsAssets.push({ tagsId, assetsId });
|
||||
for (const tagId of tagBulkAssets.tagIds) {
|
||||
for (const assetId of tagBulkAssets.assetIds) {
|
||||
tagsAssets.push({ tagId, assetId });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,6 +312,63 @@ export class SyncTestContext extends MediumTestContext<SyncService> {
|
||||
}
|
||||
}
|
||||
|
||||
const mockDate = new Date('2024-06-01T12:00:00.000Z');
|
||||
const mockStats = {
|
||||
mtime: mockDate,
|
||||
atime: mockDate,
|
||||
ctime: mockDate,
|
||||
birthtime: mockDate,
|
||||
atimeMs: 0,
|
||||
mtimeMs: 0,
|
||||
ctimeMs: 0,
|
||||
birthtimeMs: 0,
|
||||
};
|
||||
|
||||
export class ExifTestContext extends MediumTestContext<MetadataService> {
|
||||
constructor(database: Kysely<DB>) {
|
||||
super(MetadataService, {
|
||||
database,
|
||||
real: [AssetRepository, AssetJobRepository, MetadataRepository, SystemMetadataRepository, TagRepository],
|
||||
mock: [ConfigRepository, EventRepository, LoggingRepository, MapRepository, StorageRepository],
|
||||
});
|
||||
|
||||
this.getMock(ConfigRepository).getEnv.mockReturnValue(mockEnvData({}));
|
||||
this.getMock(EventRepository).emit.mockResolvedValue();
|
||||
this.getMock(MapRepository).reverseGeocode.mockResolvedValue({ country: null, state: null, city: null });
|
||||
this.getMock(StorageRepository).stat.mockResolvedValue(mockStats as Stats);
|
||||
}
|
||||
|
||||
getMockStats() {
|
||||
return mockStats;
|
||||
}
|
||||
|
||||
getGps(assetId: string) {
|
||||
return this.database
|
||||
.selectFrom('asset_exif')
|
||||
.select(['latitude', 'longitude'])
|
||||
.where('assetId', '=', assetId)
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
|
||||
getTags(assetId: string) {
|
||||
return this.database
|
||||
.selectFrom('tag')
|
||||
.innerJoin('tag_asset', 'tag.id', 'tag_asset.tagId')
|
||||
.where('tag_asset.assetId', '=', assetId)
|
||||
.selectAll()
|
||||
.execute();
|
||||
}
|
||||
|
||||
getDates(assetId: string) {
|
||||
return this.database
|
||||
.selectFrom('asset')
|
||||
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
|
||||
.where('id', '=', assetId)
|
||||
.select(['asset.fileCreatedAt', 'asset.localDateTime', 'asset_exif.dateTimeOriginal', 'asset_exif.timeZone'])
|
||||
.executeTakeFirstOrThrow();
|
||||
}
|
||||
}
|
||||
|
||||
const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
|
||||
switch (key) {
|
||||
case AccessRepository:
|
||||
@@ -318,6 +382,7 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
|
||||
case OcrRepository:
|
||||
case PartnerRepository:
|
||||
case PersonRepository:
|
||||
case PluginRepository:
|
||||
case SearchRepository:
|
||||
case SessionRepository:
|
||||
case SharedLinkRepository:
|
||||
@@ -327,7 +392,8 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
|
||||
case SyncCheckpointRepository:
|
||||
case SystemMetadataRepository:
|
||||
case UserRepository:
|
||||
case VersionHistoryRepository: {
|
||||
case VersionHistoryRepository:
|
||||
case WorkflowRepository: {
|
||||
return new key(db);
|
||||
}
|
||||
|
||||
@@ -344,6 +410,14 @@ const newRealRepository = <T>(key: ClassConstructor<T>, db: Kysely<DB>): T => {
|
||||
return new key(LoggingRepository.create());
|
||||
}
|
||||
|
||||
case MetadataRepository: {
|
||||
return new key(LoggingRepository.create());
|
||||
}
|
||||
|
||||
case StorageRepository: {
|
||||
return new key(LoggingRepository.create());
|
||||
}
|
||||
|
||||
case TagRepository: {
|
||||
return new key(db, LoggingRepository.create());
|
||||
}
|
||||
@@ -371,16 +445,22 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
|
||||
case OcrRepository:
|
||||
case PartnerRepository:
|
||||
case PersonRepository:
|
||||
case PluginRepository:
|
||||
case SessionRepository:
|
||||
case SyncRepository:
|
||||
case SyncCheckpointRepository:
|
||||
case SystemMetadataRepository:
|
||||
case UserRepository:
|
||||
case VersionHistoryRepository:
|
||||
case TagRepository: {
|
||||
case TagRepository:
|
||||
case WorkflowRepository: {
|
||||
return automock(key);
|
||||
}
|
||||
|
||||
case MapRepository: {
|
||||
return automock(MapRepository, { args: [undefined, undefined, { setContext: () => {} }] });
|
||||
}
|
||||
|
||||
case TelemetryRepository: {
|
||||
return newTelemetryRepositoryMock();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { DateTime } from 'luxon';
|
||||
import { resolve } from 'node:path';
|
||||
import { DB } from 'src/schema';
|
||||
import { ExifTestContext } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let database: Kysely<DB>;
|
||||
|
||||
const setup = async (testAssetPath: string) => {
|
||||
const ctx = new ExifTestContext(database);
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
|
||||
|
||||
return { ctx, sut: ctx.sut, asset };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
database = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe('exif date time', () => {
|
||||
it('should prioritize DateTimeOriginal', async () => {
|
||||
const { ctx, sut, asset } = await setup('metadata/dates/date-priority-test.jpg');
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
await expect(ctx.getDates(asset.id)).resolves.toEqual({
|
||||
timeZone: null,
|
||||
dateTimeOriginal: DateTime.fromISO('2023-02-02T02:00:00.000Z').toJSDate(),
|
||||
localDateTime: DateTime.fromISO('2023-02-02T02:00:00.000Z').toJSDate(),
|
||||
fileCreatedAt: DateTime.fromISO('2023-02-02T02:00:00.000Z').toJSDate(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should extract GPSDateTime with GPS coordinates ', async () => {
|
||||
const { ctx, sut, asset } = await setup('metadata/dates/gps-datetime.jpg');
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
await expect(ctx.getDates(asset.id)).resolves.toEqual({
|
||||
timeZone: 'America/Los_Angeles',
|
||||
dateTimeOriginal: DateTime.fromISO('2023-11-15T12:30:00.000Z').toJSDate(),
|
||||
localDateTime: DateTime.fromISO('2023-11-15T04:30:00.000Z').toJSDate(),
|
||||
fileCreatedAt: DateTime.fromISO('2023-11-15T12:30:00.000Z').toJSDate(),
|
||||
});
|
||||
});
|
||||
|
||||
it('should ignore the TimeCreated tag', async () => {
|
||||
const { ctx, sut, asset } = await setup('metadata/dates/time-created.jpg');
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
const stats = ctx.getMockStats();
|
||||
|
||||
await expect(ctx.getDates(asset.id)).resolves.toEqual({
|
||||
timeZone: null,
|
||||
dateTimeOriginal: stats.mtime,
|
||||
localDateTime: stats.mtime,
|
||||
fileCreatedAt: stats.mtime,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { resolve } from 'node:path';
|
||||
import { DB } from 'src/schema';
|
||||
import { ExifTestContext } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let database: Kysely<DB>;
|
||||
|
||||
const setup = async (testAssetPath: string) => {
|
||||
const ctx = new ExifTestContext(database);
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
|
||||
|
||||
return { ctx, sut: ctx.sut, asset };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
database = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe('exif gps', () => {
|
||||
it('should handle empty strings', async () => {
|
||||
const { ctx, sut, asset } = await setup('metadata/gps-position/empty_gps.jpg');
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
await expect(ctx.getGps(asset.id)).resolves.toEqual({ latitude: null, longitude: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { resolve } from 'node:path';
|
||||
import { DB } from 'src/schema';
|
||||
import { ExifTestContext } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let database: Kysely<DB>;
|
||||
|
||||
const setup = async (testAssetPath: string) => {
|
||||
const ctx = new ExifTestContext(database);
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
const originalPath = resolve(`../e2e/test-assets/${testAssetPath}`);
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id, originalPath });
|
||||
|
||||
return { ctx, sut: ctx.sut, asset };
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
database = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe('exif tags', () => {
|
||||
it('should detect and regular tags', async () => {
|
||||
const { ctx, sut, asset } = await setup('metadata/tags/picasa.jpg');
|
||||
|
||||
await sut.handleMetadataExtraction({ id: asset.id });
|
||||
|
||||
await expect(ctx.getTags(asset.id)).resolves.toEqual([
|
||||
expect.objectContaining({ assetId: asset.id, value: 'Frost', parentId: null }),
|
||||
expect.objectContaining({ assetId: asset.id, value: 'Yard', parentId: null }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -185,16 +185,14 @@ describe(AssetService.name, () => {
|
||||
|
||||
const { user } = await ctx.newUser();
|
||||
|
||||
const { asset: oldAssetWithoutSidecar } = await ctx.newAsset({ ownerId: user.id });
|
||||
const { asset: oldAsset } = await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
const sidecarFile = await ctx.newAssetFile({
|
||||
assetId: oldAssetWithoutSidecar.id,
|
||||
await ctx.newAssetFile({
|
||||
assetId: oldAsset.id,
|
||||
path: '/path/to/my/sidecar.xmp',
|
||||
type: AssetFileType.Sidecar,
|
||||
});
|
||||
|
||||
const oldAsset = { ...oldAssetWithoutSidecar, files: [sidecarFile] };
|
||||
|
||||
const { asset: newAsset } = await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
await ctx.newExif({ assetId: oldAsset.id, description: 'foo' });
|
||||
|
||||
@@ -153,6 +153,46 @@ describe(MemoryService.name, () => {
|
||||
);
|
||||
});
|
||||
|
||||
it('should create a memory from an asset - in advance', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const assetRepo = ctx.get(AssetRepository);
|
||||
const memoryRepo = ctx.get(MemoryRepository);
|
||||
const now = DateTime.fromObject({ year: 2035, month: 2, day: 26 }, { zone: 'utc' }) as DateTime<true>;
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id, localDateTime: now.minus({ years: 1 }).toISO() });
|
||||
await Promise.all([
|
||||
ctx.newExif({ assetId: asset.id, make: 'Canon' }),
|
||||
ctx.newJobStatus({ assetId: asset.id }),
|
||||
assetRepo.upsertFiles([
|
||||
{ assetId: asset.id, type: AssetFileType.Preview, path: '/path/to/preview.jpg' },
|
||||
{ assetId: asset.id, type: AssetFileType.Thumbnail, path: '/path/to/thumbnail.jpg' },
|
||||
]),
|
||||
]);
|
||||
|
||||
vi.setSystemTime(now.toJSDate());
|
||||
await sut.onMemoriesCreate();
|
||||
|
||||
const memories = await memoryRepo.search(user.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: user.id,
|
||||
assets: expect.arrayContaining([expect.objectContaining({ id: asset.id })]),
|
||||
isSaved: false,
|
||||
showAt: now.startOf('day').toJSDate(),
|
||||
hideAt: now.endOf('day').toJSDate(),
|
||||
seenAt: null,
|
||||
type: 'on_this_day',
|
||||
data: { year: 2034 },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should not generate a memory twice for the same day', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const assetRepo = ctx.get(AssetRepository);
|
||||
|
||||
@@ -65,42 +65,6 @@ describe(MetadataService.name, () => {
|
||||
timeZone: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'should handle no time zone information and server behind UTC',
|
||||
serverTimeZone: 'America/Los_Angeles',
|
||||
exifData: {
|
||||
DateTimeOriginal: '2022:01:01 00:00:00',
|
||||
},
|
||||
expected: {
|
||||
localDateTime: '2022-01-01T00:00:00.000Z',
|
||||
dateTimeOriginal: '2022-01-01T08:00:00.000Z',
|
||||
timeZone: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'should handle no time zone information and server ahead of UTC',
|
||||
serverTimeZone: 'Europe/Brussels',
|
||||
exifData: {
|
||||
DateTimeOriginal: '2022:01:01 00:00:00',
|
||||
},
|
||||
expected: {
|
||||
localDateTime: '2022-01-01T00:00:00.000Z',
|
||||
dateTimeOriginal: '2021-12-31T23:00:00.000Z',
|
||||
timeZone: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'should handle no time zone information and server ahead of UTC in the summer',
|
||||
serverTimeZone: 'Europe/Brussels',
|
||||
exifData: {
|
||||
DateTimeOriginal: '2022:06:01 00:00:00',
|
||||
},
|
||||
expected: {
|
||||
localDateTime: '2022-06-01T00:00:00.000Z',
|
||||
dateTimeOriginal: '2022-05-31T22:00:00.000Z',
|
||||
timeZone: null,
|
||||
},
|
||||
},
|
||||
{
|
||||
description: 'should handle a +13:00 time zone',
|
||||
exifData: {
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { PluginContext } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { PluginRepository } from 'src/repositories/plugin.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { PluginService } from 'src/services/plugin.service';
|
||||
import { newMediumService } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
let pluginRepo: PluginRepository;
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
return newMediumService(PluginService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [PluginRepository, AccessRepository],
|
||||
mock: [LoggingRepository],
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
pluginRepo = new PluginRepository(defaultDatabase);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await defaultDatabase.deleteFrom('plugin').execute();
|
||||
});
|
||||
|
||||
describe(PluginService.name, () => {
|
||||
describe('getAll', () => {
|
||||
it('should return empty array when no plugins exist', async () => {
|
||||
const { sut } = setup();
|
||||
|
||||
const plugins = await sut.getAll();
|
||||
|
||||
expect(plugins).toEqual([]);
|
||||
});
|
||||
|
||||
it('should return plugin without filters and actions', async () => {
|
||||
const { sut } = setup();
|
||||
|
||||
const result = await pluginRepo.loadPlugin(
|
||||
{
|
||||
name: 'test-plugin',
|
||||
title: 'Test Plugin',
|
||||
description: 'A test plugin',
|
||||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
wasm: { path: '/path/to/test.wasm' },
|
||||
},
|
||||
'/test/base/path',
|
||||
);
|
||||
|
||||
const plugins = await sut.getAll();
|
||||
|
||||
expect(plugins).toHaveLength(1);
|
||||
expect(plugins[0]).toMatchObject({
|
||||
id: result.plugin.id,
|
||||
name: 'test-plugin',
|
||||
description: 'A test plugin',
|
||||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return plugin with filters and actions', async () => {
|
||||
const { sut } = setup();
|
||||
|
||||
const result = await pluginRepo.loadPlugin(
|
||||
{
|
||||
name: 'full-plugin',
|
||||
title: 'Full Plugin',
|
||||
description: 'A plugin with filters and actions',
|
||||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
wasm: { path: '/path/to/full.wasm' },
|
||||
filters: [
|
||||
{
|
||||
methodName: 'test-filter',
|
||||
title: 'Test Filter',
|
||||
description: 'A test filter',
|
||||
supportedContexts: [PluginContext.Asset],
|
||||
schema: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
methodName: 'test-action',
|
||||
title: 'Test Action',
|
||||
description: 'A test action',
|
||||
supportedContexts: [PluginContext.Asset],
|
||||
schema: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
},
|
||||
'/test/base/path',
|
||||
);
|
||||
|
||||
const plugins = await sut.getAll();
|
||||
|
||||
expect(plugins).toHaveLength(1);
|
||||
expect(plugins[0]).toMatchObject({
|
||||
id: result.plugin.id,
|
||||
name: 'full-plugin',
|
||||
filters: [
|
||||
{
|
||||
id: result.filters[0].id,
|
||||
pluginId: result.plugin.id,
|
||||
methodName: 'test-filter',
|
||||
title: 'Test Filter',
|
||||
description: 'A test filter',
|
||||
supportedContexts: [PluginContext.Asset],
|
||||
schema: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: result.actions[0].id,
|
||||
pluginId: result.plugin.id,
|
||||
methodName: 'test-action',
|
||||
title: 'Test Action',
|
||||
description: 'A test action',
|
||||
supportedContexts: [PluginContext.Asset],
|
||||
schema: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return multiple plugins with their respective filters and actions', async () => {
|
||||
const { sut } = setup();
|
||||
|
||||
await pluginRepo.loadPlugin(
|
||||
{
|
||||
name: 'plugin-1',
|
||||
title: 'Plugin 1',
|
||||
description: 'First plugin',
|
||||
author: 'Author 1',
|
||||
version: '1.0.0',
|
||||
wasm: { path: '/path/to/plugin1.wasm' },
|
||||
filters: [
|
||||
{
|
||||
methodName: 'filter-1',
|
||||
title: 'Filter 1',
|
||||
description: 'Filter for plugin 1',
|
||||
supportedContexts: [PluginContext.Asset],
|
||||
schema: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
'/test/base/path',
|
||||
);
|
||||
|
||||
await pluginRepo.loadPlugin(
|
||||
{
|
||||
name: 'plugin-2',
|
||||
title: 'Plugin 2',
|
||||
description: 'Second plugin',
|
||||
author: 'Author 2',
|
||||
version: '2.0.0',
|
||||
wasm: { path: '/path/to/plugin2.wasm' },
|
||||
actions: [
|
||||
{
|
||||
methodName: 'action-2',
|
||||
title: 'Action 2',
|
||||
description: 'Action for plugin 2',
|
||||
supportedContexts: [PluginContext.Album],
|
||||
schema: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
'/test/base/path',
|
||||
);
|
||||
|
||||
const plugins = await sut.getAll();
|
||||
|
||||
expect(plugins).toHaveLength(2);
|
||||
expect(plugins[0].name).toBe('plugin-1');
|
||||
expect(plugins[0].filters).toHaveLength(1);
|
||||
expect(plugins[0].actions).toHaveLength(0);
|
||||
|
||||
expect(plugins[1].name).toBe('plugin-2');
|
||||
expect(plugins[1].filters).toHaveLength(0);
|
||||
expect(plugins[1].actions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle plugin with multiple filters and actions', async () => {
|
||||
const { sut } = setup();
|
||||
|
||||
await pluginRepo.loadPlugin(
|
||||
{
|
||||
name: 'multi-plugin',
|
||||
title: 'Multi Plugin',
|
||||
description: 'Plugin with multiple items',
|
||||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
wasm: { path: '/path/to/multi.wasm' },
|
||||
filters: [
|
||||
{
|
||||
methodName: 'filter-a',
|
||||
title: 'Filter A',
|
||||
description: 'First filter',
|
||||
supportedContexts: [PluginContext.Asset],
|
||||
schema: undefined,
|
||||
},
|
||||
{
|
||||
methodName: 'filter-b',
|
||||
title: 'Filter B',
|
||||
description: 'Second filter',
|
||||
supportedContexts: [PluginContext.Album],
|
||||
schema: undefined,
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
methodName: 'action-x',
|
||||
title: 'Action X',
|
||||
description: 'First action',
|
||||
supportedContexts: [PluginContext.Asset],
|
||||
schema: undefined,
|
||||
},
|
||||
{
|
||||
methodName: 'action-y',
|
||||
title: 'Action Y',
|
||||
description: 'Second action',
|
||||
supportedContexts: [PluginContext.Person],
|
||||
schema: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
'/test/base/path',
|
||||
);
|
||||
|
||||
const plugins = await sut.getAll();
|
||||
|
||||
expect(plugins).toHaveLength(1);
|
||||
expect(plugins[0].filters).toHaveLength(2);
|
||||
expect(plugins[0].actions).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should throw error when plugin does not exist', async () => {
|
||||
const { sut } = setup();
|
||||
|
||||
await expect(sut.get('00000000-0000-0000-0000-000000000000')).rejects.toThrow('Plugin not found');
|
||||
});
|
||||
|
||||
it('should return single plugin with filters and actions', async () => {
|
||||
const { sut } = setup();
|
||||
|
||||
const result = await pluginRepo.loadPlugin(
|
||||
{
|
||||
name: 'single-plugin',
|
||||
title: 'Single Plugin',
|
||||
description: 'A single plugin',
|
||||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
wasm: { path: '/path/to/single.wasm' },
|
||||
filters: [
|
||||
{
|
||||
methodName: 'single-filter',
|
||||
title: 'Single Filter',
|
||||
description: 'A single filter',
|
||||
supportedContexts: [PluginContext.Asset],
|
||||
schema: undefined,
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
methodName: 'single-action',
|
||||
title: 'Single Action',
|
||||
description: 'A single action',
|
||||
supportedContexts: [PluginContext.Asset],
|
||||
schema: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
'/test/base/path',
|
||||
);
|
||||
|
||||
const pluginResult = await sut.get(result.plugin.id);
|
||||
|
||||
expect(pluginResult).toMatchObject({
|
||||
id: result.plugin.id,
|
||||
name: 'single-plugin',
|
||||
filters: [
|
||||
{
|
||||
id: result.filters[0].id,
|
||||
methodName: 'single-filter',
|
||||
title: 'Single Filter',
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: result.actions[0].id,
|
||||
methodName: 'single-action',
|
||||
title: 'Single Action',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,682 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { PluginContext, PluginTriggerType } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { PluginRepository } from 'src/repositories/plugin.repository';
|
||||
import { WorkflowRepository } from 'src/repositories/workflow.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { WorkflowService } from 'src/services/workflow.service';
|
||||
import { newMediumService } from 'test/medium.factory';
|
||||
import { factory } from 'test/small.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
return newMediumService(WorkflowService, {
|
||||
database: db || defaultDatabase,
|
||||
real: [WorkflowRepository, PluginRepository, AccessRepository],
|
||||
mock: [LoggingRepository],
|
||||
});
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
});
|
||||
|
||||
describe(WorkflowService.name, () => {
|
||||
let testPluginId: string;
|
||||
let testFilterId: string;
|
||||
let testActionId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Create a test plugin with filters and actions once for all tests
|
||||
const pluginRepo = new PluginRepository(defaultDatabase);
|
||||
const result = await pluginRepo.loadPlugin(
|
||||
{
|
||||
name: 'test-core-plugin',
|
||||
title: 'Test Core Plugin',
|
||||
description: 'A test core plugin for workflow tests',
|
||||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
wasm: {
|
||||
path: '/test/path.wasm',
|
||||
},
|
||||
filters: [
|
||||
{
|
||||
methodName: 'test-filter',
|
||||
title: 'Test Filter',
|
||||
description: 'A test filter',
|
||||
supportedContexts: [PluginContext.Asset],
|
||||
schema: undefined,
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
methodName: 'test-action',
|
||||
title: 'Test Action',
|
||||
description: 'A test action',
|
||||
supportedContexts: [PluginContext.Asset],
|
||||
schema: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
'/plugins/test-core-plugin',
|
||||
);
|
||||
|
||||
testPluginId = result.plugin.id;
|
||||
testFilterId = result.filters[0].id;
|
||||
testActionId = result.actions[0].id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await defaultDatabase.deleteFrom('plugin').where('id', '=', testPluginId).execute();
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a workflow without filters or actions', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const workflow = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'test-workflow',
|
||||
description: 'A test workflow',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
expect(workflow).toMatchObject({
|
||||
id: expect.any(String),
|
||||
ownerId: user.id,
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'test-workflow',
|
||||
description: 'A test workflow',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should create a workflow with filters and actions', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const workflow = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'test-workflow-with-relations',
|
||||
description: 'A test workflow with filters and actions',
|
||||
enabled: true,
|
||||
filters: [
|
||||
{
|
||||
filterId: testFilterId,
|
||||
filterConfig: { key: 'value' },
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
actionId: testActionId,
|
||||
actionConfig: { action: 'test' },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(workflow).toMatchObject({
|
||||
id: expect.any(String),
|
||||
ownerId: user.id,
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'test-workflow-with-relations',
|
||||
enabled: true,
|
||||
});
|
||||
|
||||
expect(workflow.filters).toHaveLength(1);
|
||||
expect(workflow.filters[0]).toMatchObject({
|
||||
id: expect.any(String),
|
||||
workflowId: workflow.id,
|
||||
filterId: testFilterId,
|
||||
filterConfig: { key: 'value' },
|
||||
order: 0,
|
||||
});
|
||||
|
||||
expect(workflow.actions).toHaveLength(1);
|
||||
expect(workflow.actions[0]).toMatchObject({
|
||||
id: expect.any(String),
|
||||
workflowId: workflow.id,
|
||||
actionId: testActionId,
|
||||
actionConfig: { action: 'test' },
|
||||
order: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should throw error when creating workflow with invalid filter', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
await expect(
|
||||
sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'invalid-workflow',
|
||||
description: 'A workflow with invalid filter',
|
||||
enabled: true,
|
||||
filters: [{ filterId: factory.uuid(), filterConfig: { key: 'value' } }],
|
||||
actions: [],
|
||||
}),
|
||||
).rejects.toThrow('Invalid filter ID');
|
||||
});
|
||||
|
||||
it('should throw error when creating workflow with invalid action', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
await expect(
|
||||
sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'invalid-workflow',
|
||||
description: 'A workflow with invalid action',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [{ actionId: factory.uuid(), actionConfig: { action: 'test' } }],
|
||||
}),
|
||||
).rejects.toThrow('Invalid action ID');
|
||||
});
|
||||
|
||||
it('should throw error when filter does not support trigger context', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
// Create a plugin with a filter that only supports Album context
|
||||
const pluginRepo = new PluginRepository(defaultDatabase);
|
||||
const result = await pluginRepo.loadPlugin(
|
||||
{
|
||||
name: 'album-only-plugin',
|
||||
title: 'Album Only Plugin',
|
||||
description: 'Plugin with album-only filter',
|
||||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
wasm: { path: '/test/album-plugin.wasm' },
|
||||
filters: [
|
||||
{
|
||||
methodName: 'album-filter',
|
||||
title: 'Album Filter',
|
||||
description: 'A filter that only works with albums',
|
||||
supportedContexts: [PluginContext.Album],
|
||||
schema: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
'/plugins/test-core-plugin',
|
||||
);
|
||||
|
||||
await expect(
|
||||
sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'invalid-context-workflow',
|
||||
description: 'A workflow with context mismatch',
|
||||
enabled: true,
|
||||
filters: [{ filterId: result.filters[0].id }],
|
||||
actions: [],
|
||||
}),
|
||||
).rejects.toThrow('does not support asset context');
|
||||
});
|
||||
|
||||
it('should throw error when action does not support trigger context', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
// Create a plugin with an action that only supports Person context
|
||||
const pluginRepo = new PluginRepository(defaultDatabase);
|
||||
const result = await pluginRepo.loadPlugin(
|
||||
{
|
||||
name: 'person-only-plugin',
|
||||
title: 'Person Only Plugin',
|
||||
description: 'Plugin with person-only action',
|
||||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
wasm: { path: '/test/person-plugin.wasm' },
|
||||
actions: [
|
||||
{
|
||||
methodName: 'person-action',
|
||||
title: 'Person Action',
|
||||
description: 'An action that only works with persons',
|
||||
supportedContexts: [PluginContext.Person],
|
||||
schema: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
'/plugins/test-core-plugin',
|
||||
);
|
||||
|
||||
await expect(
|
||||
sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'invalid-context-workflow',
|
||||
description: 'A workflow with context mismatch',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [{ actionId: result.actions[0].id }],
|
||||
}),
|
||||
).rejects.toThrow('does not support asset context');
|
||||
});
|
||||
|
||||
it('should create workflow with multiple filters and actions in correct order', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const workflow = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'multi-step-workflow',
|
||||
description: 'A workflow with multiple filters and actions',
|
||||
enabled: true,
|
||||
filters: [
|
||||
{ filterId: testFilterId, filterConfig: { step: 1 } },
|
||||
{ filterId: testFilterId, filterConfig: { step: 2 } },
|
||||
],
|
||||
actions: [
|
||||
{ actionId: testActionId, actionConfig: { step: 1 } },
|
||||
{ actionId: testActionId, actionConfig: { step: 2 } },
|
||||
{ actionId: testActionId, actionConfig: { step: 3 } },
|
||||
],
|
||||
});
|
||||
|
||||
expect(workflow.filters).toHaveLength(2);
|
||||
expect(workflow.filters[0].order).toBe(0);
|
||||
expect(workflow.filters[0].filterConfig).toEqual({ step: 1 });
|
||||
expect(workflow.filters[1].order).toBe(1);
|
||||
expect(workflow.filters[1].filterConfig).toEqual({ step: 2 });
|
||||
|
||||
expect(workflow.actions).toHaveLength(3);
|
||||
expect(workflow.actions[0].order).toBe(0);
|
||||
expect(workflow.actions[1].order).toBe(1);
|
||||
expect(workflow.actions[2].order).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('should return all workflows for a user', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const workflow1 = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'workflow-1',
|
||||
description: 'First workflow',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const workflow2 = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'workflow-2',
|
||||
description: 'Second workflow',
|
||||
enabled: false,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const workflows = await sut.getAll(auth);
|
||||
|
||||
expect(workflows).toHaveLength(2);
|
||||
expect(workflows).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ id: workflow1.id, name: 'workflow-1' }),
|
||||
expect.objectContaining({ id: workflow2.id, name: 'workflow-2' }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return empty array when user has no workflows', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const workflows = await sut.getAll(auth);
|
||||
|
||||
expect(workflows).toEqual([]);
|
||||
});
|
||||
|
||||
it('should not return workflows from other users', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user: user1 } = await ctx.newUser();
|
||||
const { user: user2 } = await ctx.newUser();
|
||||
const auth1 = factory.auth({ user: user1 });
|
||||
const auth2 = factory.auth({ user: user2 });
|
||||
|
||||
await sut.create(auth1, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'user1-workflow',
|
||||
description: 'User 1 workflow',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const user2Workflows = await sut.getAll(auth2);
|
||||
|
||||
expect(user2Workflows).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get', () => {
|
||||
it('should return a specific workflow by id', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const created = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'test-workflow',
|
||||
description: 'A test workflow',
|
||||
enabled: true,
|
||||
filters: [{ filterId: testFilterId, filterConfig: { key: 'value' } }],
|
||||
actions: [{ actionId: testActionId, actionConfig: { action: 'test' } }],
|
||||
});
|
||||
|
||||
const workflow = await sut.get(auth, created.id);
|
||||
|
||||
expect(workflow).toMatchObject({
|
||||
id: created.id,
|
||||
name: 'test-workflow',
|
||||
description: 'A test workflow',
|
||||
enabled: true,
|
||||
});
|
||||
expect(workflow.filters).toHaveLength(1);
|
||||
expect(workflow.actions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should throw error when workflow does not exist', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
await expect(sut.get(auth, '66da82df-e424-4bf4-b6f3-5d8e71620dae')).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error when user does not have access to workflow', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user: user1 } = await ctx.newUser();
|
||||
const { user: user2 } = await ctx.newUser();
|
||||
const auth1 = factory.auth({ user: user1 });
|
||||
const auth2 = factory.auth({ user: user2 });
|
||||
|
||||
const workflow = await sut.create(auth1, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'private-workflow',
|
||||
description: 'Private workflow',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await expect(sut.get(auth2, workflow.id)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('should update workflow basic fields', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const created = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'original-workflow',
|
||||
description: 'Original description',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const updated = await sut.update(auth, created.id, {
|
||||
name: 'updated-workflow',
|
||||
description: 'Updated description',
|
||||
enabled: false,
|
||||
});
|
||||
|
||||
expect(updated).toMatchObject({
|
||||
id: created.id,
|
||||
name: 'updated-workflow',
|
||||
description: 'Updated description',
|
||||
enabled: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('should update workflow filters', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const created = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'test-workflow',
|
||||
description: 'Test',
|
||||
enabled: true,
|
||||
filters: [{ filterId: testFilterId, filterConfig: { old: 'config' } }],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const updated = await sut.update(auth, created.id, {
|
||||
filters: [
|
||||
{ filterId: testFilterId, filterConfig: { new: 'config' } },
|
||||
{ filterId: testFilterId, filterConfig: { second: 'filter' } },
|
||||
],
|
||||
});
|
||||
|
||||
expect(updated.filters).toHaveLength(2);
|
||||
expect(updated.filters[0].filterConfig).toEqual({ new: 'config' });
|
||||
expect(updated.filters[1].filterConfig).toEqual({ second: 'filter' });
|
||||
});
|
||||
|
||||
it('should update workflow actions', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const created = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'test-workflow',
|
||||
description: 'Test',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [{ actionId: testActionId, actionConfig: { old: 'config' } }],
|
||||
});
|
||||
|
||||
const updated = await sut.update(auth, created.id, {
|
||||
actions: [
|
||||
{ actionId: testActionId, actionConfig: { new: 'config' } },
|
||||
{ actionId: testActionId, actionConfig: { second: 'action' } },
|
||||
],
|
||||
});
|
||||
|
||||
expect(updated.actions).toHaveLength(2);
|
||||
expect(updated.actions[0].actionConfig).toEqual({ new: 'config' });
|
||||
expect(updated.actions[1].actionConfig).toEqual({ second: 'action' });
|
||||
});
|
||||
|
||||
it('should clear filters when updated with empty array', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const created = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'test-workflow',
|
||||
description: 'Test',
|
||||
enabled: true,
|
||||
filters: [{ filterId: testFilterId, filterConfig: { key: 'value' } }],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const updated = await sut.update(auth, created.id, {
|
||||
filters: [],
|
||||
});
|
||||
|
||||
expect(updated.filters).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('should throw error when no fields to update', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const created = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'test-workflow',
|
||||
description: 'Test',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await expect(sut.update(auth, created.id, {})).rejects.toThrow('No fields to update');
|
||||
});
|
||||
|
||||
it('should throw error when updating non-existent workflow', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
await expect(sut.update(auth, factory.uuid(), { name: 'updated-name' })).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error when user does not have access to update workflow', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user: user1 } = await ctx.newUser();
|
||||
const { user: user2 } = await ctx.newUser();
|
||||
const auth1 = factory.auth({ user: user1 });
|
||||
const auth2 = factory.auth({ user: user2 });
|
||||
|
||||
const workflow = await sut.create(auth1, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'private-workflow',
|
||||
description: 'Private',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.update(auth2, workflow.id, {
|
||||
name: 'hacked-workflow',
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error when updating with invalid filter', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const created = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'test-workflow',
|
||||
description: 'Test',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.update(auth, created.id, {
|
||||
filters: [{ filterId: factory.uuid(), filterConfig: {} }],
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error when updating with invalid action', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const created = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'test-workflow',
|
||||
description: 'Test',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await expect(
|
||||
sut.update(auth, created.id, { actions: [{ actionId: factory.uuid(), actionConfig: {} }] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete', () => {
|
||||
it('should delete a workflow', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const workflow = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'test-workflow',
|
||||
description: 'Test',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await sut.delete(auth, workflow.id);
|
||||
|
||||
await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access');
|
||||
});
|
||||
|
||||
it('should delete workflow with filters and actions', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const workflow = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'test-workflow',
|
||||
description: 'Test',
|
||||
enabled: true,
|
||||
filters: [{ filterId: testFilterId, filterConfig: {} }],
|
||||
actions: [{ actionId: testActionId, actionConfig: {} }],
|
||||
});
|
||||
|
||||
await sut.delete(auth, workflow.id);
|
||||
|
||||
await expect(sut.get(auth, workflow.id)).rejects.toThrow('Not found or no workflow.read access');
|
||||
});
|
||||
|
||||
it('should throw error when deleting non-existent workflow', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
await expect(sut.delete(auth, factory.uuid())).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should throw error when user does not have access to delete workflow', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user: user1 } = await ctx.newUser();
|
||||
const { user: user2 } = await ctx.newUser();
|
||||
const auth1 = factory.auth({ user: user1 });
|
||||
const auth2 = factory.auth({ user: user2 });
|
||||
|
||||
const workflow = await sut.create(auth1, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
name: 'private-workflow',
|
||||
description: 'Private',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await expect(sut.delete(auth2, workflow.id)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -74,7 +74,7 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
|
||||
await albumUserRepo.update({ albumsId: album.id, usersId: user1.id }, { role: AlbumUserRole.Viewer });
|
||||
await albumUserRepo.update({ albumId: album.id, userId: user1.id }, { role: AlbumUserRole.Viewer });
|
||||
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
expect(newResponse).toEqual([
|
||||
{
|
||||
@@ -104,7 +104,7 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
|
||||
await albumUserRepo.delete({ albumsId: album.id, usersId: user1.id });
|
||||
await albumUserRepo.delete({ albumId: album.id, userId: user1.id });
|
||||
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
expect(newResponse).toEqual([
|
||||
{
|
||||
@@ -171,7 +171,7 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
|
||||
await albumUserRepo.update({ albumsId: album.id, usersId: user.id }, { role: AlbumUserRole.Viewer });
|
||||
await albumUserRepo.update({ albumId: album.id, userId: user.id }, { role: AlbumUserRole.Viewer });
|
||||
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
expect(newResponse).toEqual([
|
||||
{
|
||||
@@ -208,7 +208,7 @@ describe(SyncRequestType.AlbumUsersV1, () => {
|
||||
await ctx.syncAckAll(auth, response);
|
||||
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
await albumUserRepo.delete({ albumsId: album.id, usersId: user.id });
|
||||
await albumUserRepo.delete({ albumId: album.id, userId: user.id });
|
||||
|
||||
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumUsersV1]);
|
||||
expect(newResponse).toEqual([
|
||||
|
||||
@@ -217,7 +217,7 @@ describe(SyncRequestType.AlbumsV1, () => {
|
||||
await ctx.syncAckAll(auth, response);
|
||||
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumsV1]);
|
||||
|
||||
await albumUserRepo.delete({ albumsId: album.id, usersId: auth.user.id });
|
||||
await albumUserRepo.delete({ albumId: album.id, userId: auth.user.id });
|
||||
const newResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumsV1]);
|
||||
expect(newResponse).toEqual([
|
||||
{
|
||||
|
||||
@@ -176,7 +176,7 @@ describe(SyncRequestType.PartnerAssetExifsV1, () => {
|
||||
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
|
||||
expect(newResponse).toEqual([
|
||||
{
|
||||
ack: expect.stringMatching(new RegExp(`${SyncEntityType.PartnerAssetExifBackfillV1}\\|.+?\\|.+`)),
|
||||
ack: expect.stringMatching(new RegExp(String.raw`${SyncEntityType.PartnerAssetExifBackfillV1}\|.+?\|.+`)),
|
||||
data: expect.objectContaining({
|
||||
assetId: assetUser3.id,
|
||||
}),
|
||||
@@ -226,7 +226,7 @@ describe(SyncRequestType.PartnerAssetExifsV1, () => {
|
||||
const newResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetExifsV1]);
|
||||
expect(newResponse).toEqual([
|
||||
{
|
||||
ack: expect.stringMatching(new RegExp(`${SyncEntityType.PartnerAssetExifBackfillV1}\\|.+?\\|.+`)),
|
||||
ack: expect.stringMatching(new RegExp(String.raw`${SyncEntityType.PartnerAssetExifBackfillV1}\|.+?\|.+`)),
|
||||
data: expect.objectContaining({
|
||||
assetId: assetUser3.id,
|
||||
}),
|
||||
|
||||
@@ -65,5 +65,9 @@ export const newAccessRepositoryMock = (): IAccessRepositoryMock => {
|
||||
tag: {
|
||||
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||
},
|
||||
|
||||
workflow: {
|
||||
checkOwnerAccess: vitest.fn().mockResolvedValue(new Set()),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
@@ -72,6 +72,7 @@ const envData: EnvData = {
|
||||
root: '/build/www',
|
||||
indexHtml: '/build/www/index.html',
|
||||
},
|
||||
corePlugin: '/build/corePlugin',
|
||||
},
|
||||
|
||||
storage: {
|
||||
@@ -86,6 +87,11 @@ const envData: EnvData = {
|
||||
|
||||
workers: [ImmichWorker.Api, ImmichWorker.Microservices],
|
||||
|
||||
plugins: {
|
||||
enabled: true,
|
||||
installFolder: '/app/data/plugins',
|
||||
},
|
||||
|
||||
noColor: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -13,5 +13,7 @@ export const newCryptoRepositoryMock = (): Mocked<RepositoryInterface<CryptoRepo
|
||||
hashSha1: vitest.fn().mockImplementation((input) => Buffer.from(`${input.toString()} (hashed)`)),
|
||||
hashFile: vitest.fn().mockImplementation((input) => `${input} (file-hashed)`),
|
||||
randomBytesAsText: vitest.fn().mockReturnValue(Buffer.from('random-bytes').toString('base64')),
|
||||
signJwt: vitest.fn().mockReturnValue('mock-jwt-token'),
|
||||
verifyJwt: vitest.fn().mockImplementation((token) => ({ verified: true, token })),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -19,6 +19,7 @@ export const newDatabaseRepositoryMock = (): Mocked<RepositoryInterface<Database
|
||||
deleteAllSearchEmbeddings: vitest.fn(),
|
||||
prewarm: vitest.fn(),
|
||||
runMigrations: vitest.fn(),
|
||||
revertLastMigration: vitest.fn(),
|
||||
withLock: vitest.fn().mockImplementation((_, function_: <R>() => Promise<R>) => function_()),
|
||||
tryLock: vitest.fn(),
|
||||
isBusy: vitest.fn(),
|
||||
|
||||
@@ -11,9 +11,11 @@ export const newJobRepositoryMock = (): Mocked<RepositoryInterface<JobRepository
|
||||
empty: vitest.fn(),
|
||||
pause: vitest.fn(),
|
||||
resume: vitest.fn(),
|
||||
searchJobs: vitest.fn(),
|
||||
queue: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||
queueAll: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||
getQueueStatus: vitest.fn(),
|
||||
isActive: vitest.fn(),
|
||||
isPaused: vitest.fn(),
|
||||
getJobCounts: vitest.fn(),
|
||||
clear: vitest.fn(),
|
||||
waitForQueueCompletion: vitest.fn(),
|
||||
|
||||
@@ -6,6 +6,7 @@ export const newMediaRepositoryMock = (): Mocked<RepositoryInterface<MediaReposi
|
||||
return {
|
||||
generateThumbnail: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||
writeExif: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||
copyTagGroup: vitest.fn().mockImplementation(() => Promise.resolve()),
|
||||
generateThumbhash: vitest.fn().mockResolvedValue(Buffer.from('')),
|
||||
decodeImage: vitest.fn().mockResolvedValue({ data: Buffer.from(''), info: {} }),
|
||||
extract: vitest.fn().mockResolvedValue(null),
|
||||
|
||||
@@ -38,6 +38,7 @@ export const makeMockWatcher =
|
||||
return () => close();
|
||||
}
|
||||
|
||||
// eslint-disable-next-line unicorn/consistent-function-scoping
|
||||
return () => Promise.resolve();
|
||||
};
|
||||
|
||||
@@ -49,6 +50,7 @@ export const newStorageRepositoryMock = (): Mocked<RepositoryInterface<StorageRe
|
||||
createZipStream: vitest.fn(),
|
||||
createReadStream: vitest.fn(),
|
||||
readFile: vitest.fn(),
|
||||
readTextFile: vitest.fn(),
|
||||
createFile: vitest.fn(),
|
||||
createWriteStream: vitest.fn(),
|
||||
createOrOverwriteFile: vitest.fn(),
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
} from 'src/database';
|
||||
import { MapAsset } from 'src/dtos/asset-response.dto';
|
||||
import { AuthDto } from 'src/dtos/auth.dto';
|
||||
import { QueueStatisticsDto } from 'src/dtos/queue.dto';
|
||||
import {
|
||||
AssetFileType,
|
||||
AssetStatus,
|
||||
@@ -148,6 +149,16 @@ const sessionFactory = (session: Partial<Session> = {}) => ({
|
||||
...session,
|
||||
});
|
||||
|
||||
const queueStatisticsFactory = (dto?: Partial<QueueStatisticsDto>) => ({
|
||||
active: 0,
|
||||
completed: 0,
|
||||
failed: 0,
|
||||
delayed: 0,
|
||||
waiting: 0,
|
||||
paused: 0,
|
||||
...dto,
|
||||
});
|
||||
|
||||
const stackFactory = () => ({
|
||||
id: newUuid(),
|
||||
ownerId: newUuid(),
|
||||
@@ -369,6 +380,7 @@ export const factory = {
|
||||
library: libraryFactory,
|
||||
memory: memoryFactory,
|
||||
partner: partnerFactory,
|
||||
queueStatistics: queueStatisticsFactory,
|
||||
session: sessionFactory,
|
||||
stack: stackFactory,
|
||||
user: userFactory,
|
||||
|
||||
+22
-4
@@ -19,6 +19,7 @@ import { ActivityRepository } from 'src/repositories/activity.repository';
|
||||
import { AlbumUserRepository } from 'src/repositories/album-user.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { ApiKeyRepository } from 'src/repositories/api-key.repository';
|
||||
import { AppRepository } from 'src/repositories/app.repository';
|
||||
import { AssetJobRepository } from 'src/repositories/asset-job.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { AuditRepository } from 'src/repositories/audit.repository';
|
||||
@@ -44,6 +45,7 @@ import { OAuthRepository } from 'src/repositories/oauth.repository';
|
||||
import { OcrRepository } from 'src/repositories/ocr.repository';
|
||||
import { PartnerRepository } from 'src/repositories/partner.repository';
|
||||
import { PersonRepository } from 'src/repositories/person.repository';
|
||||
import { PluginRepository } from 'src/repositories/plugin.repository';
|
||||
import { ProcessRepository } from 'src/repositories/process.repository';
|
||||
import { SearchRepository } from 'src/repositories/search.repository';
|
||||
import { ServerInfoRepository } from 'src/repositories/server-info.repository';
|
||||
@@ -62,6 +64,7 @@ import { UserRepository } from 'src/repositories/user.repository';
|
||||
import { VersionHistoryRepository } from 'src/repositories/version-history.repository';
|
||||
import { ViewRepository } from 'src/repositories/view-repository';
|
||||
import { WebsocketRepository } from 'src/repositories/websocket.repository';
|
||||
import { WorkflowRepository } from 'src/repositories/workflow.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { AuthService } from 'src/services/auth.service';
|
||||
import { BaseService } from 'src/services/base.service';
|
||||
@@ -210,6 +213,7 @@ export type ServiceOverrides = {
|
||||
album: AlbumRepository;
|
||||
albumUser: AlbumUserRepository;
|
||||
apiKey: ApiKeyRepository;
|
||||
app: AppRepository;
|
||||
audit: AuditRepository;
|
||||
asset: AssetRepository;
|
||||
assetJob: AssetJobRepository;
|
||||
@@ -235,6 +239,7 @@ export type ServiceOverrides = {
|
||||
oauth: OAuthRepository;
|
||||
partner: PartnerRepository;
|
||||
person: PersonRepository;
|
||||
plugin: PluginRepository;
|
||||
process: ProcessRepository;
|
||||
search: SearchRepository;
|
||||
serverInfo: ServerInfoRepository;
|
||||
@@ -253,6 +258,7 @@ export type ServiceOverrides = {
|
||||
versionHistory: VersionHistoryRepository;
|
||||
view: ViewRepository;
|
||||
websocket: WebsocketRepository;
|
||||
workflow: WorkflowRepository;
|
||||
};
|
||||
|
||||
type As<T> = T extends RepositoryInterface<infer U> ? U : never;
|
||||
@@ -267,10 +273,7 @@ type Constructor<Type, Args extends Array<any>> = {
|
||||
new (...deps: Args): Type;
|
||||
};
|
||||
|
||||
export const newTestService = <T extends BaseService>(
|
||||
Service: Constructor<T, BaseServiceArgs>,
|
||||
overrides: Partial<ServiceOverrides> = {},
|
||||
) => {
|
||||
export const getMocks = () => {
|
||||
const loggerMock = { setContext: () => {} };
|
||||
const configMock = { getEnv: () => ({}) };
|
||||
|
||||
@@ -287,6 +290,7 @@ export const newTestService = <T extends BaseService>(
|
||||
albumUser: automock(AlbumUserRepository),
|
||||
asset: newAssetRepositoryMock(),
|
||||
assetJob: automock(AssetJobRepository),
|
||||
app: automock(AppRepository, { strict: false }),
|
||||
config: newConfigRepositoryMock(),
|
||||
database: newDatabaseRepositoryMock(),
|
||||
downloadRepository: automock(DownloadRepository, { strict: false }),
|
||||
@@ -308,6 +312,7 @@ export const newTestService = <T extends BaseService>(
|
||||
oauth: automock(OAuthRepository, { args: [loggerMock] }),
|
||||
partner: automock(PartnerRepository, { strict: false }),
|
||||
person: automock(PersonRepository, { strict: false }),
|
||||
plugin: automock(PluginRepository, { strict: true }),
|
||||
process: automock(ProcessRepository),
|
||||
search: automock(SearchRepository, { strict: false }),
|
||||
// eslint-disable-next-line no-sparse-arrays
|
||||
@@ -330,8 +335,18 @@ export const newTestService = <T extends BaseService>(
|
||||
view: automock(ViewRepository),
|
||||
// eslint-disable-next-line no-sparse-arrays
|
||||
websocket: automock(WebsocketRepository, { args: [, loggerMock], strict: false }),
|
||||
workflow: automock(WorkflowRepository, { strict: true }),
|
||||
};
|
||||
|
||||
return mocks;
|
||||
};
|
||||
|
||||
export const newTestService = <T extends BaseService>(
|
||||
Service: Constructor<T, BaseServiceArgs>,
|
||||
overrides: Partial<ServiceOverrides> = {},
|
||||
) => {
|
||||
const mocks = getMocks();
|
||||
|
||||
const sut = new Service(
|
||||
overrides.logger || (mocks.logger as As<LoggingRepository>),
|
||||
overrides.access || (mocks.access as IAccessRepository as AccessRepository),
|
||||
@@ -339,6 +354,7 @@ export const newTestService = <T extends BaseService>(
|
||||
overrides.album || (mocks.album as As<AlbumRepository>),
|
||||
overrides.albumUser || (mocks.albumUser as As<AlbumUserRepository>),
|
||||
overrides.apiKey || (mocks.apiKey as As<ApiKeyRepository>),
|
||||
overrides.app || (mocks.app as As<AppRepository>),
|
||||
overrides.asset || (mocks.asset as As<AssetRepository>),
|
||||
overrides.assetJob || (mocks.assetJob as As<AssetJobRepository>),
|
||||
overrides.audit || (mocks.audit as As<AuditRepository>),
|
||||
@@ -363,6 +379,7 @@ export const newTestService = <T extends BaseService>(
|
||||
overrides.ocr || (mocks.ocr as As<OcrRepository>),
|
||||
overrides.partner || (mocks.partner as As<PartnerRepository>),
|
||||
overrides.person || (mocks.person as As<PersonRepository>),
|
||||
overrides.plugin || (mocks.plugin as As<PluginRepository>),
|
||||
overrides.process || (mocks.process as As<ProcessRepository>),
|
||||
overrides.search || (mocks.search as As<SearchRepository>),
|
||||
overrides.serverInfo || (mocks.serverInfo as As<ServerInfoRepository>),
|
||||
@@ -381,6 +398,7 @@ export const newTestService = <T extends BaseService>(
|
||||
overrides.versionHistory || (mocks.versionHistory as As<VersionHistoryRepository>),
|
||||
overrides.view || (mocks.view as As<ViewRepository>),
|
||||
overrides.websocket || (mocks.websocket as As<WebsocketRepository>),
|
||||
overrides.workflow || (mocks.workflow as As<WorkflowRepository>),
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user