mirror of
https://github.com/immich-app/immich.git
synced 2026-05-22 23:52:32 -04:00
feat: workflows & plugins (#26727)
feat: plugins chore: better types feat: plugins
This commit is contained in:
@@ -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