[ISSUE #5411] Serve the legacy SDK gRPC protocol on the v2 runtime (port 10205 bridge) + connector plugin tests 23/23 (#5412)
* [ISSUE #5411] Serve the legacy SDK gRPC protocol on the v2 runtime (port 10205 bridge)
EventMeshGrpcServer binds PublisherService/ConsumerService/HeartbeatService on
eventmesh.grpc.port (opt-in, 0/-1 = off) and maps every call onto the v2
UniIngressService pipeline (WAL at-least-once, shared retry/DLQ):
- publish/batchPublish/publishOneWay/batchPublishOneWay -> ingress.publish (topic = subject)
- requestReply -> ingress.request with the legacy TTL attribute
- webhook subscribe -> WebHookChannel target; CLUSTERING->LOAD_BALANCE, BROADCASTING->BROADCAST
- subscribeStream (bidi) -> GrpcStreamChannel push target, ACKs ride back on the stream
- heartbeat -> GrpcClientRegistry TTL refresh + reaper unsubscribes stale clients
GrpcCloudEventMapper holds the proto<->v2 CloudEvent mapping + legacy Response envelope.
ruleGrpcProtocolHidden grants the sanctioned runtime.grpc.. adapter (TCP-rule precedent).
* [ISSUE #5411] gRPC bridge tests: hermetic mapping unit tests + real-SDK integration
GrpcCloudEventMapperTest covers every proto attribute flavor + the 3-key response
envelope. GrpcLegacyBridgeIntegrationTest boots the bridge over the in-memory
storage and drives it with the real EventMeshGrpcProducer/Consumer (the SDK as its
own conformance suite): publish, batch publish, stream subscription round-trip.
* Add unit tests for the remaining 19 connector plugins (23/23 coverage)
Hermetic where possible: webhook sinks (dingtalk/http/knative/lark/slack/wechat/
wecom/chatgpt) verified against a local capturing HTTP server; mcp (JSON-RPC
envelope), openfunction (Ce-* context headers), prometheus (single merged push),
spring (EventForwarder contract) exercised with their exact wire contracts;
webhook sources (10 plugins) POST their native callback payloads through the
lazy-bound hook port; prometheus/spring sources cover scrape + buffer drains;
external-client plugins (canal/jdbc/mongodb/rabbitmq/redis/s3/pravega) get
contract-level tests (init config-parse where lazy, commit no-op) since their
backends cannot boot in CI. Closes the "4 of 23 plugins carry the only unit
tests" gap tracked under the #5296 review.
* [ISSUE #5411] docs: legacy gRPC bridge served (opt-in); Connector Runtime test status
configuration.md flips eventmesh.grpc.port from RESERVED to served (opt-in, 1.x
default 10205); protocols.md gains the legacy-gRPC mapping table section; both
READMEs note the served bridge and update the Connector Runtime row to 23/23
plugin test coverage (drops the stale "4 of 23" wording).
* docs: make the Connector Runtime GA-criteria cell concrete
Replace the vague "GA criteria tracked under the #5296 architecture review"
wording with the actual remaining GA blockers: per-plugin real-backend
integration tests and the connector-runtime HA story. Also reference #5412
for the 23/23 unit-test coverage.
* docs: refresh remaining cross-references for the #5411 bridge and 23/23 tests
- deployment.md: connector-runtime status drops the stale "only 4 of 23
plugins carry unit tests" (now 23/23 since #5412; real-backend ITs stay
the GA gate)
- architecture/overview.md: protocol-status bullet + legacy-SDK paragraph
mention the served opt-in gRPC bridge; Key-classes table gains the
EventMeshGrpcServer entry
- quickstart/getting-started.md: port list mentions the opt-in 10205
legacy gRPC bridge
diff --git a/README.md b/README.md
index 7efd4d1..bf6dd66 100644
--- a/README.md
+++ b/README.md
@@ -67,7 +67,7 @@
| [Kafka / RocketMQ storage](eventmesh-storage-plugin/) (4.x, 5.x) | **GA target** | Recommended — pluggable WAL backends, TCK-covered (`MeshStoragePluginTCK`) | Primary path |
| [Memory storage](eventmesh-storage-plugin/eventmesh-storage-memory/) (default) | **Beta** | Zero-dependency dev/CI/quick-start backend (`docker run apache/eventmesh` with no broker); state is process-local — not for production | Switch `EVENTMESH_STORAGE_TYPE` to kafka / rocketmq / rocketmq5 |
| SSE / WebSocket push | **Beta** | Usable — integration-tested; ACK-tracked redelivery + DLQ are shared with long-polling (same `ReliableDispatcher`), e2e-real-broker suite in [#5389](https://github.com/apache/eventmesh/pull/5389) | Unified push transports |
-| Connector Runtime | **Beta** | Usable — working end-to-end; all 23 plugins fully implemented since [#5394](https://github.com/apache/eventmesh/pull/5394) (no template stubs left), data-loss hardening in [#5328](https://github.com/apache/eventmesh/pull/5328); 4 of 23 (file/kafka/pulsar/rocketmq) still carry the only unit tests | Remaining plugin tests + GA criteria tracked under the #5296 architecture review |
+| Connector Runtime | **Beta** | Usable — working end-to-end; all 23 plugins fully implemented since [#5394](https://github.com/apache/eventmesh/pull/5394) (no template stubs left), data-loss hardening in [#5328](https://github.com/apache/eventmesh/pull/5328); unit tests cover all 23 plugins since [#5412](https://github.com/apache/eventmesh/pull/5412) | Promote to GA once the remaining #5296 review items land (real-backend integration tests per plugin, connector-runtime HA story) |
| [A2A / Agent Gateway](docs/feature/a2a.md) | **Beta** | Usable — task store + runtime bridge (#5302/#5304), task reaper + Meta-backed agent cards in [#5346](https://github.com/apache/eventmesh/pull/5346), quota classification in [#5373](https://github.com/apache/eventmesh/pull/5373); the Testcontainers E2E gate (#5340) closed in September 2026 | Unified Runtime A2A |
| [Agent tools & event triggers](docs/feature/agent-tools.md) | **Experimental** | Evaluate — `LlmClient`/`ConversationMemory`/`AgentTool` extension points, connector-backed tools and SPI plugin deployment landed in [#5408](https://github.com/apache/eventmesh/pull/5408)/[#5409](https://github.com/apache/eventmesh/pull/5409); runtime in `eventmesh-agent-runtime`, plugins in `eventmesh-agent-plugin/` | Agent tool ecosystem |
| TCP / gRPC / OpenMessaging SDKs | **Legacy-compatible** | Existing users only — kept so old clients run unmodified; not extended | [HTTP + CloudEvents](docs/feature/client-java.md) |
@@ -81,7 +81,10 @@
> Migrating off TCP / gRPC SDKs? The legacy clients keep working against the current
> runtime; see the [client guide](docs/feature/client-java.md) for the
-> HTTP + CloudEvents replacement (`CloudEventsClient`).
+> HTTP + CloudEvents replacement (`CloudEventsClient`). The legacy gRPC SDK
+> surface is served again since the #5411 bridge (opt-in via
+> `eventmesh.grpc.port=10205`) — old `EventMeshGrpcProducer` /
+> `EventMeshGrpcConsumer` clients run unmodified on the v2 runtime.
### Documentation
diff --git a/README.zh-CN.md b/README.zh-CN.md
index 15e498f..b2da17d 100644
--- a/README.zh-CN.md
+++ b/README.zh-CN.md
@@ -67,7 +67,7 @@
| [Kafka / RocketMQ 存储](eventmesh-storage-plugin/)(4.x、5.x) | **GA 目标** | 推荐——可插拔 WAL 后端,TCK 覆盖(`MeshStoragePluginTCK`) | 主路径 |
| [Memory 内存存储](eventmesh-storage-plugin/eventmesh-storage-memory/)(默认) | **Beta** | 零依赖的开发/CI/快速上手后端(`docker run apache/eventmesh` 无需 broker);状态仅存于进程内——不可用于生产 | 切换 `EVENTMESH_STORAGE_TYPE` 为 kafka / rocketmq / rocketmq5 |
| SSE / WebSocket 推送 | **Beta** | 可用——已有集成测试;ACK 追踪的重投递与 DLQ 已与长轮询共享同一 `ReliableDispatcher`,真实 broker 的 e2e 套件见 [#5389](https://github.com/apache/eventmesh/pull/5389) | 统一推送传输 |
-| Connector Runtime | **Beta** | 可用——端到端工作;自 [#5394](https://github.com/apache/eventmesh/pull/5394) 起 23 个插件全部完整实现(不再有模板桩),数据丢失加固见 [#5328](https://github.com/apache/eventmesh/pull/5328);单元测试仍仅覆盖其中 4 个(file/kafka/pulsar/rocketmq) | 剩余插件测试与 GA 标准由 #5296 架构 review 跟踪 |
+| Connector Runtime | **Beta** | 可用——端到端工作;自 [#5394](https://github.com/apache/eventmesh/pull/5394) 起 23 个插件全部完整实现(不再有模板桩),数据丢失加固见 [#5328](https://github.com/apache/eventmesh/pull/5328);自 [#5412](https://github.com/apache/eventmesh/pull/5412) 起单元测试覆盖全部 23 个插件 | 待 #5296 review 剩余项落地后升级 GA(各插件真实后端集成测试、connector-runtime HA 方案) |
| [A2A / Agent 网关](docs/feature/a2a.md) | **Beta** | 可用——TaskStore + Runtime 桥(#5302/#5304)、任务 reaper 与 Meta 化 AgentCard([#5346](https://github.com/apache/eventmesh/pull/5346))、配额分类([#5373](https://github.com/apache/eventmesh/pull/5373))均已落地;Testcontainers E2E 门槛(#5340)已于 2026 年 9 月关闭 | 统一 Runtime A2A |
| [智能体工具与事件触发](docs/feature/agent-tools.md) | **实验性** | 评估——`LlmClient`/`ConversationMemory`/`AgentTool` 扩展点、connector 工具化与 SPI 插件化部署已落地([#5408](https://github.com/apache/eventmesh/pull/5408)/[#5409](https://github.com/apache/eventmesh/pull/5409));运行时在 `eventmesh-agent-runtime`,插件在 `eventmesh-agent-plugin/` | 智能体工具生态 |
| TCP / gRPC / OpenMessaging SDK | **Legacy 兼容** | 仅存量用户——保持老客户端零改动运行;不再扩展 | [HTTP + CloudEvents](docs/feature/client-java.md) |
@@ -80,7 +80,11 @@
- **Legacy 兼容** —— 仅为存量客户端零改动兼容而维护;只修缺陷、不加功能。新接入不要选这里。
> 正在从 TCP / gRPC SDK 迁移?Legacy 客户端在当前 Runtime 上继续可用;替代方案(HTTP +
-> CloudEvents 的 `CloudEventsClient`)见[客户端指引](docs/feature/client-java.md)。
+> CloudEvents 的 `CloudEventsClient`)
+
+> #5411 之后 legacy gRPC SDK 接入面重新可用(通过 `eventmesh.grpc.port=10205`
+> 显式开启)——旧的 `EventMeshGrpcProducer` / `EventMeshGrpcConsumer` 客户端
+> 可以零改动运行在 v2 Runtime 上。见[客户端指引](docs/feature/client-java.md)。
### 文档导航
diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md
index 16a0128..e37bb50 100644
--- a/docs/architecture/overview.md
+++ b/docs/architecture/overview.md
@@ -114,7 +114,8 @@
The legacy TCP / gRPC / OpenMessaging SDKs still work because the new
`EventMeshFrame` adaptor (`eventmesh-runtime/.../protocol/meshmessage`) and the
`UniTcpServer` (public/internal split per `#5297`) preserve the wire format and
-semantics of the old `MeshMessage` / `OpenMessage` clients.
+semantics of the old `MeshMessage` / `OpenMessage` clients; the legacy gRPC
+SDK family is served by the opt-in `EventMeshGrpcServer` bridge (#5411).
### Key classes
@@ -122,6 +123,7 @@
| --- | --- | --- |
| HTTP entry | `eventmesh-runtime/.../http/UniHttpServer.java` | Netty HTTP/S; entry of all `/events/*` + `/a2a/*` traffic; `withSecurityGate(...)` wiring point |
| WebSocket entry | `eventmesh-runtime/.../http/UniWsServer.java` | WebSocket transport for subscribers |
+| Legacy gRPC bridge | `eventmesh-runtime/.../grpc/EventMeshGrpcServer.java` | Opt-in port-10205 bridge serving the 1.x `PublisherService`/`ConsumerService`/`HeartbeatService` (#5411) |
| Ingress orchestrator | `eventmesh-runtime/.../ingress/UniIngressService.java` | Frame-typed facade; single protocol path (`EventMeshFrame`) used by both HTTP and the legacy TCP adaptor |
| Security gate | `eventmesh-runtime/.../security/gate/SecurityGate.java` | Opt-in unified gate (see §4) |
| Filter chain | `eventmesh-runtime/.../security/FilterChain.java` | Auth + ACL filters executed before the gate |
@@ -418,7 +420,9 @@
HTTP codec are still supported for backward compatibility but
are explicitly marked Legacy and receive only critical bug
fixes. See `docs/feature/protocols.md` for the migration path.
-* gRPC framing is **Beta**: stable, but the API surface may shift.
+* gRPC framing is **Beta**: stable, but the API surface may shift. The
+ legacy SDK gRPC surface is served again as an opt-in compatibility bridge
+ on `eventmesh.grpc.port` since #5411 (see `docs/feature/protocols.md` §1.1).
* A2A is **Experimental** until the readiness checks in #5340
pass.
diff --git a/docs/feature/deployment.md b/docs/feature/deployment.md
index 1e364e2..344abda 100644
--- a/docs/feature/deployment.md
+++ b/docs/feature/deployment.md
@@ -120,8 +120,10 @@
scheduled by the runtime's `ConnectorScheduler` with **generation
fencing** (#5382): every (re)assignment bumps a per-connector generation,
and a stale delayed start can never take over a connector running a newer
-generation. Status: **Experimental** — only 4 of 23 plugins carry unit
-tests today.
+generation. Status: **Experimental** — all 23 plugins carry unit tests
+since [#5412](https://github.com/apache/eventmesh/pull/5412) (hermetic
+webhook tests + contract tests for external-client plugins); real-backend
+integration tests remain the GA gate.
## Production checklist
diff --git a/docs/feature/protocols.md b/docs/feature/protocols.md
index 20fb1c7..b880520 100644
--- a/docs/feature/protocols.md
+++ b/docs/feature/protocols.md
@@ -20,7 +20,7 @@
| EventMeshFrame (internal) | **GA** | `org.apache.eventmesh.common.wire.EventMeshFrame` (Frame architecture) | runtime internal; producer / storage / push path | - |
| A2A (Agent-to-Agent) | **Experimental** | A2A JSON-RPC + SSE | `eventmesh-protocol-plugin/eventmesh-protocol-a2a`, A2A gateway on Runtime | - |
| MeshMessage TCP | **Legacy** | length-prefixed `MeshMessage` bytes | runtime `tcp/` subpackage, `eventmesh-protocol-plugin/eventmesh-protocol-meshmessage/resolver/tcp` | CloudEvents HTTP, or A2A (for agent workloads) |
-| gRPC (CloudEvents + EventMeshMessage) | **Beta** | protobuf over HTTP/2 | runtime `transport/grpc`, `eventmesh-protocol-plugin/eventmesh-protocol-meshmessage/resolver/grpc` | CloudEvents HTTP for new clients |
+| gRPC (CloudEvents + EventMeshMessage) | **Beta** | protobuf over HTTP/2 | runtime `grpc/` bridge (issue #5411), `eventmesh-protocol-plugin/eventmesh-protocol-meshmessage/resolver/grpc` | CloudEvents HTTP for new clients |
| OpenMessaging API (TCP) | **Legacy** | OMA spec, used by the legacy TCP client only | `eventmesh-sdks/eventmesh-sdk-java/client/tcp/impl/openmessage` | CloudEvents HTTP client |
> **GA** = production-ready and the recommended path. **Beta** = stable but
@@ -28,6 +28,33 @@
> change without notice. **Legacy** = still works, but is no longer the
> recommended choice and is being phased out.
+### 1.1 Legacy gRPC bridge (served, opt-in — issue #5411)
+
+The v2 runtime serves the legacy SDK gRPC protocol as a **compatibility
+bridge** on `eventmesh.grpc.port` (1.x default `10205`; unset / `-1` =
+disabled, opt-in like the WS port). There is no second messaging engine:
+every call maps onto the same v2 ingress/delivery pipeline the HTTP plane
+uses (WAL durability, at-least-once, shared retry/DLQ).
+
+| Legacy call | v2 mapping |
+| --- | --- |
+| `publish` / `batchPublish` / `publishOneWay` / `batchPublishOneWay` | proto `CloudEvent` → v2 CloudEvent, persisted via `UniIngressService.publish` (topic = proto `subject` attribute) |
+| `requestReply` | v2 request/reply correlation (`UniIngressService.request`), TTL attribute drives the timeout |
+| `subscribe` (webhook `url`) | `WebHookChannel` push target + v2 subscription per topic |
+| `subscribeStream` (bidi) | `GrpcStreamChannel` push target pumping the v2 dispatcher into the stream; ACKs ride back as stream replies |
+| `unsubscribe` | v2 unsubscribe per topic + client deregistration |
+| `heartbeat` | TTL refresh in the `GrpcClientRegistry`; a reaper evicts stale clients (unsubscribes them) |
+
+Group semantics: the legacy `consumerGroup` maps onto a v2 subscription
+group; the SDK's default `CLUSTERING` mode maps to `LOAD_BALANCE`
+distribution, `BROADCASTING` maps to `BROADCAST`. The clientId is derived
+from `consumerGroup` + `env` + `idc` (the 1.x triple).
+
+Non-goals (follow-ups): a new gRPC-native v2 API (HTTP + CloudEvents stays
+the primary path), the gRPC admin surface (stays unimplemented as on 1.x),
+and Go/Rust SDK verification (the protos are shared; Java SDK is the
+conformance suite — see `GrpcLegacyBridgeIntegrationTest`).
+
## 2. Server-side protocol plugins
The runtime discovers protocol adaptors via the
diff --git a/docs/quickstart/configuration.md b/docs/quickstart/configuration.md
index 4e5f017..796f879 100644
--- a/docs/quickstart/configuration.md
+++ b/docs/quickstart/configuration.md
@@ -78,7 +78,7 @@
| `eventmesh.http.port` | `10105` | Traffic HTTP (`/events/*`, `/agent/*`, `/session/*`) |
| `eventmesh.admin.port` | `10106` | Admin HTTP (`/admin/*`, `/metrics`) |
| `eventmesh.ws.port` | `-1` (disabled) | WebSocket push port; set e.g. `10107` to enable |
-| `eventmesh.grpc.port` | `10205` | RESERVED for the future gRPC protocol (not served yet; keeps the 1.x default warm) |
+| `eventmesh.grpc.port` | `-1` (disabled) | Legacy SDK gRPC bridge (Publisher/Consumer/Heartbeat services, issue #5411); set `10205` (the 1.x default) to serve old `EventMeshGrpcProducer`/`EventMeshGrpcConsumer` clients on the v2 runtime |
| `eventmesh.a2a.port` | `10108` | A2A gateway REST plane (opt-in via `eventmesh.a2a.enabled=true`) |
| `eventmesh.offset.path` | `./data/offset` | Local offset store directory |
diff --git a/docs/quickstart/getting-started.md b/docs/quickstart/getting-started.md
index 9413b20..76d3886 100644
--- a/docs/quickstart/getting-started.md
+++ b/docs/quickstart/getting-started.md
@@ -54,7 +54,8 @@
backend address keys shown below.
Ports: `10105` = traffic HTTP (`/events/*`), `10106` = admin HTTP (`/admin/*`). The WebSocket
-push port (`10107`) is opt-in.
+push port (`10107`) is opt-in, and the legacy SDK gRPC bridge (`10205`, for old
+`EventMeshGrpcProducer`/`EventMeshGrpcConsumer` clients) is opt-in since #5411.
### Option B — From source
diff --git a/eventmesh-architecture-guard/src/main/java/org/apache/eventmesh/architecture/guard/ArchitectureRules.java b/eventmesh-architecture-guard/src/main/java/org/apache/eventmesh/architecture/guard/ArchitectureRules.java
index 2968b99..53f9657 100644
--- a/eventmesh-architecture-guard/src/main/java/org/apache/eventmesh/architecture/guard/ArchitectureRules.java
+++ b/eventmesh-architecture-guard/src/main/java/org/apache/eventmesh/architecture/guard/ArchitectureRules.java
@@ -65,6 +65,9 @@
.and().resideOutsideOfPackage("org.apache.eventmesh.common..")
.and().resideOutsideOfPackage("org.apache.eventmesh.protocol.meshmessage..")
.and().resideOutsideOfPackage("org.apache.eventmesh.client..")
+ // #5411: the legacy gRPC bridge (runtime.grpc..) is the sanctioned server-side
+ // adapter for these types — same carve-out the TCP rule grants runtime..
+ .and().resideOutsideOfPackage("org.apache.eventmesh.runtime.grpc..")
.should().dependOnClassesThat().resideInAPackage("org.apache.eventmesh.common.protocol.grpc..");
public static ArchRule ruleTcpProtocolHidden = noClasses()
diff --git a/eventmesh-common/src/main/java/org/apache/eventmesh/common/protocol/grpc/package-info.java b/eventmesh-common/src/main/java/org/apache/eventmesh/common/protocol/grpc/package-info.java
index 210f7e6..bc4672a 100644
--- a/eventmesh-common/src/main/java/org/apache/eventmesh/common/protocol/grpc/package-info.java
+++ b/eventmesh-common/src/main/java/org/apache/eventmesh/common/protocol/grpc/package-info.java
@@ -31,8 +31,9 @@
* <p><b>Forbidden (this package must NOT be imported by):</b>
* <ul>
- * <li>{@code ANY module other than eventmesh-sdks:eventmesh-sdk-java and}
- * {@code eventmesh-protocol-plugin:eventmesh-protocol-meshmessage (ruleGrpcProtocolHidden)}</li>
+ * <li>{@code ANY module other than eventmesh-sdks:eventmesh-sdk-java,}
+ * {@code eventmesh-protocol-plugin:eventmesh-protocol-meshmessage and the runtime gRPC bridge}
+ * {@code (org.apache.eventmesh.runtime.grpc.., issue #5411) (ruleGrpcProtocolHidden)}</li>
* </ul>
*/
package org.apache.eventmesh.common.protocol.grpc;
diff --git a/eventmesh-connector-plugin/eventmesh-connector-canal/src/test/java/org/apache/eventmesh/connector/canal/sink/CanalSinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-canal/src/test/java/org/apache/eventmesh/connector/canal/sink/CanalSinkConnectorTest.java
new file mode 100644
index 0000000..4ec4583
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-canal/src/test/java/org/apache/eventmesh/connector/canal/sink/CanalSinkConnectorTest.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.canal.sink;
+
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Contract-level unit test for CanalSinkConnector: the external system is NOT available in CI, so this covers
+ * the parts that must hold regardless — instantiation, the documented default-config surface of
+ * init() (where init only parses config), and the commit() no-op contract.
+ */
+class CanalSinkConnectorTest {
+
+ @Test
+ void commitIsNoOp() {
+ CanalSinkConnector connector = new CanalSinkConnector();
+ CloudEvent last = CloudEventBuilder.v1().withId("e1")
+ .withSource(java.net.URI.create("/test")).withType("test.event").build();
+ connector.commit(Collections.singletonList(last)); // must not throw
+ }
+
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-canal/src/test/java/org/apache/eventmesh/connector/canal/source/CanalSourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-canal/src/test/java/org/apache/eventmesh/connector/canal/source/CanalSourceConnectorTest.java
new file mode 100644
index 0000000..1f71384
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-canal/src/test/java/org/apache/eventmesh/connector/canal/source/CanalSourceConnectorTest.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.canal.source;
+
+import java.util.Properties;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Contract-level unit test for CanalSourceConnector: the external system is NOT available in CI, so this covers
+ * the parts that must hold regardless — instantiation, the documented default-config surface of
+ * init() (where init only parses config), and the commit() no-op contract.
+ */
+class CanalSourceConnectorTest {
+
+ @Test
+ void commitIsNoOp() {
+ CanalSourceConnector connector = new CanalSourceConnector();
+ CloudEvent last = CloudEventBuilder.v1().withId("e1")
+ .withSource(java.net.URI.create("/test")).withType("test.event").build();
+ connector.commit(last); // must not throw
+ }
+
+ @Test
+ void initParsesDocumentedDefaults() {
+ CanalSourceConnector connector = new CanalSourceConnector();
+ connector.init(new Properties()); // must not connect anything
+ }
+
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-chatgpt/src/test/java/org/apache/eventmesh/connector/chatgpt/sink/ChatgptSinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-chatgpt/src/test/java/org/apache/eventmesh/connector/chatgpt/sink/ChatgptSinkConnectorTest.java
new file mode 100644
index 0000000..62ce4cb
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-chatgpt/src/test/java/org/apache/eventmesh/connector/chatgpt/sink/ChatgptSinkConnectorTest.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.chatgpt.sink;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayOutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: {class} POSTs every event's data bytes to the configured URL and swallows
+ * individual failures (log-and-continue contract). A local HTTP server captures the requests.
+ */
+class ChatgptSinkConnectorTest {
+
+ private HttpServer server;
+ private final List<String> bodies = new CopyOnWriteArrayList<>();
+ private ChatgptSinkConnector sink;
+
+ @BeforeEach
+ void boot() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", (HttpExchange ex) -> {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ ex.getRequestBody().transferTo(out);
+ bodies.add(out.toString(StandardCharsets.UTF_8));
+ ex.sendResponseHeaders(200, 0);
+ ex.close();
+ });
+ server.start();
+ sink = new ChatgptSinkConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.webhookUrl", "http://127.0.0.1:" + server.getAddress().getPort() + "/hook");
+ sink.init(props);
+ }
+
+ @AfterEach
+ void tearDown() {
+ sink.commit(Collections.emptyList());
+ server.stop(0);
+ }
+
+ private static CloudEvent event(String id, String data) {
+ return CloudEventBuilder.v1().withId(id).withSource(java.net.URI.create("/test"))
+ .withType("test.event")
+ .withData(data.getBytes(StandardCharsets.UTF_8)).build();
+ }
+
+ @Test
+ void putPostsEveryEventData() {
+ sink.put(List.of(event("e1", "alpha"), event("e2", "beta")));
+ assertEquals(List.of("alpha", "beta"), bodies);
+ }
+
+ @Test
+ void putWithEmptyBatchIsNoOp() {
+ sink.put(Collections.emptyList());
+ assertEquals(Collections.emptyList(), bodies);
+ }
+
+ @Test
+ void putWithNullDataPostsEmptyBody() {
+ sink.put(Collections.singletonList(
+ CloudEventBuilder.v1().withId("n1").withSource(java.net.URI.create("/test"))
+ .withType("test.event").build()));
+ assertEquals(1, bodies.size());
+ assertEquals("", bodies.get(0));
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-chatgpt/src/test/java/org/apache/eventmesh/connector/chatgpt/source/ChatgptSourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-chatgpt/src/test/java/org/apache/eventmesh/connector/chatgpt/source/ChatgptSourceConnectorTest.java
new file mode 100644
index 0000000..b2a835c
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-chatgpt/src/test/java/org/apache/eventmesh/connector/chatgpt/source/ChatgptSourceConnectorTest.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.chatgpt.source;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.InetSocketAddress;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Properties;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: ChatgptSourceConnector receives a POST on its webhook endpoint and poll() surfaces the body
+ * as a CloudEvent (POST -> buffered -> poll contract).
+ */
+class ChatgptSourceConnectorTest {
+
+ private ChatgptSourceConnector source;
+ private HttpServer probe; // keeps an unrelated port warm so stop ordering is deterministic
+
+ @BeforeEach
+ void boot() throws Exception {
+ source = new ChatgptSourceConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.port", "0");
+ props.setProperty("connector.path", "/chatgpt");
+ source.init(props);
+ probe = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ probe.start();
+ }
+
+ @AfterEach
+ void tearDown() {
+ probe.stop(0);
+ }
+
+ private int hookPort() throws Exception {
+ // the connector binds its port lazily on first poll; trigger it then discover the bound port
+ java.lang.reflect.Field f = ChatgptSourceConnector.class.getDeclaredField("server");
+ f.setAccessible(true);
+ com.sun.net.httpserver.HttpServer s =
+ (com.sun.net.httpserver.HttpServer) f.get(source);
+ return s.getAddress().getPort();
+ }
+
+ @Test
+ void postedBodyIsPolledAsEvent() throws Exception {
+ source.poll(); // trigger lazy server start
+ int port = hookPort();
+ HttpURLConnection conn = (HttpURLConnection) new URL(
+ "http://127.0.0.1:" + port + "/chatgpt").openConnection();
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ try (OutputStream os = conn.getOutputStream()) {
+ os.write("{\"prompt\":\"hi\"}".getBytes(StandardCharsets.UTF_8));
+ }
+ assertEquals(200, conn.getResponseCode());
+ conn.disconnect();
+
+ java.util.List<CloudEvent> events = source.poll();
+ assertEquals(1, events.size(), "posted body must surface as exactly one event");
+ // the connector answers the prompt: without an OpenAI key the answer is empty
+ assertEquals("{\"prompt\":\"hi\",\"answer\":\"\"}",
+ new String(events.get(0).getData().toBytes(), StandardCharsets.UTF_8));
+ assertTrue(events.get(0).getId() != null && !events.get(0).getId().isEmpty());
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-dingtalk/src/test/java/org/apache/eventmesh/connector/dingtalk/sink/DingtalkSinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-dingtalk/src/test/java/org/apache/eventmesh/connector/dingtalk/sink/DingtalkSinkConnectorTest.java
new file mode 100644
index 0000000..99b1784
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-dingtalk/src/test/java/org/apache/eventmesh/connector/dingtalk/sink/DingtalkSinkConnectorTest.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.dingtalk.sink;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayOutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: {class} POSTs every event's data bytes to the configured URL and swallows
+ * individual failures (log-and-continue contract). A local HTTP server captures the requests.
+ */
+class DingtalkSinkConnectorTest {
+
+ private HttpServer server;
+ private final List<String> bodies = new CopyOnWriteArrayList<>();
+ private DingtalkSinkConnector sink;
+
+ @BeforeEach
+ void boot() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", (HttpExchange ex) -> {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ ex.getRequestBody().transferTo(out);
+ bodies.add(out.toString(StandardCharsets.UTF_8));
+ ex.sendResponseHeaders(200, 0);
+ ex.close();
+ });
+ server.start();
+ sink = new DingtalkSinkConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.webhookUrl", "http://127.0.0.1:" + server.getAddress().getPort() + "/hook");
+ sink.init(props);
+ }
+
+ @AfterEach
+ void tearDown() {
+ sink.commit(Collections.emptyList());
+ server.stop(0);
+ }
+
+ private static CloudEvent event(String id, String data) {
+ return CloudEventBuilder.v1().withId(id).withSource(java.net.URI.create("/test"))
+ .withType("test.event")
+ .withData(data.getBytes(StandardCharsets.UTF_8)).build();
+ }
+
+ @Test
+ void putPostsEveryEventData() {
+ sink.put(List.of(event("e1", "alpha"), event("e2", "beta")));
+ assertEquals(List.of("alpha", "beta"), bodies);
+ }
+
+ @Test
+ void putWithEmptyBatchIsNoOp() {
+ sink.put(Collections.emptyList());
+ assertEquals(Collections.emptyList(), bodies);
+ }
+
+ @Test
+ void putWithNullDataPostsEmptyBody() {
+ sink.put(Collections.singletonList(
+ CloudEventBuilder.v1().withId("n1").withSource(java.net.URI.create("/test"))
+ .withType("test.event").build()));
+ assertEquals(1, bodies.size());
+ assertEquals("", bodies.get(0));
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-dingtalk/src/test/java/org/apache/eventmesh/connector/dingtalk/source/DingtalkSourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-dingtalk/src/test/java/org/apache/eventmesh/connector/dingtalk/source/DingtalkSourceConnectorTest.java
new file mode 100644
index 0000000..071134a
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-dingtalk/src/test/java/org/apache/eventmesh/connector/dingtalk/source/DingtalkSourceConnectorTest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.dingtalk.source;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.OutputStream;
+import java.lang.reflect.Field;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Properties;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: DingtalkSourceConnector lazily boots its webhook endpoint on first poll; a native dingtalk
+ * callback payload POSTed to the hook surfaces via poll() as a CloudEvent carrying the platform
+ * message id / subject mapping.
+ */
+class DingtalkSourceConnectorTest {
+
+ private static final String BODY = "{\"msgId\":\"m-1\",\"conversationId\":\"c-1\",\"text\":\"hello\"}";
+
+ private int hookPort(DingtalkSourceConnector source) throws Exception {
+ Field f = DingtalkSourceConnector.class.getDeclaredField("server");
+ f.setAccessible(true);
+ HttpServer s = (HttpServer) f.get(source);
+ return s.getAddress().getPort();
+ }
+
+ private DingtalkSourceConnector boot() {
+ DingtalkSourceConnector source = new DingtalkSourceConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.port", "0");
+ props.setProperty("connector.path", "/dingtalk");
+ // no secret/token set -> signature checks are disabled (dev mode)
+ source.init(props);
+ return source;
+ }
+
+ @Test
+ void postedCallbackSurfacesAsEvent() throws Exception {
+ DingtalkSourceConnector source = boot();
+ source.poll(); // trigger lazy server start
+ int port = hookPort(source);
+
+ HttpURLConnection conn = (HttpURLConnection) new URL(
+ "http://127.0.0.1:" + port + "/dingtalk").openConnection();
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type",
+ BODY.startsWith("<") ? "text/xml" : "application/json");
+ try (OutputStream os = conn.getOutputStream()) {
+ os.write(BODY.getBytes(StandardCharsets.UTF_8));
+ }
+ assertTrue(conn.getResponseCode() < 400, "callback must be accepted, got "
+ + conn.getResponseCode());
+ conn.disconnect();
+
+ List<CloudEvent> events = source.poll();
+ assertEquals(1, events.size(), "callback must surface as exactly one event");
+ CloudEvent event = events.get(0);
+ assertEquals("dingtalk.message", event.getType());
+ assertEquals("c-1", event.getSubject());
+ assertTrue(event.getId().startsWith("dingtalk-"),
+ "event id must be namespaced by the platform, got: " + event.getId());
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-http/src/test/java/org/apache/eventmesh/connector/http/sink/HttpSinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-http/src/test/java/org/apache/eventmesh/connector/http/sink/HttpSinkConnectorTest.java
new file mode 100644
index 0000000..a92b697
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-http/src/test/java/org/apache/eventmesh/connector/http/sink/HttpSinkConnectorTest.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.http.sink;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayOutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: {class} POSTs every event's data bytes to the configured URL and swallows
+ * individual failures (log-and-continue contract). A local HTTP server captures the requests.
+ */
+class HttpSinkConnectorTest {
+
+ private HttpServer server;
+ private final List<String> bodies = new CopyOnWriteArrayList<>();
+ private HttpSinkConnector sink;
+
+ @BeforeEach
+ void boot() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", (HttpExchange ex) -> {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ ex.getRequestBody().transferTo(out);
+ bodies.add(out.toString(StandardCharsets.UTF_8));
+ ex.sendResponseHeaders(200, 0);
+ ex.close();
+ });
+ server.start();
+ sink = new HttpSinkConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.url", "http://127.0.0.1:" + server.getAddress().getPort() + "/hook");
+ sink.init(props);
+ }
+
+ @AfterEach
+ void tearDown() {
+ sink.commit(Collections.emptyList());
+ server.stop(0);
+ }
+
+ private static CloudEvent event(String id, String data) {
+ return CloudEventBuilder.v1().withId(id).withSource(java.net.URI.create("/test"))
+ .withType("test.event")
+ .withData(data.getBytes(StandardCharsets.UTF_8)).build();
+ }
+
+ @Test
+ void putPostsEveryEventData() {
+ sink.put(List.of(event("e1", "alpha"), event("e2", "beta")));
+ assertEquals(List.of("alpha", "beta"), bodies);
+ }
+
+ @Test
+ void putWithEmptyBatchIsNoOp() {
+ sink.put(Collections.emptyList());
+ assertEquals(Collections.emptyList(), bodies);
+ }
+
+ @Test
+ void putWithNullDataPostsEmptyBody() {
+ sink.put(Collections.singletonList(
+ CloudEventBuilder.v1().withId("n1").withSource(java.net.URI.create("/test"))
+ .withType("test.event").build()));
+ assertEquals(1, bodies.size());
+ assertEquals("", bodies.get(0));
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-http/src/test/java/org/apache/eventmesh/connector/http/source/HttpSourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-http/src/test/java/org/apache/eventmesh/connector/http/source/HttpSourceConnectorTest.java
new file mode 100644
index 0000000..a46c8cb
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-http/src/test/java/org/apache/eventmesh/connector/http/source/HttpSourceConnectorTest.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.http.source;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.InetSocketAddress;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Properties;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: HttpSourceConnector receives a POST on its webhook endpoint and poll() surfaces the body
+ * as a CloudEvent (POST -> buffered -> poll contract).
+ */
+class HttpSourceConnectorTest {
+
+ private HttpSourceConnector source;
+ private HttpServer probe; // keeps an unrelated port warm so stop ordering is deterministic
+
+ @BeforeEach
+ void boot() throws Exception {
+ source = new HttpSourceConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.port", "0");
+ props.setProperty("connector.path", "/webhook");
+ source.init(props);
+ probe = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ probe.start();
+ }
+
+ @AfterEach
+ void tearDown() {
+ probe.stop(0);
+ }
+
+ private int hookPort() throws Exception {
+ // the connector binds its port lazily on first poll; trigger it then discover the bound port
+ java.lang.reflect.Field f = HttpSourceConnector.class.getDeclaredField("server");
+ f.setAccessible(true);
+ com.sun.net.httpserver.HttpServer s =
+ (com.sun.net.httpserver.HttpServer) f.get(source);
+ return s.getAddress().getPort();
+ }
+
+ @Test
+ void postedBodyIsPolledAsEvent() throws Exception {
+ source.poll(); // trigger lazy server start
+ int port = hookPort();
+ HttpURLConnection conn = (HttpURLConnection) new URL(
+ "http://127.0.0.1:" + port + "/webhook").openConnection();
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ try (OutputStream os = conn.getOutputStream()) {
+ os.write("hello-http".getBytes(StandardCharsets.UTF_8));
+ }
+ assertEquals(200, conn.getResponseCode());
+ conn.disconnect();
+
+ java.util.List<CloudEvent> events = source.poll();
+ assertEquals(1, events.size(), "posted body must surface as exactly one event");
+ assertEquals("hello-http",
+ new String(events.get(0).getData().toBytes(), StandardCharsets.UTF_8));
+ assertTrue(events.get(0).getId() != null && !events.get(0).getId().isEmpty());
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-jdbc/src/test/java/org/apache/eventmesh/connector/jdbc/sink/JdbcSinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-jdbc/src/test/java/org/apache/eventmesh/connector/jdbc/sink/JdbcSinkConnectorTest.java
new file mode 100644
index 0000000..31e34c0
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-jdbc/src/test/java/org/apache/eventmesh/connector/jdbc/sink/JdbcSinkConnectorTest.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.jdbc.sink;
+
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Contract-level unit test for JdbcSinkConnector: the external system is NOT available in CI, so this covers
+ * the parts that must hold regardless — instantiation, the documented default-config surface of
+ * init() (where init only parses config), and the commit() no-op contract.
+ */
+class JdbcSinkConnectorTest {
+
+ @Test
+ void commitIsNoOp() {
+ JdbcSinkConnector connector = new JdbcSinkConnector();
+ CloudEvent last = CloudEventBuilder.v1().withId("e1")
+ .withSource(java.net.URI.create("/test")).withType("test.event").build();
+ connector.commit(Collections.singletonList(last)); // must not throw
+ }
+
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-jdbc/src/test/java/org/apache/eventmesh/connector/jdbc/source/JdbcSourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-jdbc/src/test/java/org/apache/eventmesh/connector/jdbc/source/JdbcSourceConnectorTest.java
new file mode 100644
index 0000000..bf9c90b
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-jdbc/src/test/java/org/apache/eventmesh/connector/jdbc/source/JdbcSourceConnectorTest.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.jdbc.source;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Contract-level unit test for JdbcSourceConnector: the external system is NOT available in CI, so this covers
+ * the parts that must hold regardless — instantiation, the documented default-config surface of
+ * init() (where init only parses config), and the commit() no-op contract.
+ */
+class JdbcSourceConnectorTest {
+
+ @Test
+ void commitIsNoOp() {
+ JdbcSourceConnector connector = new JdbcSourceConnector();
+ CloudEvent last = CloudEventBuilder.v1().withId("e1")
+ .withSource(java.net.URI.create("/test")).withType("test.event").build();
+ connector.commit(last); // must not throw
+ }
+
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-knative/src/test/java/org/apache/eventmesh/connector/knative/sink/KnativeSinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-knative/src/test/java/org/apache/eventmesh/connector/knative/sink/KnativeSinkConnectorTest.java
new file mode 100644
index 0000000..df2cf31
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-knative/src/test/java/org/apache/eventmesh/connector/knative/sink/KnativeSinkConnectorTest.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.knative.sink;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayOutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: {class} POSTs every event's data bytes to the configured URL and swallows
+ * individual failures (log-and-continue contract). A local HTTP server captures the requests.
+ */
+class KnativeSinkConnectorTest {
+
+ private HttpServer server;
+ private final List<String> bodies = new CopyOnWriteArrayList<>();
+ private KnativeSinkConnector sink;
+
+ @BeforeEach
+ void boot() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", (HttpExchange ex) -> {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ ex.getRequestBody().transferTo(out);
+ bodies.add(out.toString(StandardCharsets.UTF_8));
+ ex.sendResponseHeaders(200, 0);
+ ex.close();
+ });
+ server.start();
+ sink = new KnativeSinkConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.sinkUrl", "http://127.0.0.1:" + server.getAddress().getPort() + "/hook");
+ sink.init(props);
+ }
+
+ @AfterEach
+ void tearDown() {
+ sink.commit(Collections.emptyList());
+ server.stop(0);
+ }
+
+ private static CloudEvent event(String id, String data) {
+ return CloudEventBuilder.v1().withId(id).withSource(java.net.URI.create("/test"))
+ .withType("test.event")
+ .withData(data.getBytes(StandardCharsets.UTF_8)).build();
+ }
+
+ @Test
+ void putPostsEveryEventData() {
+ sink.put(List.of(event("e1", "alpha"), event("e2", "beta")));
+ assertEquals(List.of("alpha", "beta"), bodies);
+ }
+
+ @Test
+ void putWithEmptyBatchIsNoOp() {
+ sink.put(Collections.emptyList());
+ assertEquals(Collections.emptyList(), bodies);
+ }
+
+ @Test
+ void putWithNullDataPostsEmptyBody() {
+ sink.put(Collections.singletonList(
+ CloudEventBuilder.v1().withId("n1").withSource(java.net.URI.create("/test"))
+ .withType("test.event").build()));
+ assertEquals(1, bodies.size());
+ assertEquals("", bodies.get(0));
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-knative/src/test/java/org/apache/eventmesh/connector/knative/source/KnativeSourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-knative/src/test/java/org/apache/eventmesh/connector/knative/source/KnativeSourceConnectorTest.java
new file mode 100644
index 0000000..5f46bf7
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-knative/src/test/java/org/apache/eventmesh/connector/knative/source/KnativeSourceConnectorTest.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.knative.source;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.InetSocketAddress;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Properties;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: KnativeSourceConnector receives a POST on its webhook endpoint and poll() surfaces the body
+ * as a CloudEvent (POST -> buffered -> poll contract).
+ */
+class KnativeSourceConnectorTest {
+
+ private KnativeSourceConnector source;
+ private HttpServer probe; // keeps an unrelated port warm so stop ordering is deterministic
+
+ @BeforeEach
+ void boot() throws Exception {
+ source = new KnativeSourceConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.port", "0");
+ props.setProperty("connector.path", "/");
+ source.init(props);
+ probe = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ probe.start();
+ }
+
+ @AfterEach
+ void tearDown() {
+ probe.stop(0);
+ }
+
+ private int hookPort() throws Exception {
+ // the connector binds its port lazily on first poll; trigger it then discover the bound port
+ java.lang.reflect.Field f = KnativeSourceConnector.class.getDeclaredField("server");
+ f.setAccessible(true);
+ com.sun.net.httpserver.HttpServer s =
+ (com.sun.net.httpserver.HttpServer) f.get(source);
+ return s.getAddress().getPort();
+ }
+
+ @Test
+ void postedBodyIsPolledAsEvent() throws Exception {
+ source.poll(); // trigger lazy server start
+ int port = hookPort();
+ HttpURLConnection conn = (HttpURLConnection) new URL(
+ "http://127.0.0.1:" + port + "/").openConnection();
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ try (OutputStream os = conn.getOutputStream()) {
+ os.write("hello-knative".getBytes(StandardCharsets.UTF_8));
+ }
+ assertEquals(200, conn.getResponseCode());
+ conn.disconnect();
+
+ java.util.List<CloudEvent> events = source.poll();
+ assertEquals(1, events.size(), "posted body must surface as exactly one event");
+ assertEquals("hello-knative",
+ new String(events.get(0).getData().toBytes(), StandardCharsets.UTF_8));
+ assertTrue(events.get(0).getId() != null && !events.get(0).getId().isEmpty());
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-lark/src/test/java/org/apache/eventmesh/connector/lark/sink/LarkSinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-lark/src/test/java/org/apache/eventmesh/connector/lark/sink/LarkSinkConnectorTest.java
new file mode 100644
index 0000000..4220be9
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-lark/src/test/java/org/apache/eventmesh/connector/lark/sink/LarkSinkConnectorTest.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.lark.sink;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayOutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: {class} POSTs every event's data bytes to the configured URL and swallows
+ * individual failures (log-and-continue contract). A local HTTP server captures the requests.
+ */
+class LarkSinkConnectorTest {
+
+ private HttpServer server;
+ private final List<String> bodies = new CopyOnWriteArrayList<>();
+ private LarkSinkConnector sink;
+
+ @BeforeEach
+ void boot() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", (HttpExchange ex) -> {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ ex.getRequestBody().transferTo(out);
+ bodies.add(out.toString(StandardCharsets.UTF_8));
+ ex.sendResponseHeaders(200, 0);
+ ex.close();
+ });
+ server.start();
+ sink = new LarkSinkConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.webhookUrl", "http://127.0.0.1:" + server.getAddress().getPort() + "/hook");
+ sink.init(props);
+ }
+
+ @AfterEach
+ void tearDown() {
+ sink.commit(Collections.emptyList());
+ server.stop(0);
+ }
+
+ private static CloudEvent event(String id, String data) {
+ return CloudEventBuilder.v1().withId(id).withSource(java.net.URI.create("/test"))
+ .withType("test.event")
+ .withData(data.getBytes(StandardCharsets.UTF_8)).build();
+ }
+
+ @Test
+ void putPostsEveryEventData() {
+ sink.put(List.of(event("e1", "alpha"), event("e2", "beta")));
+ assertEquals(List.of("alpha", "beta"), bodies);
+ }
+
+ @Test
+ void putWithEmptyBatchIsNoOp() {
+ sink.put(Collections.emptyList());
+ assertEquals(Collections.emptyList(), bodies);
+ }
+
+ @Test
+ void putWithNullDataPostsEmptyBody() {
+ sink.put(Collections.singletonList(
+ CloudEventBuilder.v1().withId("n1").withSource(java.net.URI.create("/test"))
+ .withType("test.event").build()));
+ assertEquals(1, bodies.size());
+ assertEquals("", bodies.get(0));
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-lark/src/test/java/org/apache/eventmesh/connector/lark/source/LarkSourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-lark/src/test/java/org/apache/eventmesh/connector/lark/source/LarkSourceConnectorTest.java
new file mode 100644
index 0000000..107f014
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-lark/src/test/java/org/apache/eventmesh/connector/lark/source/LarkSourceConnectorTest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.lark.source;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.OutputStream;
+import java.lang.reflect.Field;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Properties;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: LarkSourceConnector lazily boots its webhook endpoint on first poll; a native lark
+ * callback payload POSTed to the hook surfaces via poll() as a CloudEvent carrying the platform
+ * message id / subject mapping.
+ */
+class LarkSourceConnectorTest {
+
+ private static final String BODY = "{\"header\":{\"event_id\":\"ev-1\",\"event_type\":\"im.message.receive_v1\",\"app_id\":\"app-1\"}}";
+
+ private int hookPort(LarkSourceConnector source) throws Exception {
+ Field f = LarkSourceConnector.class.getDeclaredField("server");
+ f.setAccessible(true);
+ HttpServer s = (HttpServer) f.get(source);
+ return s.getAddress().getPort();
+ }
+
+ private LarkSourceConnector boot() {
+ LarkSourceConnector source = new LarkSourceConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.port", "0");
+ props.setProperty("connector.path", "/lark");
+ // no secret/token set -> signature checks are disabled (dev mode)
+ source.init(props);
+ return source;
+ }
+
+ @Test
+ void postedCallbackSurfacesAsEvent() throws Exception {
+ LarkSourceConnector source = boot();
+ source.poll(); // trigger lazy server start
+ int port = hookPort(source);
+
+ HttpURLConnection conn = (HttpURLConnection) new URL(
+ "http://127.0.0.1:" + port + "/lark").openConnection();
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type",
+ BODY.startsWith("<") ? "text/xml" : "application/json");
+ try (OutputStream os = conn.getOutputStream()) {
+ os.write(BODY.getBytes(StandardCharsets.UTF_8));
+ }
+ assertTrue(conn.getResponseCode() < 400, "callback must be accepted, got "
+ + conn.getResponseCode());
+ conn.disconnect();
+
+ List<CloudEvent> events = source.poll();
+ assertEquals(1, events.size(), "callback must surface as exactly one event");
+ CloudEvent event = events.get(0);
+ assertEquals("lark.im.message.receive_v1", event.getType());
+ assertEquals("app-1", event.getSubject());
+ assertTrue(event.getId().startsWith("lark-"),
+ "event id must be namespaced by the platform, got: " + event.getId());
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-mcp/src/test/java/org/apache/eventmesh/connector/mcp/sink/McpSinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-mcp/src/test/java/org/apache/eventmesh/connector/mcp/sink/McpSinkConnectorTest.java
new file mode 100644
index 0000000..80611b1
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-mcp/src/test/java/org/apache/eventmesh/connector/mcp/sink/McpSinkConnectorTest.java
@@ -0,0 +1,106 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.mcp.sink;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayOutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: {@link McpSinkConnector} wraps each event's data into a JSON-RPC 2.0
+ * request (method from config, monotonically increasing id) and POSTs it to the MCP server URL;
+ * non-2xx answers throw (redelivery contract).
+ */
+class McpSinkConnectorTest {
+
+ private HttpServer server;
+ private final List<String> bodies = new CopyOnWriteArrayList<>();
+ private final AtomicInteger status = new AtomicInteger(200);
+ private McpSinkConnector sink;
+ private final ObjectMapper mapper = new ObjectMapper();
+
+ @BeforeEach
+ void boot() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", (HttpExchange ex) -> {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ ex.getRequestBody().transferTo(out);
+ bodies.add(out.toString(StandardCharsets.UTF_8));
+ ex.sendResponseHeaders(status.get(), 0);
+ ex.close();
+ });
+ server.start();
+ sink = new McpSinkConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.mcpServerUrl",
+ "http://127.0.0.1:" + server.getAddress().getPort() + "/mcp");
+ props.setProperty("connector.method", "eventmesh.notify");
+ sink.init(props);
+ }
+
+ @AfterEach
+ void tearDown() {
+ sink.commit(Collections.emptyList());
+ server.stop(0);
+ }
+
+ private static CloudEvent event(String id, String data) {
+ return CloudEventBuilder.v1().withId(id).withSource(java.net.URI.create("/test"))
+ .withType("test.event")
+ .withData(data.getBytes(StandardCharsets.UTF_8)).build();
+ }
+
+ @Test
+ void putWrapsDataInJsonRpcEnvelope() throws Exception {
+ sink.put(Collections.singletonList(event("e1", "{\"k\":\"v\"}")));
+ assertEquals(1, bodies.size());
+ JsonNode rpc = mapper.readTree(bodies.get(0));
+ assertEquals("2.0", rpc.get("jsonrpc").asText());
+ assertEquals("eventmesh.notify", rpc.get("method").asText());
+ assertEquals("v", rpc.get("params").get("k").asText());
+ assertTrue(rpc.get("id").asInt() >= 1, "request id must be assigned");
+ }
+
+ @Test
+ void putThrowsOnNon2xx() {
+ status.set(500);
+ assertThrows(RuntimeException.class,
+ () -> sink.put(Collections.singletonList(event("e2", "{}"))));
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-mcp/src/test/java/org/apache/eventmesh/connector/mcp/source/McpSourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-mcp/src/test/java/org/apache/eventmesh/connector/mcp/source/McpSourceConnectorTest.java
new file mode 100644
index 0000000..d4eb395
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-mcp/src/test/java/org/apache/eventmesh/connector/mcp/source/McpSourceConnectorTest.java
@@ -0,0 +1,93 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.mcp.source;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.InetSocketAddress;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Properties;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: McpSourceConnector receives a POST on its webhook endpoint and poll() surfaces the body
+ * as a CloudEvent (POST -> buffered -> poll contract).
+ */
+class McpSourceConnectorTest {
+
+ private McpSourceConnector source;
+ private HttpServer probe; // keeps an unrelated port warm so stop ordering is deterministic
+
+ @BeforeEach
+ void boot() throws Exception {
+ source = new McpSourceConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.port", "0");
+ props.setProperty("connector.path", "/mcp");
+ source.init(props);
+ probe = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ probe.start();
+ }
+
+ @AfterEach
+ void tearDown() {
+ probe.stop(0);
+ }
+
+ private int hookPort() throws Exception {
+ // the connector binds its port lazily on first poll; trigger it then discover the bound port
+ java.lang.reflect.Field f = McpSourceConnector.class.getDeclaredField("server");
+ f.setAccessible(true);
+ com.sun.net.httpserver.HttpServer s =
+ (com.sun.net.httpserver.HttpServer) f.get(source);
+ return s.getAddress().getPort();
+ }
+
+ @Test
+ void postedBodyIsPolledAsEvent() throws Exception {
+ source.poll(); // trigger lazy server start
+ int port = hookPort();
+ HttpURLConnection conn = (HttpURLConnection) new URL(
+ "http://127.0.0.1:" + port + "/mcp").openConnection();
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ try (OutputStream os = conn.getOutputStream()) {
+ os.write("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"notify\"}".getBytes(StandardCharsets.UTF_8));
+ }
+ org.junit.jupiter.api.Assertions.assertTrue(conn.getResponseCode() < 300,
+ "callback must be accepted (2xx), got " + conn.getResponseCode());
+ conn.disconnect();
+
+ java.util.List<CloudEvent> events = source.poll();
+ assertEquals(1, events.size(), "posted body must surface as exactly one event");
+ assertEquals("{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"notify\"}",
+ new String(events.get(0).getData().toBytes(), StandardCharsets.UTF_8));
+ assertTrue(events.get(0).getId() != null && !events.get(0).getId().isEmpty());
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-mongodb/src/test/java/org/apache/eventmesh/connector/mongodb/sink/MongodbSinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-mongodb/src/test/java/org/apache/eventmesh/connector/mongodb/sink/MongodbSinkConnectorTest.java
new file mode 100644
index 0000000..17b9493
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-mongodb/src/test/java/org/apache/eventmesh/connector/mongodb/sink/MongodbSinkConnectorTest.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.mongodb.sink;
+
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Contract-level unit test for MongodbSinkConnector: the external system is NOT available in CI, so this covers
+ * the parts that must hold regardless — instantiation, the documented default-config surface of
+ * init() (where init only parses config), and the commit() no-op contract.
+ */
+class MongodbSinkConnectorTest {
+
+ @Test
+ void commitIsNoOp() {
+ MongodbSinkConnector connector = new MongodbSinkConnector();
+ CloudEvent last = CloudEventBuilder.v1().withId("e1")
+ .withSource(java.net.URI.create("/test")).withType("test.event").build();
+ connector.commit(Collections.singletonList(last)); // must not throw
+ }
+
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-mongodb/src/test/java/org/apache/eventmesh/connector/mongodb/source/MongodbSourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-mongodb/src/test/java/org/apache/eventmesh/connector/mongodb/source/MongodbSourceConnectorTest.java
new file mode 100644
index 0000000..7a51cb2
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-mongodb/src/test/java/org/apache/eventmesh/connector/mongodb/source/MongodbSourceConnectorTest.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.mongodb.source;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Contract-level unit test for MongodbSourceConnector: the external system is NOT available in CI, so this covers
+ * the parts that must hold regardless — instantiation, the documented default-config surface of
+ * init() (where init only parses config), and the commit() no-op contract.
+ */
+class MongodbSourceConnectorTest {
+
+ @Test
+ void commitIsNoOp() {
+ MongodbSourceConnector connector = new MongodbSourceConnector();
+ CloudEvent last = CloudEventBuilder.v1().withId("e1")
+ .withSource(java.net.URI.create("/test")).withType("test.event").build();
+ connector.commit(last); // must not throw
+ }
+
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-openfunction/src/test/java/org/apache/eventmesh/connector/openfunction/sink/OpenfunctionSinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-openfunction/src/test/java/org/apache/eventmesh/connector/openfunction/sink/OpenfunctionSinkConnectorTest.java
new file mode 100644
index 0000000..cc05539
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-openfunction/src/test/java/org/apache/eventmesh/connector/openfunction/sink/OpenfunctionSinkConnectorTest.java
@@ -0,0 +1,98 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.openfunction.sink;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayOutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: {@link OpenfunctionSinkConnector} POSTs each event to the function URL with
+ * the CloudEvents context in Ce-Id/Ce-Type/Ce-Source headers; non-2xx throws (redelivery).
+ */
+class OpenfunctionSinkConnectorTest {
+
+ private HttpServer server;
+ private final List<String> bodies = new CopyOnWriteArrayList<>();
+ private final List<String> ceIds = new CopyOnWriteArrayList<>();
+ private final AtomicInteger status = new AtomicInteger(200);
+ private OpenfunctionSinkConnector sink;
+
+ @BeforeEach
+ void boot() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", (HttpExchange ex) -> {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ ex.getRequestBody().transferTo(out);
+ bodies.add(out.toString(StandardCharsets.UTF_8));
+ ceIds.add(ex.getRequestHeaders().getFirst("Ce-Id"));
+ ex.sendResponseHeaders(status.get(), 0);
+ ex.close();
+ });
+ server.start();
+ sink = new OpenfunctionSinkConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.functionUrl",
+ "http://127.0.0.1:" + server.getAddress().getPort() + "/fn");
+ sink.init(props);
+ }
+
+ @AfterEach
+ void tearDown() {
+ sink.commit(Collections.emptyList());
+ server.stop(0);
+ }
+
+ private static CloudEvent event(String id, String data) {
+ return CloudEventBuilder.v1().withId(id).withSource(java.net.URI.create("/test"))
+ .withType("test.event")
+ .withData(data.getBytes(StandardCharsets.UTF_8)).build();
+ }
+
+ @Test
+ void putForwardsEventWithCeContextHeaders() {
+ sink.put(Collections.singletonList(event("of-1", "payload")));
+ assertEquals(List.of("payload"), bodies);
+ assertEquals(List.of("of-1"), ceIds, "Ce-Id header must carry the event id");
+ }
+
+ @Test
+ void putThrowsOnNon2xx() {
+ status.set(502);
+ assertThrows(RuntimeException.class,
+ () -> sink.put(Collections.singletonList(event("of-2", "x"))));
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-openfunction/src/test/java/org/apache/eventmesh/connector/openfunction/source/OpenfunctionSourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-openfunction/src/test/java/org/apache/eventmesh/connector/openfunction/source/OpenfunctionSourceConnectorTest.java
new file mode 100644
index 0000000..5a6929f
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-openfunction/src/test/java/org/apache/eventmesh/connector/openfunction/source/OpenfunctionSourceConnectorTest.java
@@ -0,0 +1,92 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.openfunction.source;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.InetSocketAddress;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Properties;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: OpenfunctionSourceConnector receives a POST on its webhook endpoint and poll() surfaces the body
+ * as a CloudEvent (POST -> buffered -> poll contract).
+ */
+class OpenfunctionSourceConnectorTest {
+
+ private OpenfunctionSourceConnector source;
+ private HttpServer probe; // keeps an unrelated port warm so stop ordering is deterministic
+
+ @BeforeEach
+ void boot() throws Exception {
+ source = new OpenfunctionSourceConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.port", "0");
+ props.setProperty("connector.path", "/openfunction");
+ source.init(props);
+ probe = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ probe.start();
+ }
+
+ @AfterEach
+ void tearDown() {
+ probe.stop(0);
+ }
+
+ private int hookPort() throws Exception {
+ // the connector binds its port lazily on first poll; trigger it then discover the bound port
+ java.lang.reflect.Field f = OpenfunctionSourceConnector.class.getDeclaredField("server");
+ f.setAccessible(true);
+ com.sun.net.httpserver.HttpServer s =
+ (com.sun.net.httpserver.HttpServer) f.get(source);
+ return s.getAddress().getPort();
+ }
+
+ @Test
+ void postedBodyIsPolledAsEvent() throws Exception {
+ source.poll(); // trigger lazy server start
+ int port = hookPort();
+ HttpURLConnection conn = (HttpURLConnection) new URL(
+ "http://127.0.0.1:" + port + "/openfunction").openConnection();
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ try (OutputStream os = conn.getOutputStream()) {
+ os.write("{\"x\":1}".getBytes(StandardCharsets.UTF_8));
+ }
+ assertEquals(200, conn.getResponseCode());
+ conn.disconnect();
+
+ java.util.List<CloudEvent> events = source.poll();
+ assertEquals(1, events.size(), "posted body must surface as exactly one event");
+ assertEquals("{\"x\":1}",
+ new String(events.get(0).getData().toBytes(), StandardCharsets.UTF_8));
+ assertTrue(events.get(0).getId() != null && !events.get(0).getId().isEmpty());
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-pravega/src/test/java/org/apache/eventmesh/connector/pravega/sink/PravegaSinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-pravega/src/test/java/org/apache/eventmesh/connector/pravega/sink/PravegaSinkConnectorTest.java
new file mode 100644
index 0000000..8fe6e91
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-pravega/src/test/java/org/apache/eventmesh/connector/pravega/sink/PravegaSinkConnectorTest.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.pravega.sink;
+
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Contract-level unit test for PravegaSinkConnector: the external system is NOT available in CI, so this covers
+ * the parts that must hold regardless — instantiation, the documented default-config surface of
+ * init() (where init only parses config), and the commit() no-op contract.
+ */
+class PravegaSinkConnectorTest {
+
+ @Test
+ void commitIsNoOp() {
+ PravegaSinkConnector connector = new PravegaSinkConnector();
+ CloudEvent last = CloudEventBuilder.v1().withId("e1")
+ .withSource(java.net.URI.create("/test")).withType("test.event").build();
+ connector.commit(Collections.singletonList(last)); // must not throw
+ }
+
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-pravega/src/test/java/org/apache/eventmesh/connector/pravega/source/PravegaSourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-pravega/src/test/java/org/apache/eventmesh/connector/pravega/source/PravegaSourceConnectorTest.java
new file mode 100644
index 0000000..89d4ae6
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-pravega/src/test/java/org/apache/eventmesh/connector/pravega/source/PravegaSourceConnectorTest.java
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.pravega.source;
+
+import java.util.Properties;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Contract-level unit test for PravegaSourceConnector: the external system is NOT available in CI, so this covers
+ * the parts that must hold regardless — instantiation, the documented default-config surface of
+ * init() (where init only parses config), and the commit() no-op contract.
+ */
+class PravegaSourceConnectorTest {
+
+ @Test
+ void commitIsNoOp() {
+ PravegaSourceConnector connector = new PravegaSourceConnector();
+ CloudEvent last = CloudEventBuilder.v1().withId("e1")
+ .withSource(java.net.URI.create("/test")).withType("test.event").build();
+ connector.commit(last); // must not throw
+ }
+
+ @Test
+ void initParsesDocumentedDefaults() {
+ PravegaSourceConnector connector = new PravegaSourceConnector();
+ connector.init(new Properties()); // must not connect anything
+ }
+
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-prometheus/src/test/java/org/apache/eventmesh/connector/prometheus/sink/PrometheusSinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-prometheus/src/test/java/org/apache/eventmesh/connector/prometheus/sink/PrometheusSinkConnectorTest.java
new file mode 100644
index 0000000..4a76efe
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-prometheus/src/test/java/org/apache/eventmesh/connector/prometheus/sink/PrometheusSinkConnectorTest.java
@@ -0,0 +1,107 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.prometheus.sink;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.io.ByteArrayOutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.atomic.AtomicInteger;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: {@link PrometheusSinkConnector} merges the batch into ONE text exposition
+ * payload pushed to the Pushgateway in a single request; an all-empty batch skips the push; a
+ * non-2xx answer throws (redelivery contract).
+ */
+class PrometheusSinkConnectorTest {
+
+ private HttpServer server;
+ private final List<String> bodies = new CopyOnWriteArrayList<>();
+ private final AtomicInteger status = new AtomicInteger(200);
+ private PrometheusSinkConnector sink;
+
+ @BeforeEach
+ void boot() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", (HttpExchange ex) -> {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ ex.getRequestBody().transferTo(out);
+ bodies.add(out.toString(StandardCharsets.UTF_8));
+ ex.sendResponseHeaders(status.get(), 0);
+ ex.close();
+ });
+ server.start();
+ sink = new PrometheusSinkConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.pushgatewayUrl",
+ "http://127.0.0.1:" + server.getAddress().getPort());
+ sink.init(props);
+ }
+
+ @AfterEach
+ void tearDown() {
+ sink.commit(Collections.emptyList());
+ server.stop(0);
+ }
+
+ private static CloudEvent metric(String data) {
+ return CloudEventBuilder.v1().withId("m").withSource(java.net.URI.create("/test"))
+ .withType("test.metric")
+ .withData(data.getBytes(StandardCharsets.UTF_8)).build();
+ }
+
+ @Test
+ void putMergesBatchIntoOnePush() {
+ sink.put(Arrays.asList(metric("a 1\n"), metric("b 2\n")));
+ assertEquals(1, bodies.size(), "batch must be one merged push");
+ String payload = bodies.get(0);
+ org.junit.jupiter.api.Assertions.assertTrue(payload.contains("a 1"));
+ org.junit.jupiter.api.Assertions.assertTrue(payload.contains("b 2"));
+ }
+
+ @Test
+ void putWithAllEmptyDataSkipsPush() {
+ sink.put(Collections.singletonList(
+ CloudEventBuilder.v1().withId("m").withSource(java.net.URI.create("/t"))
+ .withType("test.metric").build()));
+ assertEquals(Collections.emptyList(), bodies, "no metric text -> no push");
+ }
+
+ @Test
+ void putThrowsOnNon2xx() {
+ status.set(500);
+ assertThrows(RuntimeException.class, () -> sink.put(List.of(metric("x 1\n"))));
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-prometheus/src/test/java/org/apache/eventmesh/connector/prometheus/source/PrometheusSourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-prometheus/src/test/java/org/apache/eventmesh/connector/prometheus/source/PrometheusSourceConnectorTest.java
new file mode 100644
index 0000000..369ecea
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-prometheus/src/test/java/org/apache/eventmesh/connector/prometheus/source/PrometheusSourceConnectorTest.java
@@ -0,0 +1,87 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.prometheus.source;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Properties;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: {@link PrometheusSourceConnector} scrapes the configured metrics URL and
+ * surfaces the exposition body as one CloudEvent per poll.
+ */
+class PrometheusSourceConnectorTest {
+
+ private HttpServer server;
+ private PrometheusSourceConnector source;
+
+ @BeforeEach
+ void boot() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/metrics", (HttpExchange ex) -> {
+ byte[] body = "up 1\n".getBytes(StandardCharsets.UTF_8);
+ ex.sendResponseHeaders(200, body.length);
+ try (OutputStream os = ex.getResponseBody()) {
+ os.write(body);
+ }
+ });
+ server.start();
+ source = new PrometheusSourceConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.metricsUrl",
+ "http://127.0.0.1:" + server.getAddress().getPort() + "/metrics");
+ source.init(props);
+ }
+
+ @AfterEach
+ void tearDown() {
+ server.stop(0);
+ }
+
+ @Test
+ void pollScrapesMetricsBodyIntoEvent() {
+ List<CloudEvent> events = source.poll();
+ assertEquals(1, events.size());
+ assertEquals("up 1\n",
+ new String(events.get(0).getData().toBytes(), StandardCharsets.UTF_8));
+ assertEquals("prometheus.metrics", events.get(0).getType());
+ }
+
+ @Test
+ void pollOnUnreachableUrlReturnsEmpty() {
+ PrometheusSourceConnector dead = new PrometheusSourceConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.metricsUrl", "http://127.0.0.1:1/metrics");
+ dead.init(props);
+ org.junit.jupiter.api.Assertions.assertTrue(dead.poll().isEmpty(),
+ "scrape failure must degrade to an empty poll, not throw");
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-rabbitmq/src/test/java/org/apache/eventmesh/connector/rabbitmq/sink/RabbitmqSinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-rabbitmq/src/test/java/org/apache/eventmesh/connector/rabbitmq/sink/RabbitmqSinkConnectorTest.java
new file mode 100644
index 0000000..7ecf7c1
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-rabbitmq/src/test/java/org/apache/eventmesh/connector/rabbitmq/sink/RabbitmqSinkConnectorTest.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.rabbitmq.sink;
+
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Contract-level unit test for RabbitmqSinkConnector: the external system is NOT available in CI, so this covers
+ * the parts that must hold regardless — instantiation, the documented default-config surface of
+ * init() (where init only parses config), and the commit() no-op contract.
+ */
+class RabbitmqSinkConnectorTest {
+
+ @Test
+ void commitIsNoOp() {
+ RabbitmqSinkConnector connector = new RabbitmqSinkConnector();
+ CloudEvent last = CloudEventBuilder.v1().withId("e1")
+ .withSource(java.net.URI.create("/test")).withType("test.event").build();
+ connector.commit(Collections.singletonList(last)); // must not throw
+ }
+
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-rabbitmq/src/test/java/org/apache/eventmesh/connector/rabbitmq/source/RabbitmqSourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-rabbitmq/src/test/java/org/apache/eventmesh/connector/rabbitmq/source/RabbitmqSourceConnectorTest.java
new file mode 100644
index 0000000..bc984bd
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-rabbitmq/src/test/java/org/apache/eventmesh/connector/rabbitmq/source/RabbitmqSourceConnectorTest.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.rabbitmq.source;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Contract-level unit test for RabbitmqSourceConnector: the external system is NOT available in CI, so this covers
+ * the parts that must hold regardless — instantiation, the documented default-config surface of
+ * init() (where init only parses config), and the commit() no-op contract.
+ */
+class RabbitmqSourceConnectorTest {
+
+ @Test
+ void commitIsNoOp() {
+ RabbitmqSourceConnector connector = new RabbitmqSourceConnector();
+ CloudEvent last = CloudEventBuilder.v1().withId("e1")
+ .withSource(java.net.URI.create("/test")).withType("test.event").build();
+ connector.commit(last); // must not throw
+ }
+
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-redis/src/test/java/org/apache/eventmesh/connector/redis/sink/RedisSinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-redis/src/test/java/org/apache/eventmesh/connector/redis/sink/RedisSinkConnectorTest.java
new file mode 100644
index 0000000..0f7dee0
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-redis/src/test/java/org/apache/eventmesh/connector/redis/sink/RedisSinkConnectorTest.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.redis.sink;
+
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Contract-level unit test for RedisSinkConnector: the external system is NOT available in CI, so this covers
+ * the parts that must hold regardless — instantiation, the documented default-config surface of
+ * init() (where init only parses config), and the commit() no-op contract.
+ */
+class RedisSinkConnectorTest {
+
+ @Test
+ void commitIsNoOp() {
+ RedisSinkConnector connector = new RedisSinkConnector();
+ CloudEvent last = CloudEventBuilder.v1().withId("e1")
+ .withSource(java.net.URI.create("/test")).withType("test.event").build();
+ connector.commit(Collections.singletonList(last)); // must not throw
+ }
+
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-redis/src/test/java/org/apache/eventmesh/connector/redis/source/RedisSourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-redis/src/test/java/org/apache/eventmesh/connector/redis/source/RedisSourceConnectorTest.java
new file mode 100644
index 0000000..d8f8df0
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-redis/src/test/java/org/apache/eventmesh/connector/redis/source/RedisSourceConnectorTest.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.redis.source;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Contract-level unit test for RedisSourceConnector: the external system is NOT available in CI, so this covers
+ * the parts that must hold regardless — instantiation, the documented default-config surface of
+ * init() (where init only parses config), and the commit() no-op contract.
+ */
+class RedisSourceConnectorTest {
+
+ @Test
+ void commitIsNoOp() {
+ RedisSourceConnector connector = new RedisSourceConnector();
+ CloudEvent last = CloudEventBuilder.v1().withId("e1")
+ .withSource(java.net.URI.create("/test")).withType("test.event").build();
+ connector.commit(last); // must not throw
+ }
+
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-s3/src/test/java/org/apache/eventmesh/connector/s3/sink/S3SinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-s3/src/test/java/org/apache/eventmesh/connector/s3/sink/S3SinkConnectorTest.java
new file mode 100644
index 0000000..60ad9ae
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-s3/src/test/java/org/apache/eventmesh/connector/s3/sink/S3SinkConnectorTest.java
@@ -0,0 +1,42 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.s3.sink;
+
+import java.util.Collections;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Contract-level unit test for S3SinkConnector: the external system is NOT available in CI, so this covers
+ * the parts that must hold regardless — instantiation, the documented default-config surface of
+ * init() (where init only parses config), and the commit() no-op contract.
+ */
+class S3SinkConnectorTest {
+
+ @Test
+ void commitIsNoOp() {
+ S3SinkConnector connector = new S3SinkConnector();
+ CloudEvent last = CloudEventBuilder.v1().withId("e1")
+ .withSource(java.net.URI.create("/test")).withType("test.event").build();
+ connector.commit(Collections.singletonList(last)); // must not throw
+ }
+
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-s3/src/test/java/org/apache/eventmesh/connector/s3/source/S3SourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-s3/src/test/java/org/apache/eventmesh/connector/s3/source/S3SourceConnectorTest.java
new file mode 100644
index 0000000..c5140c3
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-s3/src/test/java/org/apache/eventmesh/connector/s3/source/S3SourceConnectorTest.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.s3.source;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Contract-level unit test for S3SourceConnector: the external system is NOT available in CI, so this covers
+ * the parts that must hold regardless — instantiation, the documented default-config surface of
+ * init() (where init only parses config), and the commit() no-op contract.
+ */
+class S3SourceConnectorTest {
+
+ @Test
+ void commitIsNoOp() {
+ S3SourceConnector connector = new S3SourceConnector();
+ CloudEvent last = CloudEventBuilder.v1().withId("e1")
+ .withSource(java.net.URI.create("/test")).withType("test.event").build();
+ connector.commit(last); // must not throw
+ }
+
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-slack/src/test/java/org/apache/eventmesh/connector/slack/sink/SlackSinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-slack/src/test/java/org/apache/eventmesh/connector/slack/sink/SlackSinkConnectorTest.java
new file mode 100644
index 0000000..28691e9
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-slack/src/test/java/org/apache/eventmesh/connector/slack/sink/SlackSinkConnectorTest.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.slack.sink;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayOutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: {class} POSTs every event's data bytes to the configured URL and swallows
+ * individual failures (log-and-continue contract). A local HTTP server captures the requests.
+ */
+class SlackSinkConnectorTest {
+
+ private HttpServer server;
+ private final List<String> bodies = new CopyOnWriteArrayList<>();
+ private SlackSinkConnector sink;
+
+ @BeforeEach
+ void boot() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", (HttpExchange ex) -> {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ ex.getRequestBody().transferTo(out);
+ bodies.add(out.toString(StandardCharsets.UTF_8));
+ ex.sendResponseHeaders(200, 0);
+ ex.close();
+ });
+ server.start();
+ sink = new SlackSinkConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.webhookUrl", "http://127.0.0.1:" + server.getAddress().getPort() + "/hook");
+ sink.init(props);
+ }
+
+ @AfterEach
+ void tearDown() {
+ sink.commit(Collections.emptyList());
+ server.stop(0);
+ }
+
+ private static CloudEvent event(String id, String data) {
+ return CloudEventBuilder.v1().withId(id).withSource(java.net.URI.create("/test"))
+ .withType("test.event")
+ .withData(data.getBytes(StandardCharsets.UTF_8)).build();
+ }
+
+ @Test
+ void putPostsEveryEventData() {
+ sink.put(List.of(event("e1", "alpha"), event("e2", "beta")));
+ assertEquals(List.of("alpha", "beta"), bodies);
+ }
+
+ @Test
+ void putWithEmptyBatchIsNoOp() {
+ sink.put(Collections.emptyList());
+ assertEquals(Collections.emptyList(), bodies);
+ }
+
+ @Test
+ void putWithNullDataPostsEmptyBody() {
+ sink.put(Collections.singletonList(
+ CloudEventBuilder.v1().withId("n1").withSource(java.net.URI.create("/test"))
+ .withType("test.event").build()));
+ assertEquals(1, bodies.size());
+ assertEquals("", bodies.get(0));
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-slack/src/test/java/org/apache/eventmesh/connector/slack/source/SlackSourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-slack/src/test/java/org/apache/eventmesh/connector/slack/source/SlackSourceConnectorTest.java
new file mode 100644
index 0000000..0f056e2
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-slack/src/test/java/org/apache/eventmesh/connector/slack/source/SlackSourceConnectorTest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.slack.source;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.OutputStream;
+import java.lang.reflect.Field;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Properties;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: SlackSourceConnector lazily boots its webhook endpoint on first poll; a native slack
+ * callback payload POSTed to the hook surfaces via poll() as a CloudEvent carrying the platform
+ * message id / subject mapping.
+ */
+class SlackSourceConnectorTest {
+
+ private static final String BODY = "{\"event_id\":\"ev-9\",\"team_id\":\"T-1\",\"event\":{\"type\":\"message\"}}";
+
+ private int hookPort(SlackSourceConnector source) throws Exception {
+ Field f = SlackSourceConnector.class.getDeclaredField("server");
+ f.setAccessible(true);
+ HttpServer s = (HttpServer) f.get(source);
+ return s.getAddress().getPort();
+ }
+
+ private SlackSourceConnector boot() {
+ SlackSourceConnector source = new SlackSourceConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.port", "0");
+ props.setProperty("connector.path", "/slack");
+ // no secret/token set -> signature checks are disabled (dev mode)
+ source.init(props);
+ return source;
+ }
+
+ @Test
+ void postedCallbackSurfacesAsEvent() throws Exception {
+ SlackSourceConnector source = boot();
+ source.poll(); // trigger lazy server start
+ int port = hookPort(source);
+
+ HttpURLConnection conn = (HttpURLConnection) new URL(
+ "http://127.0.0.1:" + port + "/slack").openConnection();
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type",
+ BODY.startsWith("<") ? "text/xml" : "application/json");
+ try (OutputStream os = conn.getOutputStream()) {
+ os.write(BODY.getBytes(StandardCharsets.UTF_8));
+ }
+ assertTrue(conn.getResponseCode() < 400, "callback must be accepted, got "
+ + conn.getResponseCode());
+ conn.disconnect();
+
+ List<CloudEvent> events = source.poll();
+ assertEquals(1, events.size(), "callback must surface as exactly one event");
+ CloudEvent event = events.get(0);
+ assertEquals("slack.message", event.getType());
+ assertEquals("T-1", event.getSubject());
+ assertTrue(event.getId().startsWith("slack-"),
+ "event id must be namespaced by the platform, got: " + event.getId());
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-spring/src/test/java/org/apache/eventmesh/connector/spring/sink/SpringSinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-spring/src/test/java/org/apache/eventmesh/connector/spring/sink/SpringSinkConnectorTest.java
new file mode 100644
index 0000000..0947281
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-spring/src/test/java/org/apache/eventmesh/connector/spring/sink/SpringSinkConnectorTest.java
@@ -0,0 +1,77 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.spring.sink;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Hermetic unit test of the {@link SpringSinkConnector} forwarding contract: put() without an
+ * injected EventForwarder fails fast; with one, every event is forwarded in order and a failing
+ * forwarder surfaces as a RuntimeException (redelivery).
+ */
+class SpringSinkConnectorTest {
+
+ private static CloudEvent event(String id) {
+ return CloudEventBuilder.v1().withId(id).withSource(java.net.URI.create("/test"))
+ .withType("test.event").build();
+ }
+
+ @Test
+ void putWithoutForwarderFailsFast() {
+ SpringSinkConnector sink = new SpringSinkConnector();
+ sink.init(new Properties());
+ assertThrows(IllegalStateException.class,
+ () -> sink.put(Collections.singletonList(event("e1"))));
+ }
+
+ @Test
+ void putForwardsEveryEventInOrder() {
+ SpringSinkConnector sink = new SpringSinkConnector();
+ sink.init(new Properties());
+ List<CloudEvent> seen = new CopyOnWriteArrayList<>();
+ sink.setForwarder(seen::add);
+ sink.put(Arrays.asList(event("e1"), event("e2"), event("e3")));
+ assertEquals(Arrays.asList("e1", "e2", "e3"),
+ seen.stream().map(CloudEvent::getId).toList());
+ }
+
+ @Test
+ void putSurfacesForwarderFailure() {
+ SpringSinkConnector sink = new SpringSinkConnector();
+ sink.init(new Properties());
+ sink.setForwarder(e -> {
+ throw new IllegalArgumentException("boom");
+ });
+ RuntimeException ex = assertThrows(RuntimeException.class,
+ () -> sink.put(Collections.singletonList(event("e1"))));
+ assertTrue(ex.getMessage().contains("e1"), "error must name the failing event");
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-spring/src/test/java/org/apache/eventmesh/connector/spring/source/SpringSourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-spring/src/test/java/org/apache/eventmesh/connector/spring/source/SpringSourceConnectorTest.java
new file mode 100644
index 0000000..e6dc1c5
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-spring/src/test/java/org/apache/eventmesh/connector/spring/source/SpringSourceConnectorTest.java
@@ -0,0 +1,75 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.spring.source;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.lang.reflect.Field;
+import java.net.URI;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.LinkedBlockingQueue;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Hermetic unit test of the {@link SpringSourceConnector} buffer contract: a Spring
+ * {@code @EventListener} feeds the internal buffer (simulated here directly); poll() drains it
+ * in order and is a no-op before init.
+ */
+class SpringSourceConnectorTest {
+
+ private static CloudEvent event(String id) {
+ return CloudEventBuilder.v1().withId(id).withSource(URI.create("/test"))
+ .withType("test.event").build();
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void pollDrainsBufferedInOrder() throws Exception {
+ SpringSourceConnector source = new SpringSourceConnector();
+ source.init(new Properties());
+
+ Field f = SpringSourceConnector.class.getDeclaredField("buffer");
+ f.setAccessible(true);
+ LinkedBlockingQueue<CloudEvent> buffer = (LinkedBlockingQueue<CloudEvent>) f.get(source);
+ buffer.offer(event("e1"));
+ buffer.offer(event("e2"));
+
+ List<CloudEvent> drained = source.poll();
+ assertEquals(List.of("e1", "e2"), drained.stream().map(CloudEvent::getId).toList());
+ assertEquals(0, source.poll().size(), "second poll drains nothing");
+ }
+
+ @Test
+ void pollBeforeInitReturnsEmpty() {
+ SpringSourceConnector fresh = new SpringSourceConnector();
+ assertTrue(fresh.poll().isEmpty());
+ }
+
+ @Test
+ void commitIsNoOp() {
+ SpringSourceConnector source = new SpringSourceConnector();
+ source.init(new Properties());
+ source.commit(event("e1")); // must not throw
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-wechat/src/test/java/org/apache/eventmesh/connector/wechat/sink/WechatSinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-wechat/src/test/java/org/apache/eventmesh/connector/wechat/sink/WechatSinkConnectorTest.java
new file mode 100644
index 0000000..d4b25b6
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-wechat/src/test/java/org/apache/eventmesh/connector/wechat/sink/WechatSinkConnectorTest.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.wechat.sink;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayOutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: {class} POSTs every event's data bytes to the configured URL and swallows
+ * individual failures (log-and-continue contract). A local HTTP server captures the requests.
+ */
+class WechatSinkConnectorTest {
+
+ private HttpServer server;
+ private final List<String> bodies = new CopyOnWriteArrayList<>();
+ private WechatSinkConnector sink;
+
+ @BeforeEach
+ void boot() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", (HttpExchange ex) -> {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ ex.getRequestBody().transferTo(out);
+ bodies.add(out.toString(StandardCharsets.UTF_8));
+ ex.sendResponseHeaders(200, 0);
+ ex.close();
+ });
+ server.start();
+ sink = new WechatSinkConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.webhookUrl", "http://127.0.0.1:" + server.getAddress().getPort() + "/hook");
+ sink.init(props);
+ }
+
+ @AfterEach
+ void tearDown() {
+ sink.commit(Collections.emptyList());
+ server.stop(0);
+ }
+
+ private static CloudEvent event(String id, String data) {
+ return CloudEventBuilder.v1().withId(id).withSource(java.net.URI.create("/test"))
+ .withType("test.event")
+ .withData(data.getBytes(StandardCharsets.UTF_8)).build();
+ }
+
+ @Test
+ void putPostsEveryEventData() {
+ sink.put(List.of(event("e1", "alpha"), event("e2", "beta")));
+ assertEquals(List.of("alpha", "beta"), bodies);
+ }
+
+ @Test
+ void putWithEmptyBatchIsNoOp() {
+ sink.put(Collections.emptyList());
+ assertEquals(Collections.emptyList(), bodies);
+ }
+
+ @Test
+ void putWithNullDataPostsEmptyBody() {
+ sink.put(Collections.singletonList(
+ CloudEventBuilder.v1().withId("n1").withSource(java.net.URI.create("/test"))
+ .withType("test.event").build()));
+ assertEquals(1, bodies.size());
+ assertEquals("", bodies.get(0));
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-wechat/src/test/java/org/apache/eventmesh/connector/wechat/source/WechatSourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-wechat/src/test/java/org/apache/eventmesh/connector/wechat/source/WechatSourceConnectorTest.java
new file mode 100644
index 0000000..0d074ea
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-wechat/src/test/java/org/apache/eventmesh/connector/wechat/source/WechatSourceConnectorTest.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.wechat.source;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.OutputStream;
+import java.lang.reflect.Field;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Properties;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: WechatSourceConnector lazily boots its webhook endpoint on first poll; a native wechat
+ * callback payload POSTed to the hook surfaces via poll() as a CloudEvent carrying the platform
+ * message id / subject mapping.
+ */
+class WechatSourceConnectorTest {
+
+ private static final String BODY = "<xml><ToUserName><![CDATA[gh_1]]></ToUserName>"
+ + "<FromUserName><![CDATA[o-1]]></FromUserName>"
+ + "<MsgType><![CDATA[text]]></MsgType>"
+ + "<Content><![CDATA[hello-wechat]]></Content>"
+ + "<MsgId>123</MsgId></xml>";
+
+ private int hookPort(WechatSourceConnector source) throws Exception {
+ Field f = WechatSourceConnector.class.getDeclaredField("server");
+ f.setAccessible(true);
+ HttpServer s = (HttpServer) f.get(source);
+ return s.getAddress().getPort();
+ }
+
+ private WechatSourceConnector boot() {
+ WechatSourceConnector source = new WechatSourceConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.port", "0");
+ props.setProperty("connector.path", "/wechat");
+ // no secret/token set -> signature checks are disabled (dev mode)
+ source.init(props);
+ return source;
+ }
+
+ @Test
+ void postedCallbackSurfacesAsEvent() throws Exception {
+ WechatSourceConnector source = boot();
+ source.poll(); // trigger lazy server start
+ int port = hookPort(source);
+
+ HttpURLConnection conn = (HttpURLConnection) new URL(
+ "http://127.0.0.1:" + port + "/wechat").openConnection();
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type",
+ BODY.startsWith("<") ? "text/xml" : "application/json");
+ try (OutputStream os = conn.getOutputStream()) {
+ os.write(BODY.getBytes(StandardCharsets.UTF_8));
+ }
+ assertTrue(conn.getResponseCode() < 400, "callback must be accepted, got "
+ + conn.getResponseCode());
+ conn.disconnect();
+
+ List<CloudEvent> events = source.poll();
+ assertEquals(1, events.size(), "callback must surface as exactly one event");
+ CloudEvent event = events.get(0);
+ assertEquals("wechat.text", event.getType());
+ assertEquals("o-1", event.getSubject());
+ assertTrue(event.getId().startsWith("wechat-"),
+ "event id must be namespaced by the platform, got: " + event.getId());
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-wecom/src/test/java/org/apache/eventmesh/connector/wecom/sink/WecomSinkConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-wecom/src/test/java/org/apache/eventmesh/connector/wecom/sink/WecomSinkConnectorTest.java
new file mode 100644
index 0000000..e9753cf
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-wecom/src/test/java/org/apache/eventmesh/connector/wecom/sink/WecomSinkConnectorTest.java
@@ -0,0 +1,99 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.wecom.sink;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+
+import java.io.ByteArrayOutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.List;
+import java.util.Properties;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: {class} POSTs every event's data bytes to the configured URL and swallows
+ * individual failures (log-and-continue contract). A local HTTP server captures the requests.
+ */
+class WecomSinkConnectorTest {
+
+ private HttpServer server;
+ private final List<String> bodies = new CopyOnWriteArrayList<>();
+ private WecomSinkConnector sink;
+
+ @BeforeEach
+ void boot() throws Exception {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ server.createContext("/", (HttpExchange ex) -> {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ ex.getRequestBody().transferTo(out);
+ bodies.add(out.toString(StandardCharsets.UTF_8));
+ ex.sendResponseHeaders(200, 0);
+ ex.close();
+ });
+ server.start();
+ sink = new WecomSinkConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.webhookUrl", "http://127.0.0.1:" + server.getAddress().getPort() + "/hook");
+ sink.init(props);
+ }
+
+ @AfterEach
+ void tearDown() {
+ sink.commit(Collections.emptyList());
+ server.stop(0);
+ }
+
+ private static CloudEvent event(String id, String data) {
+ return CloudEventBuilder.v1().withId(id).withSource(java.net.URI.create("/test"))
+ .withType("test.event")
+ .withData(data.getBytes(StandardCharsets.UTF_8)).build();
+ }
+
+ @Test
+ void putPostsEveryEventData() {
+ sink.put(List.of(event("e1", "alpha"), event("e2", "beta")));
+ assertEquals(List.of("alpha", "beta"), bodies);
+ }
+
+ @Test
+ void putWithEmptyBatchIsNoOp() {
+ sink.put(Collections.emptyList());
+ assertEquals(Collections.emptyList(), bodies);
+ }
+
+ @Test
+ void putWithNullDataPostsEmptyBody() {
+ sink.put(Collections.singletonList(
+ CloudEventBuilder.v1().withId("n1").withSource(java.net.URI.create("/test"))
+ .withType("test.event").build()));
+ assertEquals(1, bodies.size());
+ assertEquals("", bodies.get(0));
+ }
+}
diff --git a/eventmesh-connector-plugin/eventmesh-connector-wecom/src/test/java/org/apache/eventmesh/connector/wecom/source/WecomSourceConnectorTest.java b/eventmesh-connector-plugin/eventmesh-connector-wecom/src/test/java/org/apache/eventmesh/connector/wecom/source/WecomSourceConnectorTest.java
new file mode 100644
index 0000000..a2f47a0
--- /dev/null
+++ b/eventmesh-connector-plugin/eventmesh-connector-wecom/src/test/java/org/apache/eventmesh/connector/wecom/source/WecomSourceConnectorTest.java
@@ -0,0 +1,90 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.connector.wecom.source;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.OutputStream;
+import java.lang.reflect.Field;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Properties;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+
+import com.sun.net.httpserver.HttpServer;
+
+/**
+ * Hermetic unit test: WecomSourceConnector lazily boots its webhook endpoint on first poll; a native wecom
+ * callback payload POSTed to the hook surfaces via poll() as a CloudEvent carrying the platform
+ * message id / subject mapping.
+ */
+class WecomSourceConnectorTest {
+
+ private static final String BODY = "{\"msgid\":\"w-1\",\"event_type\":\"message\",\"from\":{\"user_id\":\"u-1\"}}";
+
+ private int hookPort(WecomSourceConnector source) throws Exception {
+ Field f = WecomSourceConnector.class.getDeclaredField("server");
+ f.setAccessible(true);
+ HttpServer s = (HttpServer) f.get(source);
+ return s.getAddress().getPort();
+ }
+
+ private WecomSourceConnector boot() {
+ WecomSourceConnector source = new WecomSourceConnector();
+ Properties props = new Properties();
+ props.setProperty("connector.port", "0");
+ props.setProperty("connector.path", "/wecom");
+ // no secret/token set -> signature checks are disabled (dev mode)
+ source.init(props);
+ return source;
+ }
+
+ @Test
+ void postedCallbackSurfacesAsEvent() throws Exception {
+ WecomSourceConnector source = boot();
+ source.poll(); // trigger lazy server start
+ int port = hookPort(source);
+
+ HttpURLConnection conn = (HttpURLConnection) new URL(
+ "http://127.0.0.1:" + port + "/wecom").openConnection();
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ conn.setRequestProperty("Content-Type",
+ BODY.startsWith("<") ? "text/xml" : "application/json");
+ try (OutputStream os = conn.getOutputStream()) {
+ os.write(BODY.getBytes(StandardCharsets.UTF_8));
+ }
+ assertTrue(conn.getResponseCode() < 400, "callback must be accepted, got "
+ + conn.getResponseCode());
+ conn.disconnect();
+
+ List<CloudEvent> events = source.poll();
+ assertEquals(1, events.size(), "callback must surface as exactly one event");
+ CloudEvent event = events.get(0);
+ assertEquals("wecom.message", event.getType());
+ assertEquals("u-1", event.getSubject());
+ assertTrue(event.getId().startsWith("wecom-"),
+ "event id must be namespaced by the platform, got: " + event.getId());
+ }
+}
diff --git a/eventmesh-runtime/build.gradle b/eventmesh-runtime/build.gradle
index 3e01b5e..bc8c417 100644
--- a/eventmesh-runtime/build.gradle
+++ b/eventmesh-runtime/build.gradle
@@ -28,6 +28,12 @@
implementation 'io.cloudevents:cloudevents-core'
implementation 'io.cloudevents:cloudevents-json-jackson'
+ // #5411: legacy SDK gRPC bridge — gRPC runtime (netty-shaded server + protobuf plumbing).
+ // Version aligns with eventmesh-common's grpcVersion (1.68.0).
+ implementation 'io.grpc:grpc-netty-shaded:1.68.0'
+ implementation 'io.grpc:grpc-protobuf:1.68.0'
+ implementation 'io.grpc:grpc-stub:1.68.0'
+
implementation 'io.opentelemetry:opentelemetry-api'
implementation 'io.netty:netty-all'
diff --git a/eventmesh-runtime/conf/eventmesh.properties b/eventmesh-runtime/conf/eventmesh.properties
index a864ebd..85c96f1 100644
--- a/eventmesh-runtime/conf/eventmesh.properties
+++ b/eventmesh-runtime/conf/eventmesh.properties
@@ -52,6 +52,10 @@
#eventmesh.http.port=10105
#eventmesh.admin.port=10106
#eventmesh.ws.port=-1
+# Legacy SDK gRPC bridge (issue #5411): serves PublisherService/ConsumerService/
+# HeartbeatService for old EventMeshGrpcProducer/EventMeshGrpcConsumer clients.
+# Opt-in: unset/-1 = disabled; set 10205 (the 1.x default) to enable.
+#eventmesh.grpc.port=-1
#eventmesh.offset.path=./data/offset
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/boot/EventMeshApplication.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/boot/EventMeshApplication.java
index 781d9b5..9f88b73 100644
--- a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/boot/EventMeshApplication.java
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/boot/EventMeshApplication.java
@@ -70,6 +70,9 @@
private org.apache.eventmesh.runtime.a2a.A2AGatewayServer a2aGateway;
private org.apache.eventmesh.runtime.a2a.EventMeshA2ATransport a2aTransport;
private org.apache.eventmesh.runtime.state.TaskStore a2aTaskStore;
+ /** #5411: optional legacy SDK gRPC bridge (Publisher/Consumer/Heartbeat on port 10205). */
+ private org.apache.eventmesh.runtime.grpc.EventMeshGrpcServer grpcBridge;
+ private int grpcPort = -1;
private org.apache.eventmesh.runtime.session.AgentRegistrar agentRegistrar;
private org.apache.eventmesh.runtime.session.Matchmaker matchmaker;
private org.apache.eventmesh.runtime.session.SessionRouter sessionRouter;
@@ -187,6 +190,21 @@
return this;
}
+ /**
+ * #5411: enable the legacy SDK gRPC compatibility bridge (PublisherService /
+ * ConsumerService / HeartbeatService on {@code port}, 0 = auto-select). Must be called
+ * before {@link #start()}; the bridge shuts down with the application.
+ */
+ public EventMeshApplication withGrpcBridge(int port) {
+ this.grpcPort = port;
+ return this;
+ }
+
+ /** #5411: the live gRPC bridge (null before {@link #start()} or when not enabled). */
+ public org.apache.eventmesh.runtime.grpc.EventMeshGrpcServer grpcBridge() {
+ return grpcBridge;
+ }
+
public EventMeshApplication(MeshStoragePlugin storage, OffsetStore offsetStore, int httpPort, int adminPort) {
// #5338: read delivery topology from system property (with documented default).
// Missing / blank -> LOCAL_STICKY_PULL (backward compatible). Unknown value -> fail-fast
@@ -365,8 +383,35 @@
log.info("A2A gateway started: port={} taskStore={} auth={}",
a2aGateway.getPort(), a2aTaskStore.getClass().getSimpleName(), "enabled");
}
- log.info("EventMeshApplication started: traffic port={} admin port={} ws port={} a2a={}",
- trafficBoundPort, adminBoundPort, wsBoundPort, a2aGateway != null);
+ // #5411: optional legacy SDK gRPC bridge, started after the core servers so the ingress
+ // it bridges onto is fully up. Pushes share the v2 delivery engine (WAL + retry/DLQ).
+ if (grpcPort >= 0) {
+ grpcBridge = new org.apache.eventmesh.runtime.grpc.EventMeshGrpcServer(
+ runtime.ingress(), grpcPort,
+ (url, body, headers) -> {
+ try {
+ java.net.HttpURLConnection conn = (java.net.HttpURLConnection) new java.net.URL(url).openConnection();
+ conn.setRequestMethod("POST");
+ conn.setDoOutput(true);
+ conn.setConnectTimeout(3_000);
+ conn.setReadTimeout(10_000);
+ for (java.util.Map.Entry<String, String> h : headers.entrySet()) {
+ conn.setRequestProperty(h.getKey(), h.getValue());
+ }
+ try (java.io.OutputStream os = conn.getOutputStream()) {
+ os.write(body);
+ }
+ return conn.getResponseCode();
+ } catch (java.io.IOException e) {
+ throw new IllegalStateException("webhook POST failed: " + e.getMessage(), e);
+ }
+ },
+ event -> event.getData() == null ? new byte[0] : event.getData().toBytes());
+ grpcBridge.start();
+ log.info("legacy gRPC bridge started: port={}", grpcBridge.port());
+ }
+ log.info("EventMeshApplication started: traffic port={} admin port={} ws port={} a2a={} grpc={}",
+ trafficBoundPort, adminBoundPort, wsBoundPort, a2aGateway != null, grpcBridge != null);
}
/** Graceful shutdown: admin → traffic → runtime (flush offsets, release storage). */
@@ -380,6 +425,14 @@
if (wsServer != null) {
wsServer.stop();
}
+ // #5411: stop the gRPC bridge before the runtime so in-flight stream pushes settle.
+ if (grpcBridge != null) {
+ try {
+ grpcBridge.stop();
+ } catch (RuntimeException e) {
+ log.warn("gRPC bridge shutdown: {}", e.toString());
+ }
+ }
// #5405: gateway first (unsubscribes its transport), then its stores.
if (a2aGateway != null) {
try {
@@ -572,6 +625,14 @@
log.info("WebSocket push transport enabled on port {}", wsPort);
}
+ // Legacy SDK gRPC bridge (optional, issue #5411): -Deventmesh.grpc.port=10205 (0 = auto,
+ // omit/negative = disabled - opt-in like the WS port; keeps the 1.x default warm).
+ int grpcPort = Integer.getInteger("eventmesh.grpc.port", -1);
+ if (grpcPort >= 0) {
+ app.withGrpcBridge(grpcPort);
+ log.info("legacy gRPC bridge enabled on port {}", grpcPort);
+ }
+
// A2A gateway (optional, issue #5405): -Deventmesh.a2a.enabled=true, port 10108 by
// default, token via -Deventmesh.a2a.token (open gateway without it — dev mode).
// Task store: local RocksDB under the same data dir by default; the Meta-backed store
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/grpc/EventMeshGrpcServer.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/grpc/EventMeshGrpcServer.java
new file mode 100644
index 0000000..93561be
--- /dev/null
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/grpc/EventMeshGrpcServer.java
@@ -0,0 +1,553 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.runtime.grpc;
+
+import org.apache.eventmesh.common.protocol.SubscriptionItem;
+import org.apache.eventmesh.common.protocol.grpc.cloudevents.CloudEvent;
+import org.apache.eventmesh.common.protocol.grpc.cloudevents.CloudEventBatch;
+import org.apache.eventmesh.common.protocol.grpc.cloudevents.ConsumerServiceGrpc;
+import org.apache.eventmesh.common.protocol.grpc.cloudevents.HeartbeatServiceGrpc;
+import org.apache.eventmesh.common.protocol.grpc.cloudevents.PublisherServiceGrpc;
+import org.apache.eventmesh.common.protocol.grpc.common.ProtocolKey;
+import org.apache.eventmesh.common.protocol.grpc.common.StatusCode;
+import org.apache.eventmesh.common.util.JsonUtils;
+import org.apache.eventmesh.runtime.delivery.AckCallback;
+import org.apache.eventmesh.runtime.delivery.CloudEventSerializer;
+import org.apache.eventmesh.runtime.delivery.HttpCaller;
+import org.apache.eventmesh.runtime.delivery.WebHookChannel;
+import org.apache.eventmesh.runtime.ingress.UniIngressService;
+import org.apache.eventmesh.runtime.push.BufferedEvent;
+import org.apache.eventmesh.runtime.push.PushService;
+import org.apache.eventmesh.runtime.subscription.DistributionMode;
+
+import java.io.IOException;
+import java.net.InetSocketAddress;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Executors;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+
+import io.grpc.Server;
+import io.grpc.ServerBuilder;
+import io.grpc.stub.StreamObserver;
+
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.google.protobuf.Empty;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * The legacy SDK gRPC protocol compatibility bridge (issue #5411): serves
+ * {@code org.apache.eventmesh.cloudevents.v1.PublisherService/ConsumerService/HeartbeatService} on
+ * the reserved port {@code eventmesh.grpc.port} (1.x default 10205), mapping everything onto the
+ * v2 ingress path - no second messaging engine, the same {@link UniIngressService} the HTTP plane
+ * uses (WAL durability, at-least-once delivery, shared retry/DLQ).
+ *
+ * <p>Opt-in like the WS port: {@code eventmesh.grpc.port} unset / 0 = disabled. Graceful shutdown
+ * with the application lifecycle.</p>
+ *
+ * <p>Mapping summary (compat table in docs/feature/protocols.md):</p>
+ * <ul>
+ * <li>publish / batchPublish / publishOneWay / batchPublishOneWay -> proto CloudEvent mapped to
+ * v2 and persisted via {@code UniIngressService.publish} (topic = proto {@code subject}).</li>
+ * <li>requestReply -> the v2 request/reply correlation ({@code UniIngressService.request});
+ * the reply maps back to a proto CloudEvent. Legacy {@code Response} codes on errors.</li>
+ * <li>subscribe (webhook url) -> a {@link WebHookChannel} push target + v2 subscriptions
+ * (LOAD_BALANCE for the SDK's default CLUSTERING, BROADCAST for BROADCASTING).</li>
+ * <li>subscribeStream (bidi) -> a {@link GrpcStreamChannel} push target pumping the v2 push
+ * pipeline into the stream; client ACKs ride back as stream replies.</li>
+ * <li>heartbeat -> refreshes the {@link GrpcClientRegistry} TTL; the reaper unsubscribes
+ * stale clients (same model as the agent plane).</li>
+ * </ul>
+ */
+@Slf4j
+public class EventMeshGrpcServer {
+
+ /** Attribute key carrying the delivery id on stream pushes (client echoes it to ACK). */
+ public static final String DELIVERY_ID_ATTR = "emdeliveryid";
+
+ private final UniIngressService ingress;
+ private final int port;
+ private final HttpCaller httpCaller;
+ private final CloudEventSerializer serializer;
+
+ private Server server;
+ private GrpcClientRegistry clientRegistry = new GrpcClientRegistry();
+ private ScheduledExecutorService reaper;
+ /** Pending stream deliveries: deliveryId -> ACK callback (fired on client stream ACK). */
+ private final Map<String, AckCallback> pendingStreamAcks = new ConcurrentHashMap<>();
+ /** Per-client stream channel (latest stream wins), for push targeting. */
+ private final Map<String, GrpcStreamChannel> streamChannels = new ConcurrentHashMap<>();
+
+ public EventMeshGrpcServer(UniIngressService ingress, int port,
+ HttpCaller httpCaller, CloudEventSerializer serializer) {
+ this.ingress = ingress;
+ this.port = port;
+ this.httpCaller = httpCaller;
+ this.serializer = serializer;
+ }
+
+ /** Bind and start serving. Returns the actual bound port. */
+ public int start() throws IOException {
+ server = ServerBuilder.forPort(port)
+ .addService(new PublisherImpl())
+ .addService(new ConsumerImpl())
+ .addService(new HeartbeatImpl())
+ .build()
+ .start();
+ // Eviction reaper: same TTL model as the agent plane; unsubscribe dead gRPC clients.
+ clientRegistry.addEvictionListener(clientId -> {
+ ingress.unsubscribeByClient(clientId);
+ GrpcStreamChannel channel = streamChannels.remove(clientId);
+ if (channel != null) {
+ channel.close();
+ }
+ });
+ reaper = Executors.newSingleThreadScheduledExecutor(r -> {
+ Thread t = new Thread(r, "em-grpc-reaper");
+ t.setDaemon(true);
+ return t;
+ });
+ reaper.scheduleAtFixedRate(clientRegistry::reapStale, 30, 30, TimeUnit.SECONDS);
+ int bound = server.getPort();
+ log.info("legacy gRPC bridge started on port {} ({} clients capacity)", bound,
+ InetSocketAddress.createUnresolved("localhost", bound).getPort());
+ return bound;
+ }
+
+ /** Graceful shutdown with the application lifecycle. */
+ public void stop() {
+ if (reaper != null) {
+ reaper.shutdownNow();
+ }
+ if (server != null) {
+ server.shutdown();
+ try {
+ if (!server.awaitTermination(5, TimeUnit.SECONDS)) {
+ server.shutdownNow();
+ }
+ } catch (InterruptedException e) {
+ server.shutdownNow();
+ Thread.currentThread().interrupt();
+ }
+ }
+ log.info("legacy gRPC bridge stopped");
+ }
+
+ public int port() {
+ return server == null ? -1 : server.getPort();
+ }
+
+ // ------------------------------------------------------------------
+ // PublisherService
+ // ------------------------------------------------------------------
+
+ private final class PublisherImpl extends PublisherServiceGrpc.PublisherServiceImplBase {
+
+ @Override
+ public void publish(CloudEvent request, StreamObserver<CloudEvent> responseObserver) {
+ try {
+ String topic = GrpcCloudEventMapper.topicOf(request);
+ if (topic.isEmpty()) {
+ responseObserver.onNext(GrpcCloudEventMapper.errorResponse(
+ StatusCode.EVENTMESH_PROTOCOL_BODY_ERR, "missing subject(topic)"));
+ responseObserver.onCompleted();
+ return;
+ }
+ ingress.publish(topic, GrpcCloudEventMapper.toV2(request))
+ .whenComplete((v, err) -> {
+ if (err != null) {
+ responseObserver.onNext(GrpcCloudEventMapper.errorResponse(
+ StatusCode.EVENTMESH_SEND_ASYNC_MSG_ERR, err.toString()));
+ } else {
+ responseObserver.onNext(GrpcCloudEventMapper.okResponse());
+ }
+ responseObserver.onCompleted();
+ });
+ } catch (RuntimeException e) {
+ responseObserver.onNext(GrpcCloudEventMapper.errorResponse(
+ StatusCode.EVENTMESH_RUNTIME_ERR, e.toString()));
+ responseObserver.onCompleted();
+ }
+ }
+
+ @Override
+ public void batchPublish(CloudEventBatch request, StreamObserver<CloudEvent> responseObserver) {
+ publishBatchInternal(request, responseObserver, false);
+ }
+
+ @Override
+ public void publishOneWay(CloudEvent request, StreamObserver<Empty> responseObserver) {
+ String topic = GrpcCloudEventMapper.topicOf(request);
+ if (topic.isEmpty()) {
+ responseObserver.onError(new IllegalArgumentException("missing subject(topic)"));
+ return;
+ }
+ ingress.publish(topic, GrpcCloudEventMapper.toV2(request));
+ responseObserver.onNext(Empty.getDefaultInstance());
+ responseObserver.onCompleted();
+ }
+
+ @Override
+ public void batchPublishOneWay(CloudEventBatch request, StreamObserver<Empty> responseObserver) {
+ publishBatchInternal(request, null, true);
+ responseObserver.onNext(Empty.getDefaultInstance());
+ responseObserver.onCompleted();
+ }
+
+ @SuppressWarnings("rawtypes")
+ private void publishBatchInternal(CloudEventBatch request,
+ StreamObserver<CloudEvent> responseObserver, boolean oneWay) {
+ try {
+ if (request.getEventsCount() == 0) {
+ if (!oneWay) {
+ responseObserver.onNext(GrpcCloudEventMapper.errorResponse(
+ StatusCode.EVENTMESH_PROTOCOL_BODY_ERR, "empty batch"));
+ responseObserver.onCompleted();
+ }
+ return;
+ }
+ String topic = GrpcCloudEventMapper.topicOf(request.getEvents(0));
+ if (topic.isEmpty()) {
+ if (!oneWay) {
+ responseObserver.onNext(GrpcCloudEventMapper.errorResponse(
+ StatusCode.EVENTMESH_PROTOCOL_BODY_ERR, "missing subject(topic)"));
+ responseObserver.onCompleted();
+ }
+ return;
+ }
+ List<io.cloudevents.CloudEvent> events = request.getEventsList().stream()
+ .map(GrpcCloudEventMapper::toV2)
+ .collect(java.util.stream.Collectors.toList());
+ CompletableFuture<Void> done = ingress.publishBatch(topic, events);
+ if (oneWay) {
+ return;
+ }
+ done.whenComplete((v, err) -> {
+ if (err != null) {
+ responseObserver.onNext(GrpcCloudEventMapper.errorResponse(
+ StatusCode.EVENTMESH_BATCH_PUBLISH_ERR, err.toString()));
+ } else {
+ responseObserver.onNext(GrpcCloudEventMapper.okResponse());
+ }
+ responseObserver.onCompleted();
+ });
+ } catch (RuntimeException e) {
+ if (!oneWay) {
+ responseObserver.onNext(GrpcCloudEventMapper.errorResponse(
+ StatusCode.EVENTMESH_RUNTIME_ERR, e.toString()));
+ responseObserver.onCompleted();
+ }
+ }
+ }
+
+ @Override
+ public void requestReply(CloudEvent request, StreamObserver<CloudEvent> responseObserver) {
+ try {
+ String topic = GrpcCloudEventMapper.topicOf(request);
+ if (topic.isEmpty()) {
+ responseObserver.onNext(GrpcCloudEventMapper.errorResponse(
+ StatusCode.EVENTMESH_PROTOCOL_BODY_ERR, "missing subject(topic)"));
+ responseObserver.onCompleted();
+ return;
+ }
+ io.cloudevents.CloudEvent reply = ingress.request(
+ topic, GrpcCloudEventMapper.toV2(request), ttlOf(request));
+ responseObserver.onNext(GrpcCloudEventMapper.toProto(reply));
+ responseObserver.onCompleted();
+ } catch (Exception e) {
+ responseObserver.onNext(GrpcCloudEventMapper.errorResponse(
+ StatusCode.EVENTMESH_REQUEST_REPLY_MSG_ERR, e.toString()));
+ responseObserver.onCompleted();
+ }
+ }
+
+ /** The legacy TTL attribute (ms) drives the request/reply timeout; default 10s like 1.x. */
+ private long ttlOf(CloudEvent request) {
+ String ttl = org.apache.eventmesh.common.protocol.grpc.common.EventMeshCloudEventUtils
+ .getTtl(request, "");
+ try {
+ return ttl.isEmpty() ? 10_000L : Long.parseLong(ttl);
+ } catch (NumberFormatException e) {
+ return 10_000L;
+ }
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // ConsumerService
+ // ------------------------------------------------------------------
+
+ private final class ConsumerImpl extends ConsumerServiceGrpc.ConsumerServiceImplBase {
+
+ @Override
+ public void subscribe(CloudEvent request, StreamObserver<CloudEvent> responseObserver) {
+ try {
+ String url = GrpcCloudEventMapper.urlOf(request);
+ if (url.isEmpty() || "grpc_stream".equals(url)) {
+ // A unary subscribe without a webhook URL is invalid - stream subs go through
+ // subscribeStream.
+ responseObserver.onNext(GrpcCloudEventMapper.errorResponse(
+ StatusCode.EVENTMESH_SUBSCRIBE_ERR, "missing url (webhook)"));
+ responseObserver.onCompleted();
+ return;
+ }
+ String clientId = subscribeAll(request, new WebHookChannel(
+ url, webhookSecret(), httpCaller, serializer));
+ responseObserver.onNext(GrpcCloudEventMapper.okResponse());
+ responseObserver.onCompleted();
+ log.info("grpc webhook subscriber: clientId={} url={}", clientId, url);
+ } catch (RuntimeException e) {
+ responseObserver.onNext(GrpcCloudEventMapper.errorResponse(
+ StatusCode.EVENTMESH_SUBSCRIBE_ERR, e.toString()));
+ responseObserver.onCompleted();
+ }
+ }
+
+ @Override
+ public void unsubscribe(CloudEvent request, StreamObserver<CloudEvent> responseObserver) {
+ try {
+ String group = GrpcCloudEventMapper.consumerGroupOf(request);
+ String env = org.apache.eventmesh.common.protocol.grpc.common.EventMeshCloudEventUtils
+ .getEnv(request, "");
+ String idc = org.apache.eventmesh.common.protocol.grpc.common.EventMeshCloudEventUtils
+ .getIdc(request, "");
+ String clientId = clientId(group, env, idc);
+ Set<String> topics = topicsOf(request);
+ int removed = 0;
+ for (String topic : topics) {
+ if (ingress.unsubscribe(topic, clientId)) {
+ removed++;
+ }
+ }
+ clientRegistry.deregister(clientId);
+ GrpcStreamChannel channel = streamChannels.remove(clientId);
+ if (channel != null) {
+ channel.close();
+ }
+ responseObserver.onNext(GrpcCloudEventMapper.okResponse());
+ responseObserver.onCompleted();
+ log.info("grpc unsubscribe: clientId={} topics={} removed={}", clientId, topics, removed);
+ } catch (RuntimeException e) {
+ responseObserver.onNext(GrpcCloudEventMapper.errorResponse(
+ StatusCode.EVENTMESH_UNSUBSCRIBE_ERR, e.toString()));
+ responseObserver.onCompleted();
+ }
+ }
+
+ /**
+ * subscribeStream (bidi): the first inbound message carries the subscription (same
+ * envelope as unary subscribe); every later message is either a reply (SUB_REPLY_MESSAGE)
+ * or an ACK ({@link #DELIVERY_ID_ATTR} echoed). Push goes through the v2 dispatcher onto
+ * the {@link GrpcStreamChannel}.
+ */
+ @Override
+ public StreamObserver<CloudEvent> subscribeStream(StreamObserver<CloudEvent> responseObserver) {
+ return new StreamObserver<CloudEvent>() {
+
+ private String clientId;
+
+ @Override
+ public void onNext(CloudEvent message) {
+ boolean isAck = message.getAttributesMap().containsKey(DELIVERY_ID_ATTR)
+ && !message.getAttributesMap().containsKey(ProtocolKey.SUB_MESSAGE_TYPE);
+ if (isAck) {
+ ackFromClient(message);
+ return;
+ }
+ if (clientId == null) {
+ // first message = the subscription envelope
+ GrpcStreamChannel channel = new GrpcStreamChannel(responseObserver);
+ clientId = subscribeAll(message, channel);
+ streamChannels.put(clientId, channel);
+ // acknowledge the subscription itself (1.x sent an ack envelope)
+ synchronized (responseObserver) {
+ responseObserver.onNext(GrpcCloudEventMapper.okResponse());
+ }
+ } else {
+ // later non-ack messages: a reply to a request/reply event routed over
+ // the stream - correlate via emcorrelationid if present.
+ String correlationId = attr(message, UniIngressService.EXT_CORRELATION_ID);
+ if (correlationId != null && !correlationId.isEmpty()) {
+ ingress.reply(correlationId, GrpcCloudEventMapper.toV2(message));
+ }
+ }
+ }
+
+ @Override
+ public void onError(Throwable t) {
+ closeStream();
+ log.info("grpc subscribeStream error: {}", t.toString());
+ }
+
+ @Override
+ public void onCompleted() {
+ closeStream();
+ }
+
+ private void closeStream() {
+ if (clientId != null) {
+ GrpcStreamChannel channel = streamChannels.remove(clientId);
+ if (channel != null) {
+ channel.close();
+ }
+ }
+ }
+ };
+ }
+
+ private String attr(CloudEvent message, String key) {
+ CloudEvent.CloudEventAttributeValue v = message.getAttributesMap().get(key);
+ return v == null ? null : v.getCeString();
+ }
+
+ /** Fire the pending stream ACK when the client echoes the delivery id back. */
+ private void ackFromClient(CloudEvent message) {
+ String deliveryId = attr(message, DELIVERY_ID_ATTR);
+ AckCallback callback = deliveryId == null ? null : pendingStreamAcks.remove(deliveryId);
+ if (callback != null) {
+ callback.ack();
+ } else {
+ // Fallback: long-polling style ACK via the ingress (delivery ids are shared).
+ ingress.ack(deliveryId);
+ }
+ }
+
+ /**
+ * Shared subscribe path: parse the subscription items, register the client + push channel,
+ * map each topic to a v2 subscription. Returns the derived clientId.
+ */
+ private String subscribeAll(CloudEvent request,
+ org.apache.eventmesh.runtime.delivery.PushChannel channel) {
+ String group = GrpcCloudEventMapper.consumerGroupOf(request);
+ String env = org.apache.eventmesh.common.protocol.grpc.common.EventMeshCloudEventUtils
+ .getEnv(request, "");
+ String idc = org.apache.eventmesh.common.protocol.grpc.common.EventMeshCloudEventUtils
+ .getIdc(request, "");
+ String clientId = clientId(group, env, idc);
+ clientRegistry.register(clientId, group);
+ ingress.registerChannel(clientId, channel);
+ for (SubscriptionItem item : itemsOf(request)) {
+ DistributionMode mode = item != null && item.getMode() != null
+ && "BROADCASTING".equals(item.getMode().getMode())
+ ? DistributionMode.BROADCAST : DistributionMode.LOAD_BALANCE;
+ ingress.subscribe(item.getTopic(), clientId, mode, null);
+ }
+ return clientId;
+ }
+
+ /** The 1.x clientId derivation: consumerGroup + env + idc (issue #5411 item 3). */
+ private String clientId(String group, String env, String idc) {
+ return group + "-" + env + "-" + idc;
+ }
+
+ private Set<String> topicsOf(CloudEvent request) {
+ Set<String> topics = new HashSet<>();
+ for (SubscriptionItem item : itemsOf(request)) {
+ topics.add(item.getTopic());
+ }
+ return topics;
+ }
+
+ private List<SubscriptionItem> itemsOf(CloudEvent request) {
+ String json = request.getTextData();
+ if (json == null || json.isEmpty()) {
+ return java.util.Collections.emptyList();
+ }
+ return JsonUtils.parseTypeReferenceObject(json,
+ new TypeReference<Set<SubscriptionItem>>() {
+ }) == null ? java.util.Collections.emptyList()
+ : new java.util.ArrayList<>(JsonUtils.parseTypeReferenceObject(json,
+ new TypeReference<Set<SubscriptionItem>>() {
+ }));
+ }
+ }
+
+ // ------------------------------------------------------------------
+ // HeartbeatService
+ // ------------------------------------------------------------------
+
+ private final class HeartbeatImpl extends HeartbeatServiceGrpc.HeartbeatServiceImplBase {
+
+ @Override
+ public void heartbeat(CloudEvent request, StreamObserver<CloudEvent> responseObserver) {
+ String group = GrpcCloudEventMapper.consumerGroupOf(request);
+ String env = org.apache.eventmesh.common.protocol.grpc.common.EventMeshCloudEventUtils
+ .getEnv(request, "");
+ String idc = org.apache.eventmesh.common.protocol.grpc.common.EventMeshCloudEventUtils
+ .getIdc(request, "");
+ String clientId = new ConsumerImpl().clientId(group, env, idc);
+ boolean known = clientRegistry.heartbeat(clientId);
+ if (!known) {
+ // 1.x answered CLIENT_RESUBSCRIBE for unknown clients - the SDK resubscribes.
+ responseObserver.onNext(GrpcCloudEventMapper.response(
+ StatusCode.CLIENT_RESUBSCRIBE.getRetCode(),
+ StatusCode.CLIENT_RESUBSCRIBE.getErrMsg()));
+ responseObserver.onCompleted();
+ return;
+ }
+ responseObserver.onNext(GrpcCloudEventMapper.okResponse());
+ responseObserver.onCompleted();
+ }
+ }
+
+ /** Webhook signing secret (fixed default; per-URL secrets are a follow-up like 1.x). */
+ private String webhookSecret() {
+ return "eventmesh-grpc-webhook";
+ }
+
+ // ---- test accessors ----
+
+ GrpcClientRegistry registryForTest() {
+ return clientRegistry;
+ }
+
+ Map<String, AckCallback> pendingStreamAcksForTest() {
+ return pendingStreamAcks;
+ }
+
+ PushService pushServiceForTest() {
+ return ingress.getPushService();
+ }
+
+ List<BufferedEvent> pollForTest(String clientId, int max, long timeoutMs) {
+ return ingress.poll(clientId, max, timeoutMs);
+ }
+
+ Map<String, GrpcStreamChannel> streamChannelsForTest() {
+ return streamChannels;
+ }
+
+ Map<String, AckCallback> streamAckRegistry() {
+ return pendingStreamAcks;
+ }
+
+ @SuppressWarnings("unused")
+ private Map<String, String> diagnosticSnapshot() {
+ Map<String, String> snap = new HashMap<>();
+ snap.put("clients", Integer.toString(clientRegistry.size()));
+ snap.put("streamChannels", Integer.toString(streamChannels.size()));
+ return snap;
+ }
+}
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/grpc/GrpcClientRegistry.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/grpc/GrpcClientRegistry.java
new file mode 100644
index 0000000..bdc58e4
--- /dev/null
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/grpc/GrpcClientRegistry.java
@@ -0,0 +1,147 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.runtime.grpc;
+
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.function.LongSupplier;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * In-memory registration + TTL table for gRPC clients (issue #5411 item 4/5): the 1.x heartbeat
+ * model - a client registers on subscribe, refreshes on every
+ * {@code HeartbeatService.heartbeat} call, and a reaper evicts clients whose last heartbeat is
+ * older than the TTL (default 3x the SDK's 30s heartbeat interval, matching the 1.x slack).
+ *
+ * <p>Eviction drops the client's v2 subscriptions so a dead gRPC consumer does not pin delivery
+ * targets forever (the dispatcher would keep buffering into its PushService slot).</p>
+ */
+@Slf4j
+public class GrpcClientRegistry {
+
+ /** Default TTL: 3x the SDK heartbeat interval (30s) - 1.x used the same slack. */
+ public static final long DEFAULT_TTL_MS = 90_000L;
+
+ /** Called with the clientId of every evicted client (unsubscribe hook, may be null in tests). */
+ public interface EvictionListener {
+
+ void onEvicted(String clientId);
+ }
+
+ private static final class Entry {
+
+ final String clientId;
+ final String consumerGroup;
+ volatile long lastHeartbeatMs;
+
+ Entry(String clientId, String consumerGroup, long now) {
+ this.clientId = clientId;
+ this.consumerGroup = consumerGroup;
+ this.lastHeartbeatMs = now;
+ }
+ }
+
+ private final long ttlMs;
+ private final LongSupplier clock;
+ private final Map<String, Entry> clients = new ConcurrentHashMap<>();
+ private final List<EvictionListener> listeners = new CopyOnWriteArrayList<>();
+
+ public GrpcClientRegistry() {
+ this(DEFAULT_TTL_MS, System::currentTimeMillis);
+ }
+
+ public GrpcClientRegistry(long ttlMs, LongSupplier clock) {
+ this.ttlMs = ttlMs;
+ this.clock = clock;
+ }
+
+ public void addEvictionListener(EvictionListener listener) {
+ listeners.add(listener);
+ }
+
+ /** Register (or refresh) a client. Idempotent. */
+ public void register(String clientId, String consumerGroup) {
+ long now = clock.getAsLong();
+ clients.compute(clientId, (id, existing) -> {
+ if (existing == null) {
+ return new Entry(id, consumerGroup, now);
+ }
+ existing.lastHeartbeatMs = now;
+ return existing;
+ });
+ }
+
+ /** Refresh the TTL of a registered client. Returns false for unknown clients. */
+ public boolean heartbeat(String clientId) {
+ Entry entry = clients.get(clientId);
+ if (entry == null) {
+ return false;
+ }
+ entry.lastHeartbeatMs = clock.getAsLong();
+ return true;
+ }
+
+ /** Deregister a client (unsubscribe / stream close). */
+ public void deregister(String clientId) {
+ clients.remove(clientId);
+ }
+
+ public boolean isRegistered(String clientId) {
+ return clients.containsKey(clientId);
+ }
+
+ public int size() {
+ return clients.size();
+ }
+
+ /**
+ * Evict clients whose last heartbeat is older than the TTL.
+ *
+ * @return the number of clients evicted (each fires the eviction listeners)
+ */
+ public int reapStale() {
+ long now = clock.getAsLong();
+ int evicted = 0;
+ for (Map.Entry<String, Entry> e : clients.entrySet()) {
+ Entry entry = e.getValue();
+ if (now - entry.lastHeartbeatMs > ttlMs) {
+ if (clients.remove(e.getKey(), entry)) {
+ evicted++;
+ log.info("grpc client evicted (heartbeat TTL): clientId={} group={}",
+ entry.clientId, entry.consumerGroup);
+ for (EvictionListener listener : listeners) {
+ try {
+ listener.onEvicted(entry.clientId);
+ } catch (RuntimeException ex) {
+ log.warn("eviction listener failed for {}: {}", entry.clientId, ex.toString());
+ }
+ }
+ }
+ }
+ }
+ return evicted;
+ }
+
+ long lastHeartbeatMs(String clientId) {
+ Entry entry = clients.get(clientId);
+ return entry == null ? -1L : entry.lastHeartbeatMs;
+ }
+}
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/grpc/GrpcCloudEventMapper.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/grpc/GrpcCloudEventMapper.java
new file mode 100644
index 0000000..b7ce4f5
--- /dev/null
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/grpc/GrpcCloudEventMapper.java
@@ -0,0 +1,212 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.runtime.grpc;
+
+import org.apache.eventmesh.common.protocol.grpc.cloudevents.CloudEvent;
+import org.apache.eventmesh.common.protocol.grpc.cloudevents.CloudEvent.CloudEventAttributeValue;
+import org.apache.eventmesh.common.protocol.grpc.common.EventMeshCloudEventUtils;
+import org.apache.eventmesh.common.protocol.grpc.common.ProtocolKey;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.time.OffsetDateTime;
+import java.util.Map;
+
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Maps the legacy SDK gRPC proto {@link CloudEvent} (org.apache.eventmesh.cloudevents.v1) to the v2
+ * runtime's {@link io.cloudevents.CloudEvent} and back (issue #5411).
+ *
+ * <p>Inbound (proto -> v2): the proto's well-known attribute keys (subject carries the topic; the
+ * {@code ProtocolKey.*} entries become CloudEvents extensions) and the oneof data field
+ * (textData / binaryData / protoData) become the event data bytes. Outbound (v2 -> proto) is the
+ * inverse, used by the subscribeStream push path so the legacy
+ * {@code EventMeshGrpcConsumer} can deserialize what it receives
+ * ({@code EventMeshCloudEventBuilder#buildMessageFromEventMeshCloudEvent} with the
+ * EVENT_MESH_MESSAGE protocol type reads textData + subject/seqnum/uniqueid attributes).</p>
+ *
+ * <p>Pure functions, no runtime state - hermetically unit-testable.</p>
+ */
+public final class GrpcCloudEventMapper {
+
+ private GrpcCloudEventMapper() {
+ }
+
+ /**
+ * proto {@link CloudEvent} -> v2 {@link io.cloudevents.CloudEvent}.
+ *
+ * <p>The proto's {@code subject} attribute carries the destination topic (see
+ * {@code EventMeshCloudEventBuilder#switchEventMeshMessage2EventMeshCloudEvent}); it is mapped
+ * onto the CloudEvents {@code subject} attribute so the v2 pipeline keeps topic routing intact.
+ * All other attributes become CloudEvents extensions (string-typed; numeric/boolean proto
+ * attribute values are flattened to their string form).</p>
+ */
+ public static io.cloudevents.CloudEvent toV2(CloudEvent proto) {
+ CloudEventBuilder builder = CloudEventBuilder.v1()
+ .withId(proto.getId())
+ .withSource(URI.create(proto.getSource().isEmpty() ? "/" : proto.getSource()))
+ .withType(proto.getType().isEmpty() ? "org.apache.eventmesh" : proto.getType());
+ if (proto.hasTextData()) {
+ builder.withData(proto.getTextData().getBytes(StandardCharsets.UTF_8));
+ } else if (proto.hasBinaryData()) {
+ builder.withData(proto.getBinaryData().toByteArray());
+ } else if (proto.hasProtoData()) {
+ builder.withData(proto.getProtoData().toByteArray());
+ }
+ for (Map.Entry<String, CloudEventAttributeValue> attr : proto.getAttributesMap().entrySet()) {
+ String key = attr.getKey();
+ String value = stringify(attr.getValue());
+ if (value == null) {
+ continue;
+ }
+ if (ProtocolKey.SUBJECT.equals(key)) {
+ builder.withSubject(value);
+ } else {
+ builder.withExtension(key, value);
+ }
+ }
+ return builder.build();
+ }
+
+ /**
+ * v2 {@link io.cloudevents.CloudEvent} -> proto {@link CloudEvent}, for the subscribeStream
+ * push path. Mirrors what the legacy 1.x runtime sent: id/source/specversion/type top-level,
+ * every attribute + extension in the attributes map, and the event data as
+ * {@code textData} (the legacy SDK's EventMeshMessage deserializer reads textData; binary
+ * payloads are surfaced through textData as UTF-8 like 1.x did for String contents).
+ */
+ public static CloudEvent toProto(io.cloudevents.CloudEvent event) {
+ CloudEvent.Builder builder = CloudEvent.newBuilder()
+ .setId(event.getId() == null ? "" : event.getId())
+ .setSource(event.getSource() == null ? "/" : event.getSource().toString())
+ .setSpecVersion(event.getSpecVersion() == null ? "1.0" : event.getSpecVersion().toString())
+ .setType(event.getType() == null ? "org.apache.eventmesh" : event.getType());
+ if (event.getSubject() != null) {
+ builder.putAttributes(ProtocolKey.SUBJECT, CloudEventAttributeValue.newBuilder()
+ .setCeString(event.getSubject()).build());
+ }
+ for (String name : event.getAttributeNames()) {
+ Object v = event.getAttribute(name);
+ if (v != null) {
+ putAttr(builder, name, v.toString());
+ }
+ }
+ for (String name : event.getExtensionNames()) {
+ Object v = event.getExtension(name);
+ if (v != null) {
+ putAttr(builder, name, v.toString());
+ }
+ }
+ if (event.getData() != null) {
+ byte[] data = event.getData().toBytes();
+ if (data.length > 0) {
+ builder.setTextData(new String(data, StandardCharsets.UTF_8));
+ }
+ }
+ return builder.build();
+ }
+
+ /**
+ * Build a legacy {@link CloudEvent} response envelope: the 1.x contract answers publish /
+ * subscribe / unsubscribe / heartbeat calls with a CloudEvent whose attributes carry
+ * {@code statuscode} / {@code responsemessage} / {@code time} (see {@code StatusCode} +
+ * {@code EventMeshCloudEventUtils#getResponseCode}). The SDK parses exactly these three keys.
+ */
+ public static CloudEvent response(String retCode, String message) {
+ return CloudEvent.newBuilder()
+ .setId("resp-" + java.util.UUID.randomUUID())
+ .setSource("/")
+ .setSpecVersion("1.0")
+ .setType("org.apache.eventmesh")
+ .putAttributes(ProtocolKey.GRPC_RESPONSE_CODE,
+ CloudEventAttributeValue.newBuilder().setCeString(retCode).build())
+ .putAttributes(ProtocolKey.GRPC_RESPONSE_MESSAGE,
+ CloudEventAttributeValue.newBuilder().setCeString(message).build())
+ .putAttributes(ProtocolKey.GRPC_RESPONSE_TIME,
+ CloudEventAttributeValue.newBuilder()
+ .setCeTimestamp(com.google.protobuf.Timestamp.newBuilder()
+ .setSeconds(System.currentTimeMillis() / 1000)
+ .build())
+ .build())
+ .build();
+ }
+
+ /** Legacy success envelope (StatusCode SUCCESS = "0"). */
+ public static CloudEvent okResponse() {
+ return response(org.apache.eventmesh.common.protocol.grpc.common.StatusCode.SUCCESS.getRetCode(),
+ org.apache.eventmesh.common.protocol.grpc.common.StatusCode.SUCCESS.getErrMsg());
+ }
+
+ /** Legacy error envelope for the given status code (+ detail appended to the stock message). */
+ public static CloudEvent errorResponse(
+ org.apache.eventmesh.common.protocol.grpc.common.StatusCode code, String detail) {
+ return response(code.getRetCode(), code.getErrMsg() + (detail == null ? "" : detail));
+ }
+
+ private static void putAttr(CloudEvent.Builder builder, String key, String value) {
+ if (key == null || value == null) {
+ return;
+ }
+ builder.putAttributes(key, CloudEventAttributeValue.newBuilder().setCeString(value).build());
+ }
+
+ private static String stringify(CloudEventAttributeValue value) {
+ if (value == null) {
+ return null;
+ }
+ if (value.hasCeString()) {
+ return value.getCeString();
+ }
+ if (value.hasCeInteger()) {
+ return Integer.toString(value.getCeInteger());
+ }
+ if (value.hasCeBoolean()) {
+ return Boolean.toString(value.getCeBoolean());
+ }
+ if (value.hasCeUri() || value.hasCeUriRef()) {
+ return value.hasCeUri() ? value.getCeUri() : value.getCeUriRef();
+ }
+ if (value.hasCeBytes()) {
+ return value.getCeBytes().toString(StandardCharsets.UTF_8);
+ }
+ if (value.hasCeTimestamp()) {
+ OffsetDateTime t = OffsetDateTime.ofInstant(
+ java.time.Instant.ofEpochSecond(value.getCeTimestamp().getSeconds(),
+ value.getCeTimestamp().getNanos()),
+ java.time.ZoneOffset.UTC);
+ return t.toString();
+ }
+ return null;
+ }
+
+ /** The destination topic of a publish/subscribe proto event ({@code subject} attribute). */
+ public static String topicOf(CloudEvent proto) {
+ return EventMeshCloudEventUtils.getSubject(proto, "");
+ }
+
+ /** The consumer group of a subscribe/heartbeat proto event. */
+ public static String consumerGroupOf(CloudEvent proto) {
+ return EventMeshCloudEventUtils.getConsumerGroup(proto, "");
+ }
+
+ /** The webhook URL of a subscribe proto event (empty for stream subscriptions). */
+ public static String urlOf(CloudEvent proto) {
+ return EventMeshCloudEventUtils.getURL(proto, "");
+ }
+}
diff --git a/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/grpc/GrpcStreamChannel.java b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/grpc/GrpcStreamChannel.java
new file mode 100644
index 0000000..734c314
--- /dev/null
+++ b/eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/grpc/GrpcStreamChannel.java
@@ -0,0 +1,94 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.runtime.grpc;
+
+import org.apache.eventmesh.common.protocol.grpc.cloudevents.CloudEvent;
+import org.apache.eventmesh.common.wire.EventMeshFrame;
+import org.apache.eventmesh.runtime.delivery.AckCallback;
+
+import java.util.concurrent.atomic.AtomicBoolean;
+
+import io.grpc.stub.StreamObserver;
+
+import lombok.extern.slf4j.Slf4j;
+
+/**
+ * The subscribeStream bridge (issue #5411 item 3): adapts one client-held bidi gRPC stream into a
+ * {@link org.apache.eventmesh.runtime.delivery.PushChannel}, so the v2 delivery pipeline
+ * (ReliableDispatcher -> channel.deliver) pushes straight into the stream, and the SDK's
+ * client-side ACK (a reply message sent back on the same stream) completes the delivery via
+ * {@link #ack(String)}.
+ *
+ * <p>This mirrors the WebSocket/SSE transport contract (buffered + ACK-tracked at-least-once,
+ * shared retry/DLQ) - it is the "same dispatcher the SSE path uses", just with a gRPC
+ * {@link StreamObserver} underneath.</p>
+ */
+@Slf4j
+public class GrpcStreamChannel implements org.apache.eventmesh.runtime.delivery.PushChannel {
+
+ private final StreamObserver<CloudEvent> sink;
+ private final AtomicBoolean open = new AtomicBoolean(true);
+
+ public GrpcStreamChannel(StreamObserver<CloudEvent> sink) {
+ this.sink = sink;
+ }
+
+ @Override
+ public void deliver(String deliveryId, EventMeshFrame event, AckCallback callback) {
+ if (!open.get()) {
+ callback.nack(new IllegalStateException("grpc stream closed"));
+ return;
+ }
+ try {
+ // Egress: Frame -> v2 CloudEvent -> legacy proto CloudEvent. The delivery id rides as
+ // the seqnum attribute: the legacy SDK replies with the SAME attributes (its reply
+ // builder copies the request's attribute map), so the ack path can correlate back.
+ io.cloudevents.CloudEvent v2 = event.toCloudEvent();
+ CloudEvent proto = GrpcCloudEventMapper.toProto(v2);
+ proto = CloudEvent.newBuilder(proto)
+ .putAttributes("emdeliveryid",
+ CloudEvent.CloudEventAttributeValue.newBuilder().setCeString(deliveryId).build())
+ .build();
+ synchronized (sink) {
+ sink.onNext(proto);
+ }
+ // NOTE: the ACK fires from the stream's inbound side (GrpcSubscriber reply or the
+ // dedicated ack attribute) - see EventMeshGrpcServer.ackFromClient. Until then the
+ // callback stays pending in the dispatcher's tracker (at-least-once: unACKed
+ // deliveries redeliver after ackTimeoutMs).
+ } catch (RuntimeException e) {
+ open.set(false);
+ log.warn("grpc stream push failed (delivery={}): {}", deliveryId, e.toString());
+ callback.nack(e);
+ }
+ }
+
+ /** Complete a pending delivery when the client ACKs on the stream. */
+ public boolean ack(String deliveryId) {
+ return true; // correlation handled by the server's AckCallback registry
+ }
+
+ public boolean isOpen() {
+ return open.get();
+ }
+
+ /** Mark the stream dead (client cancelled / errored). Buffered events will redeliver. */
+ public void close() {
+ open.set(false);
+ }
+}
diff --git a/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/grpc/GrpcCloudEventMapperTest.java b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/grpc/GrpcCloudEventMapperTest.java
new file mode 100644
index 0000000..cb7d3f3
--- /dev/null
+++ b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/grpc/GrpcCloudEventMapperTest.java
@@ -0,0 +1,129 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.runtime.grpc;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.eventmesh.common.protocol.grpc.cloudevents.CloudEvent;
+import org.apache.eventmesh.common.protocol.grpc.common.EventMeshCloudEventUtils;
+import org.apache.eventmesh.common.protocol.grpc.common.ProtocolKey;
+import org.apache.eventmesh.common.protocol.grpc.common.StatusCode;
+
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Hermetic unit tests for the proto <-> v2 CloudEvent mapping (issue #5411 item 7): every
+ * attribute flavor the legacy SDK sends must round-trip, and the response envelope must carry the
+ * exact three keys {@code EventMeshCloudEventUtils} parses on the client side.
+ */
+class GrpcCloudEventMapperTest {
+
+ @Test
+ void protoToV2MapsTopicSubjectAndData() {
+ CloudEvent proto = CloudEvent.newBuilder()
+ .setId("p-1")
+ .setSource("/")
+ .setSpecVersion("1.0")
+ .setType("org.apache.eventmesh")
+ .setTextData("hello-grpc")
+ .putAttributes(ProtocolKey.SUBJECT,
+ CloudEvent.CloudEventAttributeValue.newBuilder().setCeString("orders").build())
+ .putAttributes(ProtocolKey.SEQ_NUM,
+ CloudEvent.CloudEventAttributeValue.newBuilder().setCeString("seq-1").build())
+ .putAttributes(ProtocolKey.TTL,
+ CloudEvent.CloudEventAttributeValue.newBuilder().setCeString("10000").build())
+ .putAttributes(ProtocolKey.CONSUMERGROUP,
+ CloudEvent.CloudEventAttributeValue.newBuilder().setCeInteger(4).build())
+ .build();
+
+ io.cloudevents.CloudEvent v2 = GrpcCloudEventMapper.toV2(proto);
+ assertEquals("p-1", v2.getId());
+ assertEquals("orders", v2.getSubject(), "subject attribute (the topic) must map to CE subject");
+ assertEquals("hello-grpc", new String(v2.getData().toBytes(), StandardCharsets.UTF_8));
+ assertEquals("seq-1", v2.getExtension("seqnum"));
+ assertEquals("10000", v2.getExtension("ttl"));
+ assertEquals("4", v2.getExtension("consumergroup"), "integer attr flattens to string");
+ }
+
+ @Test
+ void protoToV2BinaryAndProtoData() {
+ CloudEvent binary = CloudEvent.newBuilder().setId("b-1").setSource("/").setType("t")
+ .setBinaryData(com.google.protobuf.ByteString.copyFrom(new byte[] {1, 2, 3})).build();
+ assertEquals(3, GrpcCloudEventMapper.toV2(binary).getData().toBytes().length);
+
+ CloudEvent protoData = CloudEvent.newBuilder().setId("pd-1").setSource("/").setType("t")
+ .setProtoData(com.google.protobuf.Any.getDefaultInstance()).build();
+ assertNotNull(GrpcCloudEventMapper.toV2(protoData).getData());
+ }
+
+ @Test
+ void v2ToProtoRoundTripsSubjectAndTextData() {
+ io.cloudevents.CloudEvent v2 = CloudEventBuilder.v1()
+ .withId("v-1").withSource(URI.create("/")).withType("org.apache.eventmesh")
+ .withSubject("orders")
+ .withData("payload".getBytes(StandardCharsets.UTF_8))
+ .withExtension("seqnum", "s-9")
+ .build();
+ CloudEvent proto = GrpcCloudEventMapper.toProto(v2);
+ assertEquals("v-1", proto.getId());
+ assertEquals("orders", EventMeshCloudEventUtils.getSubject(proto));
+ assertEquals("payload", proto.getTextData());
+ assertEquals("s-9", EventMeshCloudEventUtils.getSeqNum(proto));
+ }
+
+ @Test
+ void responseEnvelopeCarriesTheThreeSdkKeys() {
+ CloudEvent ok = GrpcCloudEventMapper.okResponse();
+ assertEquals(StatusCode.SUCCESS.getRetCode(), EventMeshCloudEventUtils.getResponseCode(ok));
+ assertEquals(StatusCode.SUCCESS.getErrMsg(), EventMeshCloudEventUtils.getResponseMessage(ok));
+ assertNotNull(EventMeshCloudEventUtils.getResponseTime(ok));
+
+ CloudEvent err = GrpcCloudEventMapper.errorResponse(
+ StatusCode.EVENTMESH_SEND_ASYNC_MSG_ERR, "boom");
+ assertEquals(StatusCode.EVENTMESH_SEND_ASYNC_MSG_ERR.getRetCode(),
+ EventMeshCloudEventUtils.getResponseCode(err));
+ assertTrue(EventMeshCloudEventUtils.getResponseMessage(err).contains("boom"));
+ }
+
+ @Test
+ void helpersExtractTopicGroupUrl() {
+ CloudEvent sub = CloudEvent.newBuilder().setId("sub-1").setSource("/").setType("t")
+ .putAttributes(ProtocolKey.SUBJECT,
+ CloudEvent.CloudEventAttributeValue.newBuilder().setCeString("t1").build())
+ .putAttributes(ProtocolKey.CONSUMERGROUP,
+ CloudEvent.CloudEventAttributeValue.newBuilder().setCeString("g1").build())
+ .putAttributes(ProtocolKey.URL,
+ CloudEvent.CloudEventAttributeValue.newBuilder().setCeString("http://cb").build())
+ .build();
+ assertEquals("t1", GrpcCloudEventMapper.topicOf(sub));
+ assertEquals("g1", GrpcCloudEventMapper.consumerGroupOf(sub));
+ assertEquals("http://cb", GrpcCloudEventMapper.urlOf(sub));
+
+ CloudEvent bare = CloudEvent.newBuilder().setId("x").setSource("/").setType("t").build();
+ assertEquals("", GrpcCloudEventMapper.topicOf(bare));
+ assertNull(null); // readability anchor
+ }
+}
diff --git a/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/grpc/GrpcLegacyBridgeIntegrationTest.java b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/grpc/GrpcLegacyBridgeIntegrationTest.java
new file mode 100644
index 0000000..cab9bea
--- /dev/null
+++ b/eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/grpc/GrpcLegacyBridgeIntegrationTest.java
@@ -0,0 +1,231 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.eventmesh.runtime.grpc;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.apache.eventmesh.client.grpc.config.EventMeshGrpcClientConfig;
+import org.apache.eventmesh.client.grpc.consumer.EventMeshGrpcConsumer;
+import org.apache.eventmesh.client.grpc.producer.EventMeshGrpcProducer;
+import org.apache.eventmesh.common.protocol.SubscriptionItem;
+import org.apache.eventmesh.common.protocol.SubscriptionMode;
+import org.apache.eventmesh.common.protocol.SubscriptionType;
+import org.apache.eventmesh.common.protocol.grpc.common.Response;
+import org.apache.eventmesh.common.protocol.grpc.common.StatusCode;
+import org.apache.eventmesh.runtime.boot.UniRuntime;
+import org.apache.eventmesh.runtime.offset.InMemoryOffsetStore;
+
+import java.net.URI;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import io.cloudevents.CloudEvent;
+import io.cloudevents.core.builder.CloudEventBuilder;
+
+/**
+ * Integration test booting the legacy gRPC bridge against the in-memory storage backend and
+ * driving it with the REAL legacy SDK clients (issue #5411 item 7: "the SDK is its own conformance
+ * suite") - {@link EventMeshGrpcProducer} publish / batch publish, and the
+ * {@link EventMeshGrpcConsumer} stream subscription receiving what was published.
+ *
+ * <p>Always on (no broker needed): the memory storage plugin boots in-process, exactly like
+ * {@code MemoryStorageE2EIntegrationTest}.</p>
+ */
+class GrpcLegacyBridgeIntegrationTest {
+
+ private UniRuntime runtime;
+ private EventMeshGrpcServer grpc;
+ private int port;
+
+ @BeforeEach
+ void boot() throws Exception {
+ runtime = new UniRuntime(new InMemoryStorage(), new InMemoryOffsetStore(), 50L, 200L, 100, 500L);
+ runtime.withStorageConfig(new java.util.Properties());
+ runtime.start();
+
+ grpc = new EventMeshGrpcServer(runtime.ingress(), 0,
+ (url, body, headers) -> 200,
+ event -> event.getData() == null ? new byte[0] : event.getData().toBytes());
+ port = grpc.start();
+ }
+
+ @AfterEach
+ void shutdown() {
+ if (grpc != null) {
+ grpc.stop();
+ }
+ if (runtime != null) {
+ runtime.shutdown();
+ }
+ }
+
+ private EventMeshGrpcClientConfig config() {
+ return EventMeshGrpcClientConfig.builder()
+ .serverAddr("127.0.0.1")
+ .serverPort(port)
+ .producerGroup("TestProducerGroup")
+ .consumerGroup("TestConsumerGroup")
+ .env("it-env")
+ .idc("it-idc")
+ .build();
+ }
+
+ @Test
+ void sdkPublishReceivesSuccessEnvelope() throws Exception {
+ try (EventMeshGrpcProducer producer = new EventMeshGrpcProducer(config())) {
+ CloudEvent event = CloudEventBuilder.v1()
+ .withId("it-1").withSource(URI.create("/it")).withType("it.event")
+ .withSubject("grpc-it-topic")
+ .withData("ping".getBytes(java.nio.charset.StandardCharsets.UTF_8))
+ .build();
+ Response resp = producer.publish(event);
+ assertNotNull(resp);
+ assertEquals(StatusCode.SUCCESS.getRetCode(), resp.getRespCode(),
+ "publish should answer the legacy SUCCESS envelope, got: " + resp);
+ }
+ }
+
+ @Test
+ void sdkBatchPublishReceivesSuccessEnvelope() throws Exception {
+ try (EventMeshGrpcProducer producer = new EventMeshGrpcProducer(config())) {
+ CloudEvent e1 = CloudEventBuilder.v1().withId("b-1").withSource(URI.create("/it"))
+ .withType("it.event").withSubject("grpc-it-batch")
+ .withData("1".getBytes(java.nio.charset.StandardCharsets.UTF_8)).build();
+ CloudEvent e2 = CloudEventBuilder.v1().withId("b-2").withSource(URI.create("/it"))
+ .withType("it.event").withSubject("grpc-it-batch")
+ .withData("2".getBytes(java.nio.charset.StandardCharsets.UTF_8)).build();
+ Response resp = producer.publish(List.of(e1, e2));
+ assertNotNull(resp);
+ assertEquals(StatusCode.SUCCESS.getRetCode(), resp.getRespCode(),
+ "batch publish should answer SUCCESS, got: " + resp);
+ }
+ }
+
+ @Test
+ void sdkStreamSubscriptionReceivesPublishedEvent() throws Exception {
+ String topic = "grpc-it-stream";
+ List<Object> received = new CopyOnWriteArrayList<>();
+ CountDownLatch got = new CountDownLatch(1);
+
+ EventMeshGrpcConsumer consumer = new EventMeshGrpcConsumer(config());
+ consumer.init();
+ consumer.registerListener(new org.apache.eventmesh.client.grpc.consumer.ReceiveMsgHook<Object>() {
+
+ @Override
+ public java.util.Optional<Object> handle(Object msg) {
+ received.add(msg);
+ got.countDown();
+ return java.util.Optional.empty();
+ }
+
+ @Override
+ public org.apache.eventmesh.common.enums.EventMeshProtocolType getProtocolType() {
+ return org.apache.eventmesh.common.enums.EventMeshProtocolType.EVENT_MESH_MESSAGE;
+ }
+ });
+ consumer.subscribe(List.of(new SubscriptionItem(topic,
+ SubscriptionMode.CLUSTERING, SubscriptionType.ASYNC)));
+
+ try (EventMeshGrpcProducer producer = new EventMeshGrpcProducer(config())) {
+ CloudEvent event = CloudEventBuilder.v1()
+ .withId("s-1").withSource(URI.create("/it")).withType("it.event")
+ .withSubject(topic)
+ .withData("stream-payload".getBytes(java.nio.charset.StandardCharsets.UTF_8))
+ .build();
+ Response resp = producer.publish(event);
+ assertNotNull(resp);
+ assertEquals(StatusCode.SUCCESS.getRetCode(), resp.getRespCode());
+ }
+
+ assertTrue(got.await(15, TimeUnit.SECONDS),
+ "stream subscriber did not receive the event within 15s");
+ assertEquals(1, received.size());
+ consumer.close();
+ }
+
+ /** Same in-memory storage fixture the other runtime ITs use (queue-per-topic). */
+ private static final class InMemoryStorage implements org.apache.eventmesh.api.storage.MeshStoragePlugin {
+
+ private final java.util.concurrent.ConcurrentHashMap<String,
+ java.util.Queue<io.cloudevents.CloudEvent>> queues = new java.util.concurrent.ConcurrentHashMap<>();
+
+ @Override
+ public void init(java.util.Properties properties) {
+ }
+
+ @Override
+ public void send(String topic, org.apache.eventmesh.common.wire.EventMeshFrame frame,
+ org.apache.eventmesh.api.SendCallback callback) {
+ io.cloudevents.CloudEvent event = frame.toCloudEvent();
+ queues.computeIfAbsent(topic, k -> new java.util.concurrent.ConcurrentLinkedQueue<>()).offer(event);
+ org.apache.eventmesh.api.SendResult r = new org.apache.eventmesh.api.SendResult();
+ r.setMessageId(event.getId());
+ r.setTopic(topic);
+ callback.onSuccess(r);
+ }
+
+ @Override
+ public java.util.List<org.apache.eventmesh.common.wire.EventMeshFrame> poll(
+ String topic, int partition, long startOffset, int maxEvents, long timeoutMs) {
+ java.util.Queue<io.cloudevents.CloudEvent> q = queues.get(topic);
+ if (q == null) {
+ return new java.util.ArrayList<>();
+ }
+ java.util.List<org.apache.eventmesh.common.wire.EventMeshFrame> out = new java.util.ArrayList<>();
+ io.cloudevents.CloudEvent e;
+ while (out.size() < maxEvents && (e = q.poll()) != null) {
+ out.add(org.apache.eventmesh.common.wire.EventMeshFrame.fromCloudEvent(e));
+ }
+ return out;
+ }
+
+ @Override
+ public void assignPartitions(String topic, java.util.List<Integer> partitions) {
+ }
+
+ @Override
+ public void commitOffset(String topic, int partition, long offset) {
+ }
+
+ @Override
+ public boolean isStarted() {
+ return true;
+ }
+
+ @Override
+ public boolean isClosed() {
+ return false;
+ }
+
+ @Override
+ public void start() {
+ }
+
+ @Override
+ public void shutdown() {
+ }
+ }
+}