mirror of
https://github.com/immich-app/immich.git
synced 2026-05-18 03:10:24 +03:00
feat: plugins
chore: better types feat: plugins
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { PluginContext } from 'src/enum';
|
||||
import { WorkflowType } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { PluginRepository } from 'src/repositories/plugin.repository';
|
||||
@@ -9,7 +9,8 @@ import { newMediumService } from 'test/medium.factory';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let defaultDatabase: Kysely<DB>;
|
||||
let pluginRepo: PluginRepository;
|
||||
|
||||
const wasmBytes = Buffer.from('some-wasm-binary-data');
|
||||
|
||||
const setup = (db?: Kysely<DB>) => {
|
||||
return newMediumService(PluginService, {
|
||||
@@ -21,7 +22,6 @@ const setup = (db?: Kysely<DB>) => {
|
||||
|
||||
beforeAll(async () => {
|
||||
defaultDatabase = await getKyselyDB();
|
||||
pluginRepo = new PluginRepository(defaultDatabase);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -32,214 +32,195 @@ 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([]);
|
||||
await expect(sut.search({})).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it('should return plugin without filters and actions', async () => {
|
||||
const { sut } = setup();
|
||||
it('should return plugin without methods', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
|
||||
const result = await pluginRepo.loadPlugin(
|
||||
const result = await ctx.get(PluginRepository).upsert(
|
||||
{
|
||||
enabled: true,
|
||||
name: 'test-plugin',
|
||||
title: 'Test Plugin',
|
||||
description: 'A test plugin',
|
||||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
wasm: { path: '/path/to/test.wasm' },
|
||||
wasmBytes,
|
||||
},
|
||||
'/test/base/path',
|
||||
[],
|
||||
);
|
||||
|
||||
const plugins = await sut.getAll();
|
||||
const plugins = await sut.search({});
|
||||
|
||||
expect(plugins).toHaveLength(1);
|
||||
expect(plugins[0]).toMatchObject({
|
||||
id: result.plugin.id,
|
||||
id: result.id,
|
||||
name: 'test-plugin',
|
||||
description: 'A test plugin',
|
||||
author: 'Test Author',
|
||||
version: '1.0.0',
|
||||
filters: [],
|
||||
actions: [],
|
||||
methods: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return plugin with filters and actions', async () => {
|
||||
const { sut } = setup();
|
||||
it('should return plugin with multiple methods', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
|
||||
const result = await pluginRepo.loadPlugin(
|
||||
await ctx.get(PluginRepository).upsert(
|
||||
{
|
||||
enabled: true,
|
||||
name: 'full-plugin',
|
||||
title: 'Full Plugin',
|
||||
description: 'A plugin with filters and actions',
|
||||
description: 'A plugin with multiple methods',
|
||||
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: {} },
|
||||
},
|
||||
],
|
||||
wasmBytes,
|
||||
},
|
||||
'/test/base/path',
|
||||
[
|
||||
{
|
||||
name: 'test-filter',
|
||||
title: 'Test Filter',
|
||||
description: 'A test filter',
|
||||
types: [WorkflowType.AssetV1],
|
||||
schema: { type: 'object', properties: {} },
|
||||
},
|
||||
{
|
||||
name: 'test-action',
|
||||
title: 'Test Action',
|
||||
description: 'A test action',
|
||||
types: [WorkflowType.AssetV1],
|
||||
schema: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const plugins = await sut.getAll();
|
||||
const plugins = await sut.search({});
|
||||
|
||||
expect(plugins).toHaveLength(1);
|
||||
expect(plugins[0]).toMatchObject({
|
||||
id: result.plugin.id,
|
||||
name: 'full-plugin',
|
||||
filters: [
|
||||
methods: [
|
||||
{
|
||||
id: result.filters[0].id,
|
||||
pluginId: result.plugin.id,
|
||||
methodName: 'test-filter',
|
||||
name: 'test-filter',
|
||||
title: 'Test Filter',
|
||||
description: 'A test filter',
|
||||
supportedContexts: [PluginContext.Asset],
|
||||
types: [WorkflowType.AssetV1],
|
||||
schema: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
id: result.actions[0].id,
|
||||
pluginId: result.plugin.id,
|
||||
methodName: 'test-action',
|
||||
name: 'test-action',
|
||||
title: 'Test Action',
|
||||
description: 'A test action',
|
||||
supportedContexts: [PluginContext.Asset],
|
||||
types: [WorkflowType.AssetV1],
|
||||
schema: { type: 'object', properties: {} },
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should return multiple plugins with their respective filters and actions', async () => {
|
||||
const { sut } = setup();
|
||||
it('should return multiple plugins with their respective methods', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
|
||||
await pluginRepo.loadPlugin(
|
||||
await ctx.get(PluginRepository).upsert(
|
||||
{
|
||||
enabled: true,
|
||||
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,
|
||||
},
|
||||
],
|
||||
wasmBytes,
|
||||
},
|
||||
'/test/base/path',
|
||||
[
|
||||
{
|
||||
name: 'filter-1',
|
||||
title: 'Filter 1',
|
||||
description: 'Filter for plugin 1',
|
||||
types: [WorkflowType.AssetV1],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
await pluginRepo.loadPlugin(
|
||||
await ctx.get(PluginRepository).upsert(
|
||||
{
|
||||
enabled: true,
|
||||
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,
|
||||
},
|
||||
],
|
||||
wasmBytes,
|
||||
},
|
||||
'/test/base/path',
|
||||
[
|
||||
{
|
||||
name: 'action-2',
|
||||
title: 'Action 2',
|
||||
description: 'Action for plugin 2',
|
||||
types: [WorkflowType.AssetV1],
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const plugins = await sut.getAll();
|
||||
const plugins = await sut.search({});
|
||||
|
||||
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[0].methods).toHaveLength(1);
|
||||
|
||||
expect(plugins[1].name).toBe('plugin-2');
|
||||
expect(plugins[1].filters).toHaveLength(0);
|
||||
expect(plugins[1].actions).toHaveLength(1);
|
||||
expect(plugins[1].methods).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('should handle plugin with multiple filters and actions', async () => {
|
||||
const { sut } = setup();
|
||||
it('should handle plugin with multiple methods', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
|
||||
await pluginRepo.loadPlugin(
|
||||
await ctx.get(PluginRepository).upsert(
|
||||
{
|
||||
enabled: true,
|
||||
name: 'multi-plugin',
|
||||
title: 'Multi Plugin',
|
||||
description: 'Plugin with multiple items',
|
||||
description: 'Plugin with multiple methods',
|
||||
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,
|
||||
},
|
||||
],
|
||||
wasmBytes,
|
||||
},
|
||||
'/test/base/path',
|
||||
[
|
||||
{
|
||||
name: 'filter-a',
|
||||
title: 'Filter A',
|
||||
description: 'First filter',
|
||||
types: [WorkflowType.AssetV1],
|
||||
schema: undefined,
|
||||
},
|
||||
{
|
||||
name: 'filter-b',
|
||||
title: 'Filter B',
|
||||
description: 'Second filter',
|
||||
types: [WorkflowType.AssetV1],
|
||||
schema: undefined,
|
||||
},
|
||||
{
|
||||
name: 'action-x',
|
||||
title: 'Action X',
|
||||
description: 'First action',
|
||||
types: [WorkflowType.AssetV1],
|
||||
schema: undefined,
|
||||
},
|
||||
{
|
||||
name: 'action-y',
|
||||
title: 'Action Y',
|
||||
description: 'Second action',
|
||||
types: [WorkflowType.AssetV1],
|
||||
schema: undefined,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
const plugins = await sut.getAll();
|
||||
const plugins = await sut.search({});
|
||||
|
||||
expect(plugins).toHaveLength(1);
|
||||
expect(plugins[0].filters).toHaveLength(2);
|
||||
expect(plugins[0].actions).toHaveLength(2);
|
||||
expect(plugins[0].methods).toHaveLength(4);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,55 +231,49 @@ describe(PluginService.name, () => {
|
||||
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();
|
||||
it('should return single plugin with methods', async () => {
|
||||
const { ctx, sut } = setup();
|
||||
|
||||
const result = await pluginRepo.loadPlugin(
|
||||
const result = await ctx.get(PluginRepository).upsert(
|
||||
{
|
||||
enabled: true,
|
||||
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,
|
||||
},
|
||||
],
|
||||
wasmBytes,
|
||||
},
|
||||
'/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',
|
||||
name: 'single-filter',
|
||||
title: 'Single Filter',
|
||||
description: 'A single filter',
|
||||
types: [WorkflowType.AssetV1],
|
||||
schema: undefined,
|
||||
},
|
||||
{
|
||||
name: 'single-action',
|
||||
title: 'Single Action',
|
||||
description: 'A single action',
|
||||
types: [WorkflowType.AssetV1],
|
||||
schema: undefined,
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
);
|
||||
|
||||
const pluginResult = await sut.get(result.id);
|
||||
|
||||
expect(pluginResult).toMatchObject({
|
||||
id: result.id,
|
||||
name: 'single-plugin',
|
||||
methods: [
|
||||
{
|
||||
id: result.actions[0].id,
|
||||
methodName: 'single-action',
|
||||
name: 'single-filter',
|
||||
title: 'Single Filter',
|
||||
},
|
||||
{
|
||||
name: 'single-action',
|
||||
title: 'Single Action',
|
||||
},
|
||||
],
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Kysely } from 'kysely';
|
||||
import { PluginContext, PluginTriggerType } from 'src/enum';
|
||||
import { WorkflowTrigger, WorkflowType } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { PluginRepository } from 'src/repositories/plugin.repository';
|
||||
@@ -20,53 +20,48 @@ const setup = (db?: Kysely<DB>) => {
|
||||
});
|
||||
};
|
||||
|
||||
const wasmBytes = Buffer.from('random-wasm-bytes');
|
||||
|
||||
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(
|
||||
const { ctx } = setup();
|
||||
// Create a test plugin with methods and actions once for all tests
|
||||
const pluginRepo = ctx.get(PluginRepository);
|
||||
const result = await pluginRepo.upsert(
|
||||
{
|
||||
enabled: true,
|
||||
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,
|
||||
},
|
||||
],
|
||||
wasmBytes,
|
||||
},
|
||||
'/plugins/test-core-plugin',
|
||||
[
|
||||
{
|
||||
name: 'test-filter',
|
||||
title: 'Test Filter',
|
||||
description: 'A test filter',
|
||||
types: [WorkflowType.AssetV1],
|
||||
schema: undefined,
|
||||
},
|
||||
{
|
||||
name: 'test-action',
|
||||
title: 'Test Action',
|
||||
description: 'A test action',
|
||||
types: [WorkflowType.AssetV1],
|
||||
schema: undefined,
|
||||
},
|
||||
],
|
||||
);
|
||||
|
||||
testPluginId = result.plugin.id;
|
||||
testFilterId = result.filters[0].id;
|
||||
testActionId = result.actions[0].id;
|
||||
testPluginId = result.id;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
@@ -74,230 +69,27 @@ describe(WorkflowService.name, () => {
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('should create a workflow without filters or actions', async () => {
|
||||
it('should create 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,
|
||||
trigger: WorkflowTrigger.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,
|
||||
trigger: WorkflowTrigger.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: [
|
||||
{
|
||||
pluginFilterId: testFilterId,
|
||||
filterConfig: { key: 'value' },
|
||||
},
|
||||
],
|
||||
actions: [
|
||||
{
|
||||
pluginActionId: 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,
|
||||
pluginFilterId: testFilterId,
|
||||
filterConfig: { key: 'value' },
|
||||
order: 0,
|
||||
});
|
||||
|
||||
expect(workflow.actions).toHaveLength(1);
|
||||
expect(workflow.actions[0]).toMatchObject({
|
||||
id: expect.any(String),
|
||||
workflowId: workflow.id,
|
||||
pluginActionId: 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: [{ pluginFilterId: 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: [{ pluginActionId: 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: [{ pluginFilterId: 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: [{ pluginActionId: 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: [
|
||||
{ pluginFilterId: testFilterId, filterConfig: { step: 1 } },
|
||||
{ pluginFilterId: testFilterId, filterConfig: { step: 2 } },
|
||||
],
|
||||
actions: [
|
||||
{ pluginActionId: testActionId, actionConfig: { step: 1 } },
|
||||
{ pluginActionId: testActionId, actionConfig: { step: 2 } },
|
||||
{ pluginActionId: 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', () => {
|
||||
@@ -307,24 +99,20 @@ describe(WorkflowService.name, () => {
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const workflow1 = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
name: 'workflow-1',
|
||||
description: 'First workflow',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const workflow2 = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
name: 'workflow-2',
|
||||
description: 'Second workflow',
|
||||
enabled: false,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const workflows = await sut.getAll(auth);
|
||||
const workflows = await sut.search(auth, {});
|
||||
|
||||
expect(workflows).toHaveLength(2);
|
||||
expect(workflows).toEqual(
|
||||
@@ -340,7 +128,7 @@ describe(WorkflowService.name, () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const workflows = await sut.getAll(auth);
|
||||
const workflows = await sut.search(auth, {});
|
||||
|
||||
expect(workflows).toEqual([]);
|
||||
});
|
||||
@@ -353,424 +141,15 @@ describe(WorkflowService.name, () => {
|
||||
const auth2 = factory.auth({ user: user2 });
|
||||
|
||||
await sut.create(auth1, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
name: 'user1-workflow',
|
||||
description: 'User 1 workflow',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const user2Workflows = await sut.getAll(auth2);
|
||||
const user2Workflows = await sut.search(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: [{ pluginFilterId: testFilterId, filterConfig: { key: 'value' } }],
|
||||
actions: [{ pluginActionId: 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: [{ pluginFilterId: testFilterId, filterConfig: { old: 'config' } }],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
const updated = await sut.update(auth, created.id, {
|
||||
filters: [
|
||||
{ pluginFilterId: testFilterId, filterConfig: { new: 'config' } },
|
||||
{ pluginFilterId: 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: [{ pluginActionId: testActionId, actionConfig: { old: 'config' } }],
|
||||
});
|
||||
|
||||
const updated = await sut.update(auth, created.id, {
|
||||
actions: [
|
||||
{ pluginActionId: testActionId, actionConfig: { new: 'config' } },
|
||||
{ pluginActionId: 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: [{ pluginFilterId: 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: [{ pluginFilterId: 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: [{ pluginActionId: factory.uuid(), actionConfig: {} }] }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('should update trigger type', async () => {
|
||||
const { sut, ctx } = setup();
|
||||
const { user } = await ctx.newUser();
|
||||
const auth = factory.auth({ user });
|
||||
|
||||
const created = await sut.create(auth, {
|
||||
triggerType: PluginTriggerType.PersonRecognized,
|
||||
name: 'test-workflow',
|
||||
description: 'Test',
|
||||
enabled: true,
|
||||
filters: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await sut.update(auth, created.id, {
|
||||
triggerType: PluginTriggerType.AssetCreate,
|
||||
});
|
||||
|
||||
const fetched = await sut.get(auth, created.id);
|
||||
expect(fetched.triggerType).toBe(PluginTriggerType.AssetCreate);
|
||||
});
|
||||
|
||||
it('should add 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: [],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await sut.update(auth, created.id, {
|
||||
filters: [
|
||||
{ pluginFilterId: testFilterId, filterConfig: { first: true } },
|
||||
{ pluginFilterId: testFilterId, filterConfig: { second: true } },
|
||||
],
|
||||
});
|
||||
|
||||
const fetched = await sut.get(auth, created.id);
|
||||
expect(fetched.filters).toHaveLength(2);
|
||||
expect(fetched.filters[0].filterConfig).toEqual({ first: true });
|
||||
expect(fetched.filters[1].filterConfig).toEqual({ second: true });
|
||||
});
|
||||
|
||||
it('should replace existing 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: [{ pluginFilterId: testFilterId, filterConfig: { original: true } }],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await sut.update(auth, created.id, {
|
||||
filters: [{ pluginFilterId: testFilterId, filterConfig: { replaced: true } }],
|
||||
});
|
||||
|
||||
const fetched = await sut.get(auth, created.id);
|
||||
expect(fetched.filters).toHaveLength(1);
|
||||
expect(fetched.filters[0].filterConfig).toEqual({ replaced: true });
|
||||
});
|
||||
|
||||
it('should remove existing 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: [{ pluginFilterId: testFilterId, filterConfig: { toRemove: true } }],
|
||||
actions: [],
|
||||
});
|
||||
|
||||
await sut.update(auth, created.id, {
|
||||
filters: [],
|
||||
});
|
||||
|
||||
const fetched = await sut.get(auth, created.id);
|
||||
expect(fetched.filters).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
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: [{ pluginFilterId: testFilterId, filterConfig: {} }],
|
||||
actions: [{ pluginActionId: 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { WorkflowStepConfig } from '@immich/plugin-sdk';
|
||||
import { Kysely } from 'kysely';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { PluginManifestDto } from 'src/dtos/plugin-manifest.dto';
|
||||
import { AssetVisibility, LogLevel, WorkflowTrigger } from 'src/enum';
|
||||
import { AccessRepository } from 'src/repositories/access.repository';
|
||||
import { AlbumRepository } from 'src/repositories/album.repository';
|
||||
import { AssetRepository } from 'src/repositories/asset.repository';
|
||||
import { ConfigRepository } from 'src/repositories/config.repository';
|
||||
import { CryptoRepository } from 'src/repositories/crypto.repository';
|
||||
import { DatabaseRepository } from 'src/repositories/database.repository';
|
||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||
import { PluginRepository } from 'src/repositories/plugin.repository';
|
||||
import { StorageRepository } from 'src/repositories/storage.repository';
|
||||
import { WorkflowRepository } from 'src/repositories/workflow.repository';
|
||||
import { DB } from 'src/schema';
|
||||
import { WorkflowExecutionService } from 'src/services/workflow-execution.service';
|
||||
import { resolveMethod } from 'src/utils/workflow';
|
||||
import { MediumTestContext } from 'test/medium.factory';
|
||||
import { mockEnvData } from 'test/repositories/config.repository.mock';
|
||||
import { getKyselyDB } from 'test/utils';
|
||||
|
||||
let initialized = false;
|
||||
|
||||
class WorkflowTestContext extends MediumTestContext<WorkflowExecutionService> {
|
||||
constructor(database: Kysely<DB>) {
|
||||
super(WorkflowExecutionService, {
|
||||
database,
|
||||
real: [
|
||||
AccessRepository,
|
||||
AlbumRepository,
|
||||
AssetRepository,
|
||||
CryptoRepository,
|
||||
DatabaseRepository,
|
||||
LoggingRepository,
|
||||
StorageRepository,
|
||||
PluginRepository,
|
||||
WorkflowRepository,
|
||||
],
|
||||
mock: [ConfigRepository],
|
||||
});
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mockData = mockEnvData({});
|
||||
mockData.resourcePaths.corePlugin = '../packages/plugin-core';
|
||||
mockData.plugins.external.allow = false;
|
||||
this.getMock(ConfigRepository).getEnv.mockReturnValue(mockData);
|
||||
this.get(LoggingRepository).setLogLevel(LogLevel.Verbose);
|
||||
|
||||
await this.sut.onPluginSync();
|
||||
await this.sut.onPluginLoad();
|
||||
|
||||
initialized = true;
|
||||
}
|
||||
}
|
||||
|
||||
type WorkflowTemplate = {
|
||||
ownerId: string;
|
||||
trigger: WorkflowTrigger;
|
||||
steps: WorkflowTemplateStep[];
|
||||
};
|
||||
|
||||
type WorkflowTemplateStep = {
|
||||
method: string;
|
||||
config?: WorkflowStepConfig;
|
||||
};
|
||||
|
||||
const createWorkflow = async (template: WorkflowTemplate) => {
|
||||
const workflowRepo = ctx.get(WorkflowRepository);
|
||||
const pluginRepo = ctx.get(PluginRepository);
|
||||
|
||||
const methods = await pluginRepo.getForValidation();
|
||||
const steps = template.steps.map((step) => {
|
||||
const pluginMethod = resolveMethod(methods, step.method);
|
||||
if (!pluginMethod) {
|
||||
throw new Error(`Plugin method not found: ${step.method}`);
|
||||
}
|
||||
|
||||
return { ...step, pluginMethod };
|
||||
});
|
||||
|
||||
return workflowRepo.create(
|
||||
{
|
||||
enabled: true,
|
||||
name: 'Test workflow',
|
||||
description: 'A workflow to test the core plugin',
|
||||
ownerId: template.ownerId,
|
||||
trigger: template.trigger,
|
||||
},
|
||||
steps.map((step) => ({
|
||||
enabled: true,
|
||||
pluginMethodId: step.pluginMethod.id,
|
||||
config: step.config,
|
||||
})),
|
||||
);
|
||||
};
|
||||
|
||||
let ctx: WorkflowTestContext;
|
||||
|
||||
beforeAll(async () => {
|
||||
const db = await getKyselyDB();
|
||||
ctx = new WorkflowTestContext(db);
|
||||
await ctx.init();
|
||||
}, 30_000);
|
||||
|
||||
describe('core plugin', () => {
|
||||
describe('validation', () => {
|
||||
it('should have a valid manifest.json', () => {
|
||||
const buffer = readFileSync('../packages/plugin-core/manifest.json');
|
||||
const result = PluginManifestDto.schema.safeParse(JSON.parse(buffer.toString()));
|
||||
if (!result.success) {
|
||||
const issues =
|
||||
'error' in result
|
||||
? result.error.issues.map((issue) => ` - [${issue.path.join('.')}] ${issue.message}`).join('\n')
|
||||
: '';
|
||||
const message = `Invalid packages/plugin-core/manifest.json:\n${issues}`;
|
||||
expect(result.success, message).toBe(true);
|
||||
}
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetArchive', () => {
|
||||
it('should archive an asset', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
ownerId: user.id,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
steps: [{ method: 'immich-plugin-core#assetArchive' }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
|
||||
visibility: AssetVisibility.Archive,
|
||||
});
|
||||
});
|
||||
|
||||
it('should unarchive an asset', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Archive });
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
ownerId: user.id,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
steps: [{ method: 'immich-plugin-core#assetArchive', config: { inverse: true } }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
|
||||
visibility: AssetVisibility.Timeline,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetLock', () => {
|
||||
it('should lock an asset', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
ownerId: user.id,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
steps: [{ method: 'immich-plugin-core#assetLock' }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
|
||||
visibility: AssetVisibility.Locked,
|
||||
});
|
||||
});
|
||||
|
||||
it('should unlock an asset', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id, visibility: AssetVisibility.Locked });
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
ownerId: user.id,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
steps: [{ method: 'immich-plugin-core#assetLock', config: { inverse: true } }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({
|
||||
visibility: AssetVisibility.Timeline,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetFavorite', () => {
|
||||
it('should favorite an asset', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id });
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
ownerId: user.id,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
steps: [{ method: 'immich-plugin-core#assetFavorite' }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: true });
|
||||
});
|
||||
|
||||
it('should unfavorite an asset', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
ownerId: user.id,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
steps: [{ method: 'immich-plugin-core#assetFavorite', config: { inverse: true } }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
|
||||
await expect(ctx.get(AssetRepository).getById(asset.id)).resolves.toMatchObject({ isFavorite: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('assetAddToAlbums', () => {
|
||||
it('should add an asset to an album', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user.id, isFavorite: true });
|
||||
const { album } = await ctx.newAlbum({ ownerId: user.id });
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
ownerId: user.id,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
|
||||
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.toContain(asset.id);
|
||||
});
|
||||
|
||||
it('should add an asset to multiple albums', async () => {
|
||||
const { user } = await ctx.newUser();
|
||||
const [{ asset }, { album: album1 }, { album: album2 }] = await Promise.all([
|
||||
ctx.newAsset({ ownerId: user.id, isFavorite: true }),
|
||||
ctx.newAlbum({ ownerId: user.id }),
|
||||
ctx.newAlbum({ ownerId: user.id }),
|
||||
]);
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
ownerId: user.id,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album1.id, album2.id] } }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeUndefined();
|
||||
|
||||
await expect(ctx.get(AlbumRepository).getAssetIds(album1.id, [asset.id])).resolves.toContain(asset.id);
|
||||
await expect(ctx.get(AlbumRepository).getAssetIds(album2.id, [asset.id])).resolves.toContain(asset.id);
|
||||
});
|
||||
|
||||
it('should require album access', async () => {
|
||||
const { user: user1 } = await ctx.newUser();
|
||||
const { user: user2 } = await ctx.newUser();
|
||||
const { asset } = await ctx.newAsset({ ownerId: user1.id, isFavorite: true });
|
||||
const { album } = await ctx.newAlbum({ ownerId: user2.id });
|
||||
|
||||
const workflow = await createWorkflow({
|
||||
ownerId: user1.id,
|
||||
trigger: WorkflowTrigger.AssetCreate,
|
||||
steps: [{ method: 'immich-plugin-core#assetAddToAlbums', config: { albumIds: [album.id] } }],
|
||||
});
|
||||
|
||||
await expect(ctx.sut.handleAssetCreate({ workflowId: workflow.id, assetId: asset.id })).resolves.toBeTruthy();
|
||||
|
||||
await expect(ctx.get(AlbumRepository).getAssetIds(album.id, [asset.id])).resolves.not.toContain(asset.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user