Commit 66ad87d2d284c11ac58b2144bd450198ba94c398

Authored by zichun
1 parent 4c16574d

feat(workflow): plug-in JAR BPMN auto-deployment via PluginProcessDeployer

Completes the plug-in side of the embedded Flowable story. The P2.1
core made plug-ins able to register TaskHandlers; this chunk makes
them able to ship the BPMN processes those handlers serve.

## Why Flowable's built-in auto-deployer couldn't do it

Flowable's Spring Boot starter scans the host classpath at engine
startup for `classpath[*]:/processes/[*].bpmn20.xml` and auto-deploys
every hit (the literal glob is paraphrased because the Kotlin KDoc
comment below would otherwise treat the embedded slash-star as the
start of a nested comment — feedback memory "Kotlin KDoc nested-
comment trap"). PF4J plug-ins load through an isolated child
classloader that is NOT visible to that scan, so a `processes/*.bpmn20.xml`
resource shipped inside a plug-in JAR is never seen. This chunk adds
a dedicated host-side deployer that opens each plug-in JAR file
directly (same JarFile walk pattern as
`MetadataLoader.loadFromPluginJar`) and hand-registers the BPMNs
with the Flowable `RepositoryService`.

## Mechanism

### New PluginProcessDeployer (platform-workflow)

One Spring bean, two methods:

- `deployFromPlugin(pluginId, jarPath): String?` — walks the JAR,
  collects every entry whose name starts with `processes/` and ends
  with `.bpmn20.xml` or `.bpmn`, and bundles the whole set into one
  Flowable `Deployment` named `plugin:<id>` with `category = pluginId`.
  Returns the deployment id or null (missing JAR / no BPMN resources).
  One deployment per plug-in keeps undeploy atomic and makes the
  teardown query unambiguous.
- `undeployByPlugin(pluginId): Int` — runs
  `createDeploymentQuery().deploymentCategory(pluginId).list()` and
  calls `deleteDeployment(id, cascade=true)` on each hit. Cascading
  removes process instances and history rows along with the
  deployment — "uninstalling a plug-in makes it disappear". Idempotent:
  a second call returns 0.

The deployer reads the JAR entries into byte arrays inside the
JarFile's `use` block and then passes the bytes to
`DeploymentBuilder.addBytes(name, bytes)` outside the block, so the
jar handle is already closed by the time Flowable sees the
deployment. No input-stream lifetime tangles.

### VibeErpPluginManager wiring

- New constructor dependency on `PluginProcessDeployer`.
- Deploy happens AFTER `start(context)` succeeds. The ordering matters
  because a plug-in can only register its TaskHandlers during
  `start(context)`, and a deployed BPMN whose service-task delegate
  expression resolves to a key with no matching handler would still
  deploy (Flowable only resolves delegates at process-start time).
  Registering handlers first is the safer default: the moment the
  deployment lands, every referenced handler is already in the
  TaskHandlerRegistry.
- BPMN deployment failure AFTER a successful `start(context)` now
  fully unwinds the plug-in state: call `instance.stop()`, remove
  the plug-in from the `started` list, strip its endpoints + its
  TaskHandlers + call `undeployByPlugin` (belt and suspenders — the
  deploy attempt may have partially succeeded). That mirrors the
  existing start-failure unwinding so the framework doesn't end up
  with a plug-in that's half-installed after any step throws.
- `destroy()` calls `undeployByPlugin(pluginId)` alongside the
  existing `unregisterAllByOwner(pluginId)`.

### Reference plug-in BPMN

`reference-customer/plugin-printing-shop/src/main/resources/processes/plate-approval.bpmn20.xml`
— a minimal two-task process (`start` → serviceTask → `end`) whose
serviceTask id is `printing_shop.plate.approve`, matching the
PlateApprovalTaskHandler key landed in the previous commit. Process
definition key is `plugin-printing-shop-plate-approval` (distinct
from the serviceTask id because BPMN 2.0 requires element ids to be
unique per document — same separation used for the core ping
process).

## Smoke test (fresh DB, plug-in staged)

```
$ docker compose down -v && docker compose up -d db
$ ./gradlew :distribution:bootRun &
...
registered TaskHandler 'vibeerp.workflow.ping' owner='core' ...
TaskHandlerRegistry initialised with 1 core TaskHandler bean(s): [vibeerp.workflow.ping]
...
plug-in 'printing-shop' Liquibase migrations applied successfully
[plugin:printing-shop] printing-shop plug-in started — reference acceptance test active
registered TaskHandler 'printing_shop.plate.approve' owner='printing-shop' ...
[plugin:printing-shop] registered 1 TaskHandler: printing_shop.plate.approve
PluginProcessDeployer: plug-in 'printing-shop' deployed 1 BPMN resource(s) as Flowable deploymentId='4e9f...': [processes/plate-approval.bpmn20.xml]

$ curl /api/v1/workflow/definitions (as admin)
[
  {"key":"plugin-printing-shop-plate-approval",
   "name":"Printing shop — plate approval",
   "version":1,
   "deploymentId":"4e9f85a6-33cf-11f1-acaa-1afab74ef3b4",
   "resourceName":"processes/plate-approval.bpmn20.xml"},
  {"key":"vibeerp-workflow-ping",
   "name":"vibe_erp workflow ping",
   "version":1,
   "deploymentId":"4f48...",
   "resourceName":"vibeerp-ping.bpmn20.xml"}
]

$ curl -X POST /api/v1/workflow/process-instances
         {"processDefinitionKey":"plugin-printing-shop-plate-approval",
          "variables":{"plateId":"PLATE-007"}}
  → {"processInstanceId":"5b1b...",
     "ended":true,
     "variables":{"plateId":"PLATE-007",
                  "plateApproved":true,
                  "approvedBy":"user:admin",
                  "approvedAt":"2026-04-09T04:48:30.514523Z"}}

$ kill -TERM <pid>
[ionShutdownHook] TaskHandlerRegistry.unregisterAllByOwner('printing-shop') removed 1 handler(s)
[ionShutdownHook] PluginProcessDeployer: plug-in 'printing-shop' deployment '4e9f...' removed (cascade)
```

Full end-to-end loop closed: plug-in ships a BPMN → host reads it
out of the JAR → Flowable deployment registered under the plug-in
category → HTTP caller starts a process instance via the standard
`/api/v1/workflow/process-instances` surface → dispatcher routes by
activity id to the plug-in's TaskHandler → handler writes output
variables + plug-in sees the authenticated caller as `ctx.principal()`
via the reserved `__vibeerp_*` process-variable propagation from
commit `ef9e5b42`. SIGTERM cleanly undeploys the plug-in's BPMNs.

## Tests

- 6 new unit tests on `PluginProcessDeployerTest`:
  * `deployFromPlugin returns null when jarPath is not a regular file`
    — guard against dev-exploded plug-in dirs
  * `deployFromPlugin returns null when the plug-in jar has no BPMN resources`
  * `deployFromPlugin reads every bpmn resource under processes and
    deploys one bundle` — builds a real temporary JAR with two BPMN
    entries + a README + a metadata YAML, verifies that both BPMNs
    go through `addBytes` with the right names and the README /
    metadata entries are skipped
  * `deployFromPlugin rejects a blank plug-in id`
  * `undeployByPlugin returns zero when there is nothing to remove`
  * `undeployByPlugin cascades a deleteDeployment per matching deployment`
- Total framework unit tests: 275 (was 269), all green.

## Kotlin trap caught during authoring (feedback memory paid out)

First compile failed with `Unclosed comment` on the last line of
`PluginProcessDeployer.kt`. The culprit was a KDoc paragraph
containing the literal glob
`classpath*:/processes/*.bpmn20.xml`: the embedded `/*` inside the
backtick span was parsed as the start of a nested block comment
even though the surrounding `/* ... */` KDoc was syntactically
complete. The saved feedback-memory entry "Kotlin KDoc nested-comment
trap" covered exactly this situation — the fix is to spell out glob
characters as `[star]` / `[slash]` (or the word "slash-star") inside
documentation so the literal `/*` never appears. The KDoc now
documents the behaviour AND the workaround so the next maintainer
doesn't hit the same trap.

## Non-goals (still parking lot)

- Handler-side access to the full PluginContext — PlateApprovalTaskHandler
  is still a pure function because the framework doesn't hand
  TaskHandlers a context object. For REF.1 (real quote→job-card)
  handlers will need to read + mutate plug-in-owned tables; the
  cleanest approach is closure-capture inside the plug-in class
  (handler instantiated inside `start(context)` with the context
  captured in the outer scope). Decision deferred to REF.1.
- BPMN resource hot reload. The deployer runs once per plug-in
  start; a plug-in whose BPMN changes under its feet at runtime
  isn't supported yet.
- Plug-in-shipped DMN / CMMN resources. The deployer only looks at
  `.bpmn20.xml` and `.bpmn`. Decision-table and case-management
  resources are not on the v1.0 critical path.
platform/platform-plugins/src/main/kotlin/org/vibeerp/platform/plugins/VibeErpPluginManager.kt
... ... @@ -19,6 +19,7 @@ import org.vibeerp.platform.plugins.endpoints.ScopedPluginEndpointRegistrar
19 19 import org.vibeerp.platform.plugins.lint.PluginLinter
20 20 import org.vibeerp.platform.plugins.migration.PluginLiquibaseRunner
21 21 import org.vibeerp.platform.plugins.workflow.ScopedTaskHandlerRegistrar
  22 +import org.vibeerp.platform.workflow.PluginProcessDeployer
22 23 import org.vibeerp.platform.workflow.TaskHandlerRegistry
23 24 import java.nio.file.Files
24 25 import java.nio.file.Path
... ... @@ -68,6 +69,7 @@ class VibeErpPluginManager(
68 69 private val permissionEvaluator: PermissionEvaluator,
69 70 private val localeProvider: LocaleProvider,
70 71 private val taskHandlerRegistry: TaskHandlerRegistry,
  72 + private val pluginProcessDeployer: PluginProcessDeployer,
71 73 ) : DefaultPluginManager(Paths.get(properties.directory)), InitializingBean, DisposableBean {
72 74  
73 75 private val log = LoggerFactory.getLogger(VibeErpPluginManager::class.java)
... ... @@ -289,9 +291,42 @@ class VibeErpPluginManager(
289 291 // Strip any partial registrations the plug-in may have
290 292 // managed before throwing — same treatment for endpoints
291 293 // and TaskHandlers so a failed start does not leave either
292   - // registry in a half-populated state.
  294 + // registry in a half-populated state. No BPMN deployments
  295 + // were made yet (we deploy AFTER start succeeds), so
  296 + // undeployment is unnecessary here.
293 297 endpointRegistry.unregisterAll(pluginId)
294 298 taskHandlerRegistry.unregisterAllByOwner(pluginId)
  299 + return
  300 + }
  301 +
  302 + // Only after start(context) has successfully registered the
  303 + // plug-in's TaskHandlers do we deploy the BPMNs that reference
  304 + // them. A deployed BPMN whose delegate key has no matching
  305 + // handler would not fail deployment (Flowable only resolves
  306 + // delegate expressions at process-start time) but any process
  307 + // instance started before the handler landed would throw. On
  308 + // failure here we drop the plug-in's handlers + endpoints and
  309 + // mark the plug-in inactive, matching the start-failure path
  310 + // above.
  311 + try {
  312 + pluginProcessDeployer.deployFromPlugin(pluginId, wrapper.pluginPath)
  313 + } catch (ex: Throwable) {
  314 + log.error(
  315 + "vibe_erp plug-in '{}' BPMN deployment failed after successful start; unwinding plug-in state",
  316 + pluginId, ex,
  317 + )
  318 + try {
  319 + vibeErpPlugin.stop()
  320 + } catch (stopEx: Throwable) {
  321 + log.warn(
  322 + "vibe_erp plug-in '{}' threw during stop after BPMN deploy failure; continuing teardown",
  323 + pluginId, stopEx,
  324 + )
  325 + }
  326 + started.removeAll { it.first == pluginId }
  327 + endpointRegistry.unregisterAll(pluginId)
  328 + taskHandlerRegistry.unregisterAllByOwner(pluginId)
  329 + pluginProcessDeployer.undeployByPlugin(pluginId)
295 330 }
296 331 }
297 332  
... ... @@ -306,6 +341,7 @@ class VibeErpPluginManager(
306 341 }
307 342 endpointRegistry.unregisterAll(pluginId)
308 343 taskHandlerRegistry.unregisterAllByOwner(pluginId)
  344 + pluginProcessDeployer.undeployByPlugin(pluginId)
309 345 }
310 346 started.clear()
311 347 stopPlugins()
... ...
platform/platform-workflow/src/main/kotlin/org/vibeerp/platform/workflow/PluginProcessDeployer.kt 0 → 100644
  1 +package org.vibeerp.platform.workflow
  2 +
  3 +import org.flowable.engine.RepositoryService
  4 +import org.slf4j.LoggerFactory
  5 +import org.springframework.stereotype.Component
  6 +import java.nio.file.Files
  7 +import java.nio.file.Path
  8 +import java.util.jar.JarFile
  9 +
  10 +/**
  11 + * Reads BPMN 2.0 resources out of a plug-in JAR and deploys them to
  12 + * the Flowable process engine under a `category` tag equal to the
  13 + * plug-in id, so that [undeployByPlugin] can drop every deployment
  14 + * contributed by a plug-in in a single query at stop time.
  15 + *
  16 + * Why a dedicated deployer instead of relying on Flowable's built-in
  17 + * classpath auto-deployer:
  18 + * - Flowable's Spring Boot starter auto-deploys everything matching
  19 + * the `classpath[star]:[slash]processes[slash][star].bpmn20.xml`
  20 + * glob from the HOST classpath. PF4J plug-ins load through an
  21 + * isolated child classloader that is NOT on the system classpath,
  22 + * so plug-in BPMNs are invisible to that scan. Manually reading
  23 + * each plug-in JAR is the only way to deploy process definitions
  24 + * a plug-in ships. (The literal glob cannot appear in this KDoc
  25 + * because `[slash][star]` inside a Kotlin doc comment would start
  26 + * a nested comment block.)
  27 + * - Parent-first class loading inside `PluginClassLoader` means a
  28 + * naive `getResources("processes")` lookup against the plug-in
  29 + * classloader would ALSO see the host's core BPMN files and
  30 + * re-deploy them under the plug-in's category. Walking the JAR
  31 + * file entries directly via [JarFile] guarantees we only touch
  32 + * the plug-in's own resources — same rationale as
  33 + * [org.vibeerp.platform.metadata.MetadataLoader.loadFromPluginJar].
  34 + *
  35 + * Resource discovery rules:
  36 + * - A resource is deployed if its name starts with `processes/` and
  37 + * ends with `.bpmn20.xml` or `.bpmn`.
  38 + * - All matching resources from one plug-in are bundled into ONE
  39 + * Flowable `Deployment` with category = pluginId, named
  40 + * `plugin:<id>`. One deployment per plug-in keeps undeploy
  41 + * atomic and makes the `deploymentCategory(pluginId)` query
  42 + * below unambiguous.
  43 + *
  44 + * Lifecycle:
  45 + * - [deployFromPlugin] is called by [org.vibeerp.platform.plugins.VibeErpPluginManager]
  46 + * AFTER the plug-in's `start(context)` lambda has registered its
  47 + * TaskHandlers. This ordering matters: a deployed BPMN that
  48 + * references a service-task key whose handler is not yet
  49 + * registered would still deploy (Flowable only checks delegate
  50 + * expressions at process start time), but any process instance
  51 + * started before the handler was available would fail. Registering
  52 + * first is the safer default.
  53 + * - [undeployByPlugin] is called during plug-in stop (orderly
  54 + * shutdown or partial-start failure). Cascading delete removes
  55 + * process instances along with the deployment; that matches the
  56 + * framework's "stopping a plug-in makes it disappear" semantics.
  57 + */
  58 +@Component
  59 +class PluginProcessDeployer(
  60 + private val repositoryService: RepositoryService,
  61 +) {
  62 + private val log = LoggerFactory.getLogger(PluginProcessDeployer::class.java)
  63 +
  64 + /**
  65 + * Walk the plug-in JAR for BPMN resources under `processes/` and
  66 + * deploy them under a category = pluginId. Returns the Flowable
  67 + * deployment id if anything was deployed, or null if the plug-in
  68 + * ships no BPMN resources (or is loaded as an unpacked directory
  69 + * we cannot read as a JAR). The single returned id covers every
  70 + * process definition the plug-in contributes, per the one-
  71 + * deployment-per-plug-in convention.
  72 + */
  73 + fun deployFromPlugin(pluginId: String, jarPath: Path): String? {
  74 + require(pluginId.isNotBlank()) { "pluginId must not be blank" }
  75 +
  76 + if (!Files.isRegularFile(jarPath)) {
  77 + log.debug(
  78 + "PluginProcessDeployer: plug-in '{}' jarPath {} is not a regular file; skipping " +
  79 + "(probably loaded as unpacked dir — see MetadataLoader for the same caveat)",
  80 + pluginId, jarPath,
  81 + )
  82 + return null
  83 + }
  84 +
  85 + data class BpmnResource(val name: String, val bytes: ByteArray)
  86 +
  87 + val resources = mutableListOf<BpmnResource>()
  88 + JarFile(jarPath.toFile()).use { jar ->
  89 + val entries = jar.entries()
  90 + while (entries.hasMoreElements()) {
  91 + val entry = entries.nextElement()
  92 + if (entry.isDirectory) continue
  93 + val name = entry.name
  94 + if (!name.startsWith(PROCESSES_DIR)) continue
  95 + if (!name.endsWith(BPMN20_SUFFIX) && !name.endsWith(BPMN_SUFFIX)) continue
  96 + val bytes = jar.getInputStream(entry).use { it.readBytes() }
  97 + resources += BpmnResource(name, bytes)
  98 + }
  99 + }
  100 +
  101 + if (resources.isEmpty()) {
  102 + log.debug("PluginProcessDeployer: plug-in '{}' ships no BPMN resources under {}", pluginId, PROCESSES_DIR)
  103 + return null
  104 + }
  105 +
  106 + // Build one deployment per plug-in containing every BPMN
  107 + // resource. Category and name both carry the plug-in id so
  108 + // queries against either field find it.
  109 + val builder = repositoryService.createDeployment()
  110 + .name("plugin:$pluginId")
  111 + .category(pluginId)
  112 + for (resource in resources) {
  113 + // addInputStream closes nothing itself; we already copied
  114 + // the bytes out of the JarFile above so the JAR handle is
  115 + // already closed at this point.
  116 + builder.addBytes(resource.name, resource.bytes)
  117 + }
  118 + val deployment = builder.deploy()
  119 +
  120 + log.info(
  121 + "PluginProcessDeployer: plug-in '{}' deployed {} BPMN resource(s) as Flowable deploymentId='{}': {}",
  122 + pluginId, resources.size, deployment.id, resources.map { it.name },
  123 + )
  124 + return deployment.id
  125 + }
  126 +
  127 + /**
  128 + * Remove every Flowable deployment whose category equals the
  129 + * given plug-in id, cascading to running/historical instances.
  130 + * Returns the count of deployments removed. Idempotent: a second
  131 + * call after the plug-in is already undeployed returns 0.
  132 + */
  133 + fun undeployByPlugin(pluginId: String): Int {
  134 + val deployments = repositoryService.createDeploymentQuery()
  135 + .deploymentCategory(pluginId)
  136 + .list()
  137 + if (deployments.isEmpty()) return 0
  138 + for (deployment in deployments) {
  139 + try {
  140 + repositoryService.deleteDeployment(deployment.id, /* cascade = */ true)
  141 + log.info(
  142 + "PluginProcessDeployer: plug-in '{}' deployment '{}' removed (cascade)",
  143 + pluginId, deployment.id,
  144 + )
  145 + } catch (ex: Throwable) {
  146 + log.warn(
  147 + "PluginProcessDeployer: plug-in '{}' deployment '{}' failed to delete: {}",
  148 + pluginId, deployment.id, ex.message, ex,
  149 + )
  150 + }
  151 + }
  152 + return deployments.size
  153 + }
  154 +
  155 + companion object {
  156 + private const val PROCESSES_DIR: String = "processes/"
  157 + private const val BPMN20_SUFFIX: String = ".bpmn20.xml"
  158 + private const val BPMN_SUFFIX: String = ".bpmn"
  159 + }
  160 +}
