feat: workflows & plugins (#26727)

feat: plugins

chore: better types

feat: plugins
This commit is contained in:
Jason Rasmussen
2026-05-18 11:09:33 -04:00
committed by GitHub
parent 7384799f19
commit 3d075f2bf8
144 changed files with 6099 additions and 7419 deletions
@@ -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);
});
});
});