From a33ed7d51e976cdb51feedeceacbd8aee03bb8be Mon Sep 17 00:00:00 2001 From: qqeasonchen Date: Wed, 23 Sep 2026 12:19:09 +0800 Subject: [PATCH 1/6] [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). --- .../architecture/guard/ArchitectureRules.java | 3 + .../common/protocol/grpc/package-info.java | 5 +- eventmesh-runtime/build.gradle | 6 + eventmesh-runtime/conf/eventmesh.properties | 4 + .../runtime/boot/EventMeshApplication.java | 65 +- .../runtime/grpc/EventMeshGrpcServer.java | 553 ++++++++++++++++++ .../runtime/grpc/GrpcClientRegistry.java | 147 +++++ .../runtime/grpc/GrpcCloudEventMapper.java | 212 +++++++ .../runtime/grpc/GrpcStreamChannel.java | 94 +++ 9 files changed, 1085 insertions(+), 4 deletions(-) create mode 100644 eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/grpc/EventMeshGrpcServer.java create mode 100644 eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/grpc/GrpcClientRegistry.java create mode 100644 eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/grpc/GrpcCloudEventMapper.java create mode 100644 eventmesh-runtime/src/main/java/org/apache/eventmesh/runtime/grpc/GrpcStreamChannel.java 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 2968b99d75..53f9657c51 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 @@ public static JavaClasses loadProductionClasses() { .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 210f7e6071..bc4672a524 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 @@ *

Forbidden (this package must NOT be imported by): *

*/ package org.apache.eventmesh.common.protocol.grpc; diff --git a/eventmesh-runtime/build.gradle b/eventmesh-runtime/build.gradle index 3e01b5eecb..bc8c4173e5 100644 --- a/eventmesh-runtime/build.gradle +++ b/eventmesh-runtime/build.gradle @@ -28,6 +28,12 @@ dependencies { 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 a864ebdd1e..85c96f1e5b 100644 --- a/eventmesh-runtime/conf/eventmesh.properties +++ b/eventmesh-runtime/conf/eventmesh.properties @@ -52,6 +52,10 @@ eventMesh.server.kafka.namesrvAddr=localhost:9092 #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 781d9b5459..9f88b7359c 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 @@ public class EventMeshApplication { 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 @@ public EventMeshApplication withA2aGateway(int port, 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 @@ private void startupInternal() throws Exception { 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 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 @@ public void shutdown() { 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 @@ public static void main(String[] args) throws Exception { 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 0000000000..93561be218 --- /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). + * + *

Opt-in like the WS port: {@code eventmesh.grpc.port} unset / 0 = disabled. Graceful shutdown + * with the application lifecycle.

+ * + *

Mapping summary (compat table in docs/feature/protocols.md):

+ * + */ +@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 pendingStreamAcks = new ConcurrentHashMap<>(); + /** Per-client stream channel (latest stream wins), for push targeting. */ + private final Map 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 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 responseObserver) { + publishBatchInternal(request, responseObserver, false); + } + + @Override + public void publishOneWay(CloudEvent request, StreamObserver 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 responseObserver) { + publishBatchInternal(request, null, true); + responseObserver.onNext(Empty.getDefaultInstance()); + responseObserver.onCompleted(); + } + + @SuppressWarnings("rawtypes") + private void publishBatchInternal(CloudEventBatch request, + StreamObserver 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 events = request.getEventsList().stream() + .map(GrpcCloudEventMapper::toV2) + .collect(java.util.stream.Collectors.toList()); + CompletableFuture 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 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 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 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 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 subscribeStream(StreamObserver responseObserver) { + return new StreamObserver() { + + 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 topicsOf(CloudEvent request) { + Set topics = new HashSet<>(); + for (SubscriptionItem item : itemsOf(request)) { + topics.add(item.getTopic()); + } + return topics; + } + + private List itemsOf(CloudEvent request) { + String json = request.getTextData(); + if (json == null || json.isEmpty()) { + return java.util.Collections.emptyList(); + } + return JsonUtils.parseTypeReferenceObject(json, + new TypeReference>() { + }) == null ? java.util.Collections.emptyList() + : new java.util.ArrayList<>(JsonUtils.parseTypeReferenceObject(json, + new TypeReference>() { + })); + } + } + + // ------------------------------------------------------------------ + // HeartbeatService + // ------------------------------------------------------------------ + + private final class HeartbeatImpl extends HeartbeatServiceGrpc.HeartbeatServiceImplBase { + + @Override + public void heartbeat(CloudEvent request, StreamObserver 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 pendingStreamAcksForTest() { + return pendingStreamAcks; + } + + PushService pushServiceForTest() { + return ingress.getPushService(); + } + + List pollForTest(String clientId, int max, long timeoutMs) { + return ingress.poll(clientId, max, timeoutMs); + } + + Map streamChannelsForTest() { + return streamChannels; + } + + Map streamAckRegistry() { + return pendingStreamAcks; + } + + @SuppressWarnings("unused") + private Map diagnosticSnapshot() { + Map 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 0000000000..bdc58e491c --- /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). + * + *

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).

+ */ +@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 clients = new ConcurrentHashMap<>(); + private final List 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 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 0000000000..b7ce4f5b08 --- /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). + * + *

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).