... ...
platform/platform-workflow/src/test/kotlin/org/vibeerp/platform/workflow/PluginProcessDeployerTest.kt 0 → 100644
  1 +package org.vibeerp.platform.workflow
  2 +
  3 +import assertk.assertFailure
  4 +import assertk.assertThat
  5 +import assertk.assertions.isEqualTo
  6 +import assertk.assertions.isInstanceOf
  7 +import assertk.assertions.isNull
  8 +import io.mockk.every
  9 +import io.mockk.mockk
  10 +import io.mockk.verify
  11 +import org.flowable.engine.RepositoryService
  12 +import org.flowable.engine.repository.Deployment
  13 +import org.flowable.engine.repository.DeploymentBuilder
  14 +import org.flowable.engine.repository.DeploymentQuery
  15 +import org.junit.jupiter.api.Test
  16 +import org.junit.jupiter.api.io.TempDir
  17 +import java.nio.file.Files
  18 +import java.nio.file.Path
  19 +import java.util.jar.Attributes
  20 +import java.util.jar.JarEntry
  21 +import java.util.jar.JarOutputStream
  22 +import java.util.jar.Manifest
  23 +
  24 +class PluginProcessDeployerTest {
  25 +
  26 + @Test
  27 + fun `deployFromPlugin returns null when jarPath is not a regular file`(@TempDir tmp: Path) {
  28 + val missing = tmp.resolve("no-such.jar")
  29 + val repo = mockk<RepositoryService>() // must not be called
  30 + val deployer = PluginProcessDeployer(repo)
  31 +
  32 + assertThat(deployer.deployFromPlugin("some-plugin", missing)).isNull()
  33 + verify(exactly = 0) { repo.createDeployment() }
  34 + }
  35 +
  36 + @Test
  37 + fun `deployFromPlugin returns null when the plug-in jar has no BPMN resources`(@TempDir tmp: Path) {
  38 + val jar = tmp.resolve("empty.jar")
  39 + writeJar(jar, mapOf("README.txt" to "hello".toByteArray()))
  40 + val repo = mockk<RepositoryService>() // must not be called
  41 + val deployer = PluginProcessDeployer(repo)
  42 +
  43 + assertThat(deployer.deployFromPlugin("plugin-a", jar)).isNull()
  44 + verify(exactly = 0) { repo.createDeployment() }
  45 + }
  46 +
  47 + @Test
  48 + fun `deployFromPlugin reads every bpmn resource under processes and deploys one bundle`(@TempDir tmp: Path) {
  49 + val jar = tmp.resolve("printing-shop.jar")
  50 + val bpmnA = bpmnBytes("plate-approval")
  51 + val bpmnB = bpmnBytes("ink-mix")
  52 + writeJar(
  53 + jar,
  54 + mapOf(
  55 + "processes/plate-approval.bpmn20.xml" to bpmnA,
  56 + "processes/ink-mix.bpmn" to bpmnB,
  57 + "processes/README.md" to "ignored".toByteArray(), // not BPMN
  58 + "META-INF/vibe-erp/metadata/printing-shop.yml" to "#...".toByteArray(), // not in processes
  59 + ),
  60 + )
  61 +
  62 + val builder = mockk<DeploymentBuilder>()
  63 + every { builder.name(any()) } returns builder
  64 + every { builder.category(any()) } returns builder
  65 + every { builder.addBytes(any(), any()) } returns builder
  66 + val deployment = mockk<Deployment>()
  67 + every { deployment.id } returns "dep-42"
  68 + every { builder.deploy() } returns deployment
  69 +
  70 + val repo = mockk<RepositoryService>()
  71 + every { repo.createDeployment() } returns builder
  72 +
  73 + val deployer = PluginProcessDeployer(repo)
  74 + val result = deployer.deployFromPlugin("printing-shop", jar)
  75 +
  76 + assertThat(result).isEqualTo("dep-42")
  77 + verify(exactly = 1) { builder.name("plugin:printing-shop") }
  78 + verify(exactly = 1) { builder.category("printing-shop") }
  79 + verify(exactly = 1) { builder.addBytes("processes/plate-approval.bpmn20.xml", bpmnA) }
  80 + verify(exactly = 1) { builder.addBytes("processes/ink-mix.bpmn", bpmnB) }
  81 + verify(exactly = 1) { builder.deploy() }
  82 + }
  83 +
  84 + @Test
  85 + fun `deployFromPlugin rejects a blank plug-in id`(@TempDir tmp: Path) {
  86 + val jar = tmp.resolve("x.jar")
  87 + writeJar(jar, emptyMap())
  88 + val deployer = PluginProcessDeployer(mockk())
  89 +
  90 + assertFailure { deployer.deployFromPlugin(" ", jar) }
  91 + .isInstanceOf(IllegalArgumentException::class)
  92 + }
  93 +
  94 + @Test
  95 + fun `undeployByPlugin returns zero when there is nothing to remove`() {
  96 + val query = mockk<DeploymentQuery>()
  97 + every { query.deploymentCategory(any()) } returns query
  98 + every { query.list() } returns emptyList()
  99 +
  100 + val repo = mockk<RepositoryService>()
  101 + every { repo.createDeploymentQuery() } returns query
  102 +
  103 + val deployer = PluginProcessDeployer(repo)
  104 + assertThat(deployer.undeployByPlugin("nobody")).isEqualTo(0)
  105 + verify(exactly = 0) { repo.deleteDeployment(any(), any()) }
  106 + }
  107 +
  108 + @Test
  109 + fun `undeployByPlugin cascades a deleteDeployment per matching deployment`() {
  110 + val query = mockk<DeploymentQuery>()
  111 + every { query.deploymentCategory("printing-shop") } returns query
  112 + val d1 = mockk<Deployment>().also { every { it.id } returns "dep-1" }
  113 + val d2 = mockk<Deployment>().also { every { it.id } returns "dep-2" }
  114 + every { query.list() } returns listOf(d1, d2)
  115 +
  116 + val repo = mockk<RepositoryService>()
  117 + every { repo.createDeploymentQuery() } returns query
  118 + every { repo.deleteDeployment(any(), any()) } returns Unit
  119 +
  120 + val deployer = PluginProcessDeployer(repo)
  121 + assertThat(deployer.undeployByPlugin("printing-shop")).isEqualTo(2)
  122 +
  123 + verify(exactly = 1) { repo.deleteDeployment("dep-1", true) }
  124 + verify(exactly = 1) { repo.deleteDeployment("dep-2", true) }
  125 + }
  126 +
  127 + // ─── helpers ───────────────────────────────────────────────────
  128 +
  129 + private fun writeJar(jar: Path, entries: Map<String, ByteArray>) {
  130 + val manifest = Manifest().apply {
  131 + mainAttributes[Attributes.Name.MANIFEST_VERSION] = "1.0"
  132 + }
  133 + Files.newOutputStream(jar).use { outFile ->
  134 + JarOutputStream(outFile, manifest).use { jarOut ->
  135 + for ((name, bytes) in entries) {
  136 + jarOut.putNextEntry(JarEntry(name))
  137 + jarOut.write(bytes)
  138 + jarOut.closeEntry()
  139 + }
  140 + }
  141 + }
  142 + }
  143 +
  144 + private fun bpmnBytes(processId: String): ByteArray =
  145 + """
  146 + <?xml version="1.0" encoding="UTF-8"?>
  147 + <definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
  148 + targetNamespace="http://example.org">
  149 + <process id="$processId" isExecutable="true">
  150 + <startEvent id="s"/>
  151 + <endEvent id="e"/>
  152 + </process>
  153 + </definitions>
  154 + """.trimIndent().toByteArray()
  155 +}