+ * + *

Pure functions, no runtime state - hermetically unit-testable.

+ */ +public final class GrpcCloudEventMapper { + + private GrpcCloudEventMapper() { + } + + /** + * proto {@link CloudEvent} -> v2 {@link io.cloudevents.CloudEvent}. + * + *

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).

+ */ + 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 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 0000000000..734c314780 --- /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)}. + * + *

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.

+ */ +@Slf4j +public class GrpcStreamChannel implements org.apache.eventmesh.runtime.delivery.PushChannel { + + private final StreamObserver sink; + private final AtomicBoolean open = new AtomicBoolean(true); + + public GrpcStreamChannel(StreamObserver 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); + } +} From ac8a7159254ae5b6a180e40ed65f1d6f1557635a Mon Sep 17 00:00:00 2001 From: qqeasonchen Date: Wed, 23 Sep 2026 12:19:12 +0800 Subject: [PATCH 2/6] [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. --- .../grpc/GrpcCloudEventMapperTest.java | 129 ++++++++++ .../grpc/GrpcLegacyBridgeIntegrationTest.java | 231 ++++++++++++++++++ 2 files changed, 360 insertions(+) create mode 100644 eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/grpc/GrpcCloudEventMapperTest.java create mode 100644 eventmesh-runtime/src/test/java/org/apache/eventmesh/runtime/grpc/GrpcLegacyBridgeIntegrationTest.java 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 0000000000..cb7d3f3757 --- /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 0000000000..cab9bea552 --- /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. + * + *

Always on (no broker needed): the memory storage plugin boots in-process, exactly like + * {@code MemoryStorageE2EIntegrationTest}.

+ */ +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 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() { + + @Override + public java.util.Optional 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> 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 poll( + String topic, int partition, long startOffset, int maxEvents, long timeoutMs) { + java.util.Queue q = queues.get(topic); + if (q == null) { + return new java.util.ArrayList<>(); + } + java.util.List 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 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() { + } + } +} From 48ce69b6d5b0714c2d9b2cb0fd598fbaaa207115 Mon Sep 17 00:00:00 2001 From: qqeasonchen Date: Wed, 23 Sep 2026 12:19:17 +0800 Subject: [PATCH 3/6] 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. --- .../canal/sink/CanalSinkConnectorTest.java | 42 +++++++ .../source/CanalSourceConnectorTest.java | 48 ++++++++ .../sink/ChatgptSinkConnectorTest.java | 99 ++++++++++++++++ .../source/ChatgptSourceConnectorTest.java | 93 +++++++++++++++ .../sink/DingtalkSinkConnectorTest.java | 99 ++++++++++++++++ .../source/DingtalkSourceConnectorTest.java | 90 +++++++++++++++ .../http/sink/HttpSinkConnectorTest.java | 99 ++++++++++++++++ .../http/source/HttpSourceConnectorTest.java | 92 +++++++++++++++ .../jdbc/sink/JdbcSinkConnectorTest.java | 42 +++++++ .../jdbc/source/JdbcSourceConnectorTest.java | 40 +++++++ .../sink/KnativeSinkConnectorTest.java | 99 ++++++++++++++++ .../source/KnativeSourceConnectorTest.java | 92 +++++++++++++++ .../lark/sink/LarkSinkConnectorTest.java | 99 ++++++++++++++++ .../lark/source/LarkSourceConnectorTest.java | 90 +++++++++++++++ .../mcp/sink/McpSinkConnectorTest.java | 106 +++++++++++++++++ .../mcp/source/McpSourceConnectorTest.java | 93 +++++++++++++++ .../sink/MongodbSinkConnectorTest.java | 42 +++++++ .../source/MongodbSourceConnectorTest.java | 40 +++++++ .../sink/OpenfunctionSinkConnectorTest.java | 98 ++++++++++++++++ .../OpenfunctionSourceConnectorTest.java | 92 +++++++++++++++ .../sink/PravegaSinkConnectorTest.java | 42 +++++++ .../source/PravegaSourceConnectorTest.java | 48 ++++++++ .../sink/PrometheusSinkConnectorTest.java | 107 ++++++++++++++++++ .../source/PrometheusSourceConnectorTest.java | 87 ++++++++++++++ .../sink/RabbitmqSinkConnectorTest.java | 42 +++++++ .../source/RabbitmqSourceConnectorTest.java | 40 +++++++ .../redis/sink/RedisSinkConnectorTest.java | 42 +++++++ .../source/RedisSourceConnectorTest.java | 40 +++++++ .../s3/sink/S3SinkConnectorTest.java | 42 +++++++ .../s3/source/S3SourceConnectorTest.java | 40 +++++++ .../slack/sink/SlackSinkConnectorTest.java | 99 ++++++++++++++++ .../source/SlackSourceConnectorTest.java | 90 +++++++++++++++ .../spring/sink/SpringSinkConnectorTest.java | 77 +++++++++++++ .../source/SpringSourceConnectorTest.java | 75 ++++++++++++ .../wechat/sink/WechatSinkConnectorTest.java | 99 ++++++++++++++++ .../source/WechatSourceConnectorTest.java | 94 +++++++++++++++ .../wecom/sink/WecomSinkConnectorTest.java | 99 ++++++++++++++++ .../source/WecomSourceConnectorTest.java | 90 +++++++++++++++ 38 files changed, 2848 insertions(+) create mode 100644 eventmesh-connector-plugin/eventmesh-connector-canal/src/test/java/org/apache/eventmesh/connector/canal/sink/CanalSinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-canal/src/test/java/org/apache/eventmesh/connector/canal/source/CanalSourceConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-chatgpt/src/test/java/org/apache/eventmesh/connector/chatgpt/sink/ChatgptSinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-chatgpt/src/test/java/org/apache/eventmesh/connector/chatgpt/source/ChatgptSourceConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-dingtalk/src/test/java/org/apache/eventmesh/connector/dingtalk/sink/DingtalkSinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-dingtalk/src/test/java/org/apache/eventmesh/connector/dingtalk/source/DingtalkSourceConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-http/src/test/java/org/apache/eventmesh/connector/http/sink/HttpSinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-http/src/test/java/org/apache/eventmesh/connector/http/source/HttpSourceConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-jdbc/src/test/java/org/apache/eventmesh/connector/jdbc/sink/JdbcSinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-jdbc/src/test/java/org/apache/eventmesh/connector/jdbc/source/JdbcSourceConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-knative/src/test/java/org/apache/eventmesh/connector/knative/sink/KnativeSinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-knative/src/test/java/org/apache/eventmesh/connector/knative/source/KnativeSourceConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-lark/src/test/java/org/apache/eventmesh/connector/lark/sink/LarkSinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-lark/src/test/java/org/apache/eventmesh/connector/lark/source/LarkSourceConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-mcp/src/test/java/org/apache/eventmesh/connector/mcp/sink/McpSinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-mcp/src/test/java/org/apache/eventmesh/connector/mcp/source/McpSourceConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-mongodb/src/test/java/org/apache/eventmesh/connector/mongodb/sink/MongodbSinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-mongodb/src/test/java/org/apache/eventmesh/connector/mongodb/source/MongodbSourceConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-openfunction/src/test/java/org/apache/eventmesh/connector/openfunction/sink/OpenfunctionSinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-openfunction/src/test/java/org/apache/eventmesh/connector/openfunction/source/OpenfunctionSourceConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-pravega/src/test/java/org/apache/eventmesh/connector/pravega/sink/PravegaSinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-pravega/src/test/java/org/apache/eventmesh/connector/pravega/source/PravegaSourceConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-prometheus/src/test/java/org/apache/eventmesh/connector/prometheus/sink/PrometheusSinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-prometheus/src/test/java/org/apache/eventmesh/connector/prometheus/source/PrometheusSourceConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-rabbitmq/src/test/java/org/apache/eventmesh/connector/rabbitmq/sink/RabbitmqSinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-rabbitmq/src/test/java/org/apache/eventmesh/connector/rabbitmq/source/RabbitmqSourceConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-redis/src/test/java/org/apache/eventmesh/connector/redis/sink/RedisSinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-redis/src/test/java/org/apache/eventmesh/connector/redis/source/RedisSourceConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-s3/src/test/java/org/apache/eventmesh/connector/s3/sink/S3SinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-s3/src/test/java/org/apache/eventmesh/connector/s3/source/S3SourceConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-slack/src/test/java/org/apache/eventmesh/connector/slack/sink/SlackSinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-slack/src/test/java/org/apache/eventmesh/connector/slack/source/SlackSourceConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-spring/src/test/java/org/apache/eventmesh/connector/spring/sink/SpringSinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-spring/src/test/java/org/apache/eventmesh/connector/spring/source/SpringSourceConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-wechat/src/test/java/org/apache/eventmesh/connector/wechat/sink/WechatSinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-wechat/src/test/java/org/apache/eventmesh/connector/wechat/source/WechatSourceConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-wecom/src/test/java/org/apache/eventmesh/connector/wecom/sink/WecomSinkConnectorTest.java create mode 100644 eventmesh-connector-plugin/eventmesh-connector-wecom/src/test/java/org/apache/eventmesh/connector/wecom/source/WecomSourceConnectorTest.java 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 0000000000..4ec45836f7 --- /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 0000000000..1f7138488f --- /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 0000000000..62ce4cbe45 --- /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 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 0000000000..b2a835cdfa --- /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 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 0000000000..99b178437a --- /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 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 0000000000..071134ad85 --- /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 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 0000000000..a92b6974de --- /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 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 0000000000..a46c8cb9a2 --- /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 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 0000000000..31e34c00ad --- /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 0000000000..bf9c90b0c5 --- /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 0000000000..df2cf31ed6 --- /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 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 0000000000..5f46bf78f7 --- /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 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 0000000000..4220be9fca --- /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 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 0000000000..107f0148ab --- /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 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 0000000000..80611b196f --- /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 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 0000000000..d4eb395a38 --- /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 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 0000000000..17b94938df --- /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 0000000000..7a51cb2010 --- /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 0000000000..cc05539b23 --- /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 bodies = new CopyOnWriteArrayList<>(); + private final List 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 0000000000..5a6929f089 --- /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 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 0000000000..8fe6e91ffd --- /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 0000000000..89d4ae60d6 --- /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 0000000000..4a76efed47 --- /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 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 0000000000..369eceaacb --- /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 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 0000000000..7ecf7c1583 --- /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 0000000000..bc984bde2a --- /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 0000000000..0f7dee0ae4 --- /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 0000000000..d8f8df04f7 --- /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 0000000000..60ad9aef9f --- /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 0000000000..c5140c3c0d --- /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 0000000000..28691e964d --- /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 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 0000000000..0f056e2229 --- /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 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 0000000000..094728152b --- /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 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 0000000000..e6dc1c5d02 --- /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 buffer = (LinkedBlockingQueue) f.get(source); + buffer.offer(event("e1")); + buffer.offer(event("e2")); + + List 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 0000000000..d4b25b6acf --- /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 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 0000000000..0d074ea7e3 --- /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 = "" + + "" + + "" + + "" + + "123"; + + 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 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 0000000000..e9753cfea1 --- /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 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 0000000000..a2f47a0e36 --- /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 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()); + } +} From 9b5805c7c77243c8353221ea7029235817e27b90 Mon Sep 17 00:00:00 2001 From: qqeasonchen Date: Wed, 23 Sep 2026 12:19:28 +0800 Subject: [PATCH 4/6] [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). --- README.md | 7 +++++-- README.zh-CN.md | 8 ++++++-- docs/feature/protocols.md | 29 ++++++++++++++++++++++++++++- docs/quickstart/configuration.md | 2 +- 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 7efd4d1503..eacb4d1a97 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ status. See [docs](docs/) for the per-capability guides. | [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 now cover all 23 plugins (hermetic: webhook sources/sinks via local HTTP, external-client plugins at contract level) | GA criteria tracked under the #5296 architecture review | | [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 @@ Status meanings: > 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 15e498f8d2..dd0f364a8e 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -67,7 +67,7 @@ Apache EventMesh 提供了丰富的能力,帮助用户轻松构建事件驱动 | [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);单元测试现已覆盖全部 23 个插件(webhook 类源/汇用本地 HTTP 密封测试,外部客户端类插件做契约级测试) | GA 标准由 #5296 架构 review 跟踪 | | [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 @@ Apache EventMesh 提供了丰富的能力,帮助用户轻松构建事件驱动 - **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/feature/protocols.md b/docs/feature/protocols.md index 20fb1c7a40..b880520dcd 100644 --- a/docs/feature/protocols.md +++ b/docs/feature/protocols.md @@ -20,7 +20,7 @@ public surface. | 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 @@ public surface. > 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 4e5f017f65..796f879188 100644 --- a/docs/quickstart/configuration.md +++ b/docs/quickstart/configuration.md @@ -78,7 +78,7 @@ Usually set via `-D` by `bin/start.sh`; override here if needed. | `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 | From b6b682255a5a59a5f5e480839b9148b1fac08bb9 Mon Sep 17 00:00:00 2001 From: qqeasonchen Date: Wed, 23 Sep 2026 14:56:57 +0800 Subject: [PATCH 5/6] 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. --- README.md | 2 +- README.zh-CN.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index eacb4d1a97..bf6dd662fc 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ status. See [docs](docs/) for the per-capability guides. | [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); unit tests now cover all 23 plugins (hermetic: webhook sources/sinks via local HTTP, external-client plugins at contract level) | 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) | diff --git a/README.zh-CN.md b/README.zh-CN.md index dd0f364a8e..b2da17d4d5 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -67,7 +67,7 @@ Apache EventMesh 提供了丰富的能力,帮助用户轻松构建事件驱动 | [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);单元测试现已覆盖全部 23 个插件(webhook 类源/汇用本地 HTTP 密封测试,外部客户端类插件做契约级测试) | 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) | From e86091a1e366631805317319ce74ca20862853ee Mon Sep 17 00:00:00 2001 From: qqeasonchen Date: Wed, 23 Sep 2026 15:52:18 +0800 Subject: [PATCH 6/6] 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 --- docs/architecture/overview.md | 8 ++++++-- docs/feature/deployment.md | 6 ++++-- docs/quickstart/getting-started.md | 3 ++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 16a01288ea..e37bb50099 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -114,7 +114,8 @@ request-reply). 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 @@ semantics of the old `MeshMessage` / `OpenMessage` clients. | --- | --- | --- | | 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 @@ summarized there: 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 1e364e2784..344abdad8e 100644 --- a/docs/feature/deployment.md +++ b/docs/feature/deployment.md @@ -120,8 +120,10 @@ Connector definitions are managed through `/admin/connectors` (CRUD) and 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/quickstart/getting-started.md b/docs/quickstart/getting-started.md index 9413b201c6..76d3886923 100644 --- a/docs/quickstart/getting-started.md +++ b/docs/quickstart/getting-started.md @@ -54,7 +54,8 @@ dependency, ready for a smoke test as-is. For a real broker add 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