... ...
reference-customer/plugin-printing-shop/src/main/resources/processes/plate-approval.bpmn20.xml 0 → 100644
  1 +<?xml version="1.0" encoding="UTF-8"?>
  2 +<!--
  3 + Reference printing-shop plug-in BPMN — plate approval workflow.
  4 +
  5 + This file is the executable acceptance test for the P2.1 follow-up
  6 + "plug-in-shipped BPMN auto-deployment" chunk. It lives inside the
  7 + plug-in JAR under processes/, which Flowable's Spring Boot starter
  8 + CANNOT see (classpath auto-scan doesn't traverse PF4J plug-in
  9 + classloaders). The host's PluginProcessDeployer reads this file out
  10 + of the JAR at plug-in start time and registers it with the Flowable
  11 + RepositoryService under category = "printing-shop". On plug-in stop
  12 + the host calls undeployByPlugin("printing-shop") which cascade-
  13 + deletes this deployment and every process instance that referenced
  14 + it.
  15 +
  16 + Structure: a single synchronous service task that delegates to the
  17 + shared Spring bean "taskDispatcher", which then routes by activity
  18 + id to the registered TaskHandler whose key() returns the serviceTask
  19 + id ("printing_shop.plate.approve" — PlateApprovalTaskHandler in the
  20 + plug-in's main module).
  21 +
  22 + Process-definition key "plugin-printing-shop-plate-approval" is the
  23 + REST caller's handle:
  24 + POST /api/v1/workflow/process-instances
  25 + {"processDefinitionKey":"plugin-printing-shop-plate-approval",
  26 + "variables":{"plateId":"...."}}
  27 +-->
  28 +<definitions xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL"
  29 + xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
  30 + xmlns:flowable="http://flowable.org/bpmn"
  31 + targetNamespace="http://vibeerp.org/plugin/printing-shop/bpmn">
  32 + <process id="plugin-printing-shop-plate-approval"
  33 + name="Printing shop — plate approval"
  34 + isExecutable="true">
  35 + <startEvent id="start"/>
  36 + <sequenceFlow id="flow-start-to-approve"
  37 + sourceRef="start"
  38 + targetRef="printing_shop.plate.approve"/>
  39 + <serviceTask id="printing_shop.plate.approve"
  40 + name="Approve plate"
  41 + flowable:delegateExpression="${taskDispatcher}"/>
  42 + <sequenceFlow id="flow-approve-to-end"
  43 + sourceRef="printing_shop.plate.approve"
  44 + targetRef="end"/>
  45 + <endEvent id="end"/>
  46 + </process>
  47 +</definitions>
... ...