This is an automated email from the ASF dual-hosted git repository. zqr10159 pushed a commit to branch feature/angular-three-signals in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
commit a9222b2a78ea5aa8aa87732de3aa46069bdcbf12 Author: Logic <[email protected]> AuthorDate: Sat Jul 11 16:24:17 2026 +0800 test(observability): cover transition release contracts --- .../log/config/GreptimeSignalInitializerTest.java | 92 +++++++++++ .../log/config/GreptimeSignalPipelineTest.java | 37 +++++ .../log/config/OtlpGrpcServerConfigTest.java | 107 ++++++++++++ .../log/controller/OtlpSignalControllerTest.java | 81 +++++++++ .../SignalWorkloadExceptionHandlerTest.java | 59 +++++++ .../controller/ThreeSignalQueryControllerTest.java | 145 ++++++++++++++++ .../log/service/SignalWorkloadGuardTest.java | 63 +++++++ .../impl/GreptimeOtlpSignalForwarderTest.java | 126 ++++++++++++++ .../impl/GreptimeThreeSignalQueryServiceTest.java | 182 +++++++++++++++++++++ .../impl/ManagerOtlpAccessTokenValidatorTest.java | 80 +++++++++ .../greptime/ResourceFilterExpressionTest.java | 58 +++++++ .../routes/log/log-manage/log-trend-time.spec.ts | 30 ++++ .../metrics/metrics-manage.component.spec.ts | 74 +++++++++ .../app/routes/observability-navigation.spec.ts | 84 ++++++++++ .../observability/signal-query-context.spec.ts | 65 ++++++++ .../signal-time-range.component.spec.ts | 47 ++++++ .../routes/trace/trace-manage.component.spec.ts | 126 ++++++++++++++ .../src/app/service/observability.service.spec.ts | 76 +++++++++ 18 files changed, 1532 insertions(+) diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/GreptimeSignalInitializerTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/GreptimeSignalInitializerTest.java new file mode 100644 index 0000000000..1054477af7 --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/GreptimeSignalInitializerTest.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.hertzbeat.log.config; + +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.util.List; +import java.util.Map; +import org.mockito.InOrder; +import org.apache.hertzbeat.warehouse.db.GreptimeSqlQueryExecutor; +import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +@ExtendWith(MockitoExtension.class) +class GreptimeSignalInitializerTest { + + @Mock + private GreptimeSqlQueryExecutor sqlQueryExecutor; + + @Mock + private RestTemplate restTemplate; + + private GreptimeSignalInitializer initializer; + + @BeforeEach + void setUp() { + initializer = new GreptimeSignalInitializer( + new GreptimeProperties(true, "127.0.0.1:4001", "http://127.0.0.1:4000", + "public", "greptime", "secret"), + sqlQueryExecutor, + restTemplate); + } + + @Test + void shouldPrepareTraceSchemaPipelineAndReadiness() { + when(sqlQueryExecutor.execute(anyString())).thenAnswer(invocation -> + invocation.getArgument(0, String.class).startsWith("SELECT 1") + ? List.of(Map.of("ready", 1)) : List.of()); + when(restTemplate.exchange(anyString(), eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class))) + .thenReturn(ResponseEntity.ok("ok")); + + initializer.initialize(); + + InOrder sqlOrder = org.mockito.Mockito.inOrder(sqlQueryExecutor); + sqlOrder.verify(sqlQueryExecutor).execute(org.mockito.ArgumentMatchers.startsWith("CREATE TABLE IF NOT EXISTS hzb_traces")); + sqlOrder.verify(sqlQueryExecutor).execute(org.mockito.ArgumentMatchers.startsWith( + "ALTER TABLE hzb_traces ADD COLUMN IF NOT EXISTS \"resource_attributes.service.namespace\"")); + sqlOrder.verify(sqlQueryExecutor).execute(org.mockito.ArgumentMatchers.startsWith( + "ALTER TABLE hzb_traces ADD COLUMN IF NOT EXISTS \"resource_attributes.deployment.environment.name\"")); + sqlOrder.verify(sqlQueryExecutor).execute("SELECT 1 AS ready"); + verify(restTemplate).exchange(eq("http://127.0.0.1:4000/v1/pipelines/hertzbeat_otlp_log_v1"), + eq(HttpMethod.POST), any(HttpEntity.class), eq(String.class)); + } + + @Test + void shouldFailStartupWhenGreptimeIsUnavailable() { + when(sqlQueryExecutor.execute(anyString())).thenThrow(new IllegalStateException("offline")); + + assertThatThrownBy(initializer::initialize) + .isInstanceOf(IllegalStateException.class) + .hasMessage("GreptimeDB is required for the three-signal release but initialization failed") + .hasCauseInstanceOf(IllegalStateException.class); + } +} diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/GreptimeSignalPipelineTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/GreptimeSignalPipelineTest.java new file mode 100644 index 0000000000..1e29119ec4 --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/GreptimeSignalPipelineTest.java @@ -0,0 +1,37 @@ +/* + * 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.hertzbeat.log.config; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +class GreptimeSignalPipelineTest { + + @Test + void shouldStoreOtlpBodyAsStringAndAttributesAsJson() throws Exception { + String pipeline = new ClassPathResource("greptime/pipelines/hertzbeat_otlp_log_v1.yaml") + .getContentAsString(StandardCharsets.UTF_8); + + assertThat(pipeline).contains("- field: body\n type: string"); + assertThat(pipeline).doesNotContain("- body\n - attributes"); + assertThat(pipeline).contains("- attributes\n - resource\n - instrumentation_scope\n type: json"); + } +} diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/OtlpGrpcServerConfigTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/OtlpGrpcServerConfigTest.java new file mode 100644 index 0000000000..41245fe391 --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/config/OtlpGrpcServerConfigTest.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.hertzbeat.log.config; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import io.grpc.ManagedChannel; +import io.grpc.Metadata; +import io.grpc.Server; +import io.grpc.ServerInterceptors; +import io.grpc.Status; +import io.grpc.StatusRuntimeException; +import io.grpc.netty.shaded.io.grpc.netty.NettyChannelBuilder; +import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; +import io.grpc.stub.MetadataUtils; +import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest; +import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceResponse; +import io.opentelemetry.proto.collector.metrics.v1.MetricsServiceGrpc; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; +import org.apache.hertzbeat.common.security.OtlpAccessTokenValidator; +import org.apache.hertzbeat.log.service.OtlpSignalForwarder; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard; +import org.apache.hertzbeat.log.service.SignalQueryRejectedException; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class OtlpGrpcServerConfigTest { + + private Server server; + private ManagedChannel channel; + + @BeforeEach + void setUp() throws Exception { + OtlpSignalForwarder forwarder = mock(OtlpSignalForwarder.class); + SignalWorkloadGuard guard = mock(SignalWorkloadGuard.class); + OtlpAccessTokenValidator validator = token -> "probe-token".equals(token) ? null : "Invalid token"; + when(guard.execute(eq(SignalWorkloadGuard.Workload.OTLP_WRITE), any())).thenAnswer(invocation -> { + Supplier<?> action = invocation.getArgument(1); + return action.get(); + }); + when(forwarder.forwardProtobuf(eq("metrics"), any())).thenReturn(new byte[0]); + server = NettyServerBuilder.forPort(0) + .addService(ServerInterceptors.intercept( + new OtlpGrpcServerConfig.MetricsService(forwarder, guard), + new OtlpGrpcServerConfig.BearerTokenInterceptor(validator))) + .build().start(); + channel = NettyChannelBuilder.forAddress("127.0.0.1", server.getPort()).usePlaintext().build(); + } + + @AfterEach + void tearDown() throws Exception { + channel.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + server.shutdownNow().awaitTermination(5, TimeUnit.SECONDS); + } + + @Test + void shouldAcceptAuthorizedOtlpGrpcMetricsRequest() { + Metadata headers = new Metadata(); + headers.put(Metadata.Key.of("authorization", Metadata.ASCII_STRING_MARSHALLER), "Bearer probe-token"); + ExportMetricsServiceResponse response = MetricsServiceGrpc.newBlockingStub(channel) + .withInterceptors(MetadataUtils.newAttachHeadersInterceptor(headers)) + .export(ExportMetricsServiceRequest.getDefaultInstance()); + + assertThat(response).isEqualTo(ExportMetricsServiceResponse.getDefaultInstance()); + } + + @Test + void shouldRejectMissingOtlpGrpcBearerToken() { + assertThatThrownBy(() -> MetricsServiceGrpc.newBlockingStub(channel) + .export(ExportMetricsServiceRequest.getDefaultInstance())) + .isInstanceOfSatisfying(StatusRuntimeException.class, + exception -> assertThat(exception.getStatus().getCode()) + .isEqualTo(Status.Code.UNAUTHENTICATED)); + } + + @Test + void shouldMapGrpcFailuresWithoutHidingClientAndCapacityErrors() { + assertThat(OtlpGrpcServerConfig.toGrpcStatus(new IllegalArgumentException("payload"))) + .isEqualTo(Status.INVALID_ARGUMENT); + assertThat(OtlpGrpcServerConfig.toGrpcStatus(new SignalQueryRejectedException("OTLP_WRITE"))) + .isEqualTo(Status.RESOURCE_EXHAUSTED); + assertThat(OtlpGrpcServerConfig.toGrpcStatus(new IllegalStateException("storage"))) + .isEqualTo(Status.UNAVAILABLE); + } +} diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/OtlpSignalControllerTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/OtlpSignalControllerTest.java new file mode 100644 index 0000000000..a3e48a14e0 --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/OtlpSignalControllerTest.java @@ -0,0 +1,81 @@ +/* + * 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.hertzbeat.log.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.apache.hertzbeat.log.service.OtlpSignalForwarder; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +/** OTLP/HTTP route contract tests. */ +@ExtendWith(MockitoExtension.class) +class OtlpSignalControllerTest { + + @Mock + private OtlpSignalForwarder signalForwarder; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup( + new OtlpSignalController(signalForwarder, new SignalWorkloadGuard())).build(); + } + + @Test + void shouldRouteAllThreeSignals() throws Exception { + when(signalForwarder.forwardHttp(any(), any(), any())) + .thenReturn(ResponseEntity.ok().contentType(MediaType.APPLICATION_JSON).body("{}".getBytes())); + + for (String signal : new String[] {"metrics", "logs", "traces"}) { + mockMvc.perform(MockMvcRequestBuilders.post("/api/otlp/v1/" + signal) + .contentType(MediaType.APPLICATION_JSON) + .content("{}")) + .andExpect(status().isOk()); + verify(signalForwarder).forwardHttp(eq(signal), any(), any(HttpHeaders.class)); + } + } + + @Test + void shouldReturnBadRequestForMalformedOtlpPayload() throws Exception { + when(signalForwarder.forwardHttp(eq("metrics"), any(), any())) + .thenThrow(new IllegalArgumentException("Malformed OTLP metrics JSON payload")); + + mockMvc.perform(MockMvcRequestBuilders.post("/api/otlp/v1/metrics") + .contentType(MediaType.APPLICATION_JSON) + .content("{")) + .andExpect(status().isBadRequest()) + .andExpect(content().string("Malformed OTLP metrics JSON payload")); + } +} diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/SignalWorkloadExceptionHandlerTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/SignalWorkloadExceptionHandlerTest.java new file mode 100644 index 0000000000..9e3a32fa6d --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/SignalWorkloadExceptionHandlerTest.java @@ -0,0 +1,59 @@ +/* + * 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.hertzbeat.log.controller; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import org.apache.hertzbeat.common.entity.dto.Message; +import org.apache.hertzbeat.log.service.SignalQueryRejectedException; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; + +/** Tests retryable overload responses. */ +class SignalWorkloadExceptionHandlerTest { + + @Test + void shouldReturnRetryableTooManyRequests() { + ResponseEntity<Message<Void>> response = new SignalWorkloadExceptionHandler() + .handleRejected(new SignalQueryRejectedException("METRICS")); + + assertEquals(HttpStatus.TOO_MANY_REQUESTS, response.getStatusCode()); + assertEquals("1", response.getHeaders().getFirst(HttpHeaders.RETRY_AFTER)); + } + + @Test + void shouldReturnBadRequestForInvalidAdvancedQuery() { + ResponseEntity<Message<Void>> response = new SignalWorkloadExceptionHandler() + .handleInvalidQuery(new IllegalArgumentException("Invalid PromQL query")); + + assertEquals(HttpStatus.BAD_REQUEST, response.getStatusCode()); + } + + @Test + void shouldTakePrecedenceOverTheManagerGlobalAdvice() { + Order order = SignalWorkloadExceptionHandler.class.getAnnotation(Order.class); + + assertNotNull(order); + assertEquals(Ordered.HIGHEST_PRECEDENCE, order.value()); + } +} diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/ThreeSignalQueryControllerTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/ThreeSignalQueryControllerTest.java new file mode 100644 index 0000000000..a98c9fdd2e --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/controller/ThreeSignalQueryControllerTest.java @@ -0,0 +1,145 @@ +/* + * 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.hertzbeat.log.controller; + +import static org.mockito.Mockito.when; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; +import java.util.Map; +import org.apache.hertzbeat.common.entity.dto.observability.OtlpMetricsConsole; +import org.apache.hertzbeat.common.entity.dto.observability.OtlpMetricsInventory; +import org.apache.hertzbeat.common.entity.dto.observability.SignalPage; +import org.apache.hertzbeat.common.entity.dto.observability.TraceDetail; +import org.apache.hertzbeat.common.entity.dto.observability.TraceListItem; +import org.apache.hertzbeat.common.entity.dto.observability.TraceOverview; +import org.apache.hertzbeat.common.entity.dto.observability.TraceSpanNode; +import org.apache.hertzbeat.log.service.ThreeSignalQueryService; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +/** Tests the entity-free public query contract. */ +@ExtendWith(MockitoExtension.class) +class ThreeSignalQueryControllerTest { + + private MockMvc mockMvc; + + @Mock + private ThreeSignalQueryService queryService; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup( + new ThreeSignalQueryController(queryService, new SignalWorkloadGuard())).build(); + } + + @Test + void shouldQueryMetricsWithoutEntityContext() throws Exception { + when(queryService.queryMetrics("http_server_duration", 1000L, 2000L, "checkout", "payments", + "prod", null, null, null, 60, null)) + .thenReturn(new OtlpMetricsConsole("http_server_duration", 1000L, 2000L, 60, List.of())); + + mockMvc.perform(MockMvcRequestBuilders.get("/api/ingestion/otlp/metrics/console") + .param("query", "http_server_duration") + .param("start", "1000") + .param("end", "2000") + .param("serviceName", "checkout") + .param("serviceNamespace", "payments") + .param("environment", "prod") + .param("step", "60")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.query").value("http_server_duration")) + .andExpect(jsonPath("$.data.series").isArray()); + } + + @Test + void shouldExposeMetricInventory() throws Exception { + when(queryService.metricInventory(1000L, 2000L, "checkout", "payments", "prod", 50)) + .thenReturn(new OtlpMetricsInventory(List.of("http_server_duration"))); + + mockMvc.perform(MockMvcRequestBuilders.get("/api/ingestion/otlp/metrics/inventory") + .param("start", "1000") + .param("end", "2000") + .param("serviceName", "checkout") + .param("serviceNamespace", "payments") + .param("environment", "prod") + .param("limit", "50")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.metricNames[0]").value("http_server_duration")); + } + + @Test + void shouldQueryTraceListAndDetail() throws Exception { + TraceListItem item = new TraceListItem("trace-1", "span-1", "checkout", "payments", "GET /cart", + 10_000_000L, "ERROR", 1000L, 1, Map.of("deployment.environment.name", "prod")); + when(queryService.queryTraces(1000L, 2000L, "trace-1", true, "checkout", "payments", "prod", + "GET /cart", 1L, 100L, 0, 20)) + .thenReturn(new SignalPage<>(List.of(item), 0, 20, 1)); + TraceSpanNode span = new TraceSpanNode("trace-1", "span-1", "", "GET /cart", "checkout", "ERROR", + "SERVER", "failed", 10_000_000L, 1000L, Map.of(), Map.of(), List.of()); + when(queryService.traceDetail("trace-1")).thenReturn(new TraceDetail(item, List.of(span))); + + mockMvc.perform(MockMvcRequestBuilders.get("/api/traces/list") + .param("start", "1000") + .param("end", "2000") + .param("traceId", "trace-1") + .param("errorOnly", "true") + .param("serviceName", "checkout") + .param("serviceNamespace", "payments") + .param("environment", "prod") + .param("operationName", "GET /cart") + .param("minDurationMs", "1") + .param("maxDurationMs", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.content[0].traceId").value("trace-1")); + + mockMvc.perform(MockMvcRequestBuilders.get("/api/traces/trace-1")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.spans[0].spanId").value("span-1")); + } + + @Test + void shouldExposeTraceOverview() throws Exception { + when(queryService.traceOverview(1000L, 2000L, "trace-1", true, "checkout", "payments", "prod", + "GET /cart", 1L, 100L)) + .thenReturn(new TraceOverview(4, 1, 0.25, 12.5, 24.0)); + + mockMvc.perform(MockMvcRequestBuilders.get("/api/traces/stats/overview") + .param("start", "1000") + .param("end", "2000") + .param("traceId", "trace-1") + .param("errorOnly", "true") + .param("serviceName", "checkout") + .param("serviceNamespace", "payments") + .param("environment", "prod") + .param("operationName", "GET /cart") + .param("minDurationMs", "1") + .param("maxDurationMs", "100")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.totalCount").value(4)) + .andExpect(jsonPath("$.data.errorRate").value(0.25)); + } +} diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/SignalWorkloadGuardTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/SignalWorkloadGuardTest.java new file mode 100644 index 0000000000..d8c92d7625 --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/SignalWorkloadGuardTest.java @@ -0,0 +1,63 @@ +/* + * 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.hertzbeat.log.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.util.Map; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard.Limits; +import org.apache.hertzbeat.log.service.SignalWorkloadGuard.Workload; +import org.junit.jupiter.api.Test; + +/** Tests bounded signal workload isolation and recovery. */ +class SignalWorkloadGuardTest { + + @Test + void shouldRejectOverflowAndRecoverAfterPressureStops() throws Exception { + SignalWorkloadGuard guard = new SignalWorkloadGuard(Map.of(Workload.METRICS, new Limits(1, 1))); + CountDownLatch entered = new CountDownLatch(1); + CountDownLatch release = new CountDownLatch(1); + ExecutorService executor = Executors.newSingleThreadExecutor(); + try { + Future<String> active = executor.submit(() -> guard.execute(Workload.METRICS, () -> { + entered.countDown(); + try { + release.await(); + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + } + return "done"; + })); + entered.await(); + + assertThrows(SignalQueryRejectedException.class, + () -> guard.execute(Workload.METRICS, () -> "overflow")); + + release.countDown(); + assertEquals("done", active.get()); + assertEquals("recovered", guard.execute(Workload.METRICS, () -> "recovered")); + } finally { + executor.shutdownNow(); + } + } +} diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/impl/GreptimeOtlpSignalForwarderTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/impl/GreptimeOtlpSignalForwarderTest.java new file mode 100644 index 0000000000..07b4cd1815 --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/impl/GreptimeOtlpSignalForwarderTest.java @@ -0,0 +1,126 @@ +/* + * 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.hertzbeat.log.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import io.opentelemetry.proto.collector.metrics.v1.ExportMetricsServiceRequest; +import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest; +import java.nio.charset.StandardCharsets; +import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +/** Greptime native OTLP forwarding tests. */ +@ExtendWith(MockitoExtension.class) +class GreptimeOtlpSignalForwarderTest { + + @Mock + private RestTemplate restTemplate; + + @Test + void shouldConvertJsonAndForwardMetricsWithResourcePromotion() { + when(restTemplate.exchange(eq("http://127.0.0.1:4000/v1/otlp/v1/metrics"), eq(HttpMethod.POST), + any(HttpEntity.class), eq(byte[].class))).thenReturn(ResponseEntity.ok(new byte[0])); + GreptimeOtlpSignalForwarder forwarder = new GreptimeOtlpSignalForwarder( + new GreptimeProperties(true, "127.0.0.1:4001", "http://127.0.0.1:4000", + "public", "greptime", "secret"), restTemplate); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + ResponseEntity<byte[]> response = forwarder.forwardHttp("metrics", "{}".getBytes(StandardCharsets.UTF_8), + headers); + + assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); + ArgumentCaptor<HttpEntity<byte[]>> request = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).exchange(eq("http://127.0.0.1:4000/v1/otlp/v1/metrics"), eq(HttpMethod.POST), + request.capture(), eq(byte[].class)); + assertThat(request.getValue().getHeaders().getFirst("X-Greptime-OTLP-Metric-Promote-Resource-Attrs")) + .contains("service.name", "deployment.environment.name"); + assertThat(request.getValue().getBody()).isEqualTo(ExportMetricsServiceRequest.getDefaultInstance().toByteArray()); + } + + @Test + void shouldAcceptStandardHexTraceIdsInOtlpJson() throws Exception { + when(restTemplate.exchange(eq("http://127.0.0.1:4000/v1/otlp/v1/traces"), eq(HttpMethod.POST), + any(HttpEntity.class), eq(byte[].class))).thenReturn(ResponseEntity.ok(new byte[0])); + GreptimeOtlpSignalForwarder forwarder = new GreptimeOtlpSignalForwarder( + new GreptimeProperties(true, "127.0.0.1:4001", "http://127.0.0.1:4000", + "public", "greptime", "secret"), restTemplate); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + String json = "{\"resourceSpans\":[{\"scopeSpans\":[{\"spans\":[{" + + "\"traceId\":\"0123456789abcdef0123456789abcdef\"," + + "\"spanId\":\"0123456789abcdef\"," + + "\"parentSpanId\":\"fedcba9876543210\",\"name\":\"probe\"}]}]}]}"; + + forwarder.forwardHttp("traces", json.getBytes(StandardCharsets.UTF_8), headers); + + ArgumentCaptor<HttpEntity<byte[]>> request = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).exchange(eq("http://127.0.0.1:4000/v1/otlp/v1/traces"), eq(HttpMethod.POST), + request.capture(), eq(byte[].class)); + ExportTraceServiceRequest parsed = ExportTraceServiceRequest.parseFrom(request.getValue().getBody()); + assertThat(parsed.getResourceSpans(0).getScopeSpans(0).getSpans(0).getTraceId().toByteArray()) + .containsExactly(java.util.HexFormat.of().parseHex("0123456789abcdef0123456789abcdef")); + assertThat(parsed.getResourceSpans(0).getScopeSpans(0).getSpans(0).getParentSpanId().toByteArray()) + .containsExactly(java.util.HexFormat.of().parseHex("fedcba9876543210")); + } + + @Test + void shouldRejectMalformedJsonBeforeCallingGreptime() { + GreptimeOtlpSignalForwarder forwarder = new GreptimeOtlpSignalForwarder( + new GreptimeProperties(true, "127.0.0.1:4001", "http://127.0.0.1:4000", + "public", "greptime", "secret"), restTemplate); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + assertThatThrownBy(() -> forwarder.forwardHttp("metrics", "{".getBytes(StandardCharsets.UTF_8), headers)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Malformed OTLP JSON payload"); + verifyNoInteractions(restTemplate); + } + + @Test + void shouldRejectMalformedProtobufBeforeCallingGreptime() { + GreptimeOtlpSignalForwarder forwarder = new GreptimeOtlpSignalForwarder( + new GreptimeProperties(true, "127.0.0.1:4001", "http://127.0.0.1:4000", + "public", "greptime", "secret"), restTemplate); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType("application/x-protobuf")); + + assertThatThrownBy(() -> forwarder.forwardHttp("traces", new byte[] {(byte) 0xff}, headers)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Malformed OTLP traces protobuf payload"); + verifyNoInteractions(restTemplate); + } +} diff --git a/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/impl/GreptimeThreeSignalQueryServiceTest.java b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/impl/GreptimeThreeSignalQueryServiceTest.java new file mode 100644 index 0000000000..5a377ac196 --- /dev/null +++ b/hertzbeat-log/src/test/java/org/apache/hertzbeat/log/service/impl/GreptimeThreeSignalQueryServiceTest.java @@ -0,0 +1,182 @@ +/* + * 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.hertzbeat.log.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +import java.net.URI; +import java.util.List; +import java.util.Map; +import org.apache.hertzbeat.warehouse.db.GreptimeSqlQueryExecutor; +import org.apache.hertzbeat.warehouse.store.history.tsdb.greptime.GreptimeProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.web.client.RestTemplate; + +/** Unit tests for Greptime-backed entity-free queries. */ +@ExtendWith(MockitoExtension.class) +class GreptimeThreeSignalQueryServiceTest { + + @Mock + private GreptimeSqlQueryExecutor sqlQueryExecutor; + + @Mock + private RestTemplate restTemplate; + + private GreptimeThreeSignalQueryService service; + + @BeforeEach + void setUp() { + service = new GreptimeThreeSignalQueryService( + new GreptimeProperties(true, "127.0.0.1:4001", "http://127.0.0.1:4000", + "public", "greptime", "secret"), + sqlQueryExecutor, + restTemplate); + } + + @Test + void shouldBuildResourceScopedPromqlWithoutEntityLabels() { + when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) + .thenReturn(ResponseEntity.ok(Map.of("data", Map.of("result", List.of())))); + + service.queryMetrics("http_server_duration", 1000L, 2000L, "checkout", "payments", "prod", + "http.method=GET", "service_name", "sum", 60, "GET /cart"); + + ArgumentCaptor<URI> uri = ArgumentCaptor.forClass(URI.class); + ArgumentCaptor<HttpEntity<?>> request = ArgumentCaptor.forClass(HttpEntity.class); + verify(restTemplate).exchange(uri.capture(), eq(HttpMethod.GET), request.capture(), eq(Map.class)); + assertThat(uri.getValue().toString()) + .contains("service_name", "checkout", "service_namespace", "payments") + .contains("deployment_environment_name", "prod") + .doesNotContain("entityId", "entityType", "hertzbeat_entity"); + assertThat(request.getValue().getHeaders().getFirst("Authorization")).startsWith("Basic "); + } + + @Test + void shouldAllowMetricsQueryWithoutAggregation() { + when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) + .thenReturn(ResponseEntity.ok(Map.of("data", Map.of("result", List.of())))); + + var result = service.queryMetrics("http_server_duration", 1000L, 2000L, null, null, null, + null, null, null, 60, null); + + assertThat(result.query()).isEqualTo("http_server_duration"); + } + + @Test + void shouldPreserveAdvancedPromql() { + when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) + .thenReturn(ResponseEntity.ok(Map.of("data", Map.of("result", List.of())))); + + var result = service.queryMetrics("sum by (service_name) (rate(http_requests_total[5m]))", + 1000L, 2000L, null, null, null, null, null, null, 60, null); + + assertThat(result.query()).isEqualTo("sum by (service_name) (rate(http_requests_total[5m]))"); + } + + @Test + void shouldExposeInvalidPromqlAsBadRequest() { + when(restTemplate.exchange(any(URI.class), eq(HttpMethod.GET), any(HttpEntity.class), eq(Map.class))) + .thenReturn(ResponseEntity.ok(Map.of("status", "error", "error", "invalid parameter query"))); + + assertThatThrownBy(() -> service.queryMetrics("sum(", 1000L, 2000L, null, null, null, + null, null, null, 60, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid PromQL query"); + } + + @Test + void shouldBuildTraceSqlWithoutEntityOrWorkspacePredicates() { + when(sqlQueryExecutor.execute(any())).thenReturn(List.of()); + + service.queryTraces(1000L, 2000L, "trace-1", true, "checkout", "payments", "prod", + "GET /cart", 1L, 100L, 0, 20); + + ArgumentCaptor<String> sql = ArgumentCaptor.forClass(String.class); + verify(sqlQueryExecutor).execute(sql.capture()); + assertThat(sql.getValue()) + .contains("trace_id = 'trace-1'", "service_name = 'checkout'", + "\"resource_attributes.service.namespace\"", "\"resource_attributes.deployment.environment.name\"", + "CASE WHEN parent_span_id IS NULL OR parent_span_id = '' THEN service_name END", + "CASE WHEN parent_span_id IS NULL OR parent_span_id = '' THEN span_name END", + "duration_nano >= 1000000", "duration_nano <= 100000000") + .doesNotContainIgnoringCase("entity") + .doesNotContainIgnoringCase("workspace"); + } + + @Test + void shouldRejectInvalidQueryRangesBeforeAccessingStorage() { + assertThatThrownBy(() -> service.queryMetrics("up", 2000L, 1000L, null, null, null, + null, null, null, 30, null)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Query start time must not exceed end time"); + assertThatThrownBy(() -> service.queryTraces(1000L, 2000L, null, false, null, null, null, + null, 20L, 10L, 0, 20)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Minimum trace duration must not exceed maximum duration"); + + verifyNoInteractions(restTemplate, sqlQueryExecutor); + } + + @Test + void shouldBuildTraceDurationFromTheFullSpanCoverage() { + Map<String, Object> root = new java.util.HashMap<>(spanRow("trace-1", "root", "", 1000L, 1_000_000_000L)); + root.put("span_events", "[{\"name\":\"retry.scheduled\",\"time\":\"2026-07-11T00:00:00Z\"," + + "\"attributes\":{\"retry.attempt\":1}}]"); + Map<String, Object> child = spanRow("trace-1", "child", "root", 1800L, 400_000_000L); + when(sqlQueryExecutor.execute(any())).thenReturn(List.of(root, child)); + + var detail = service.traceDetail("trace-1"); + + assertThat(detail.summary().durationNanos()).isEqualTo(1_200_000_000L); + assertThat(detail.spans()).hasSize(2); + assertThat(detail.spans().getFirst().spanEvents()).singleElement().satisfies(event -> { + assertThat(event.name()).isEqualTo("retry.scheduled"); + assertThat(event.attributes()).containsEntry("retry.attempt", 1); + }); + } + + private Map<String, Object> spanRow(String traceId, String spanId, String parentSpanId, + long timestamp, long durationNanos) { + return Map.ofEntries( + Map.entry("trace_id", traceId), + Map.entry("span_id", spanId), + Map.entry("parent_span_id", parentSpanId), + Map.entry("span_name", "GET /cart"), + Map.entry("service_name", "checkout"), + Map.entry("span_status_code", "STATUS_CODE_OK"), + Map.entry("span_kind", "SPAN_KIND_SERVER"), + Map.entry("span_status_message", ""), + Map.entry("duration_nano", durationNanos), + Map.entry("timestamp", timestamp), + Map.entry("span_events", "[]")); + } +} diff --git a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/impl/ManagerOtlpAccessTokenValidatorTest.java b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/impl/ManagerOtlpAccessTokenValidatorTest.java new file mode 100644 index 0000000000..d65a447305 --- /dev/null +++ b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/impl/ManagerOtlpAccessTokenValidatorTest.java @@ -0,0 +1,80 @@ +/* + * 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.hertzbeat.manager.service.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.usthe.sureness.util.JsonWebTokenUtil; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.apache.hertzbeat.manager.service.AccountService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class ManagerOtlpAccessTokenValidatorTest { + + @Mock + private AccountService accountService; + + private ManagerOtlpAccessTokenValidator validator; + + @BeforeEach + void setUp() { + JsonWebTokenUtil.setDefaultSecretKey("CyaFv0bwq2Eik0jdrKUtsA6bx3sDJeFV643R" + + "LnfKefTjsIfJLBa2YkhEqEGtcHDTNe4CU6+98tVt4bisXQ13rbN0oxhUZR73M6EByXIO+SV5" + + "dKhaX0csgOCTlCxq20yhmUea6H6JIpSE2Rwp"); + validator = new ManagerOtlpAccessTokenValidator(accountService); + } + + @Test + void shouldRejectRefreshTokensBeforeAccountAccess() { + String token = JsonWebTokenUtil.issueJwt("admin", 3600L, new HashMap<>(Map.of("refresh", true))); + + assertThat(validator.validate(token)).isEqualTo("Refresh token is not allowed"); + verify(accountService, never()).touchTokenLastUsedTime(token); + } + + @Test + void shouldValidateManagedAccessTokenAndRecordUsage() { + String token = JsonWebTokenUtil.issueJwt("admin", 3600L, List.of("admin"), + new HashMap<>(Map.of(AccountServiceImpl.CLAIM_MANAGED, true))); + when(accountService.checkManagedTokenAccess("admin", List.of("admin"))).thenReturn(null); + + assertThat(validator.validate(token)).isNull(); + verify(accountService).checkTokenStatus(token); + verify(accountService).touchTokenLastUsedTime(token); + } + + @Test + void shouldRejectRevokedManagedTokenWithoutUpdatingUsage() { + String token = JsonWebTokenUtil.issueJwt("admin", 3600L, List.of("admin"), + new HashMap<>(Map.of(AccountServiceImpl.CLAIM_MANAGED, true))); + when(accountService.checkTokenStatus(token)).thenReturn("Token has been revoked"); + + assertThat(validator.validate(token)).isEqualTo("Token has been revoked"); + verify(accountService, never()).touchTokenLastUsedTime(token); + } +} diff --git a/hertzbeat-warehouse/src/test/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/ResourceFilterExpressionTest.java b/hertzbeat-warehouse/src/test/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/ResourceFilterExpressionTest.java new file mode 100644 index 0000000000..a4daaba443 --- /dev/null +++ b/hertzbeat-warehouse/src/test/java/org/apache/hertzbeat/warehouse/store/history/tsdb/greptime/ResourceFilterExpressionTest.java @@ -0,0 +1,58 @@ +/* + * 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.hertzbeat.warehouse.store.history.tsdb.greptime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.Test; + +class ResourceFilterExpressionTest { + + @Test + void shouldParseMultipleResourceClauses() { + var clauses = ResourceFilterExpression.parse( + "resource.cloud.region = \"ap-southeast-1\" AND service.version != dev AND pod.name EXISTS"); + + assertThat(clauses).extracting(ResourceFilterExpression.Clause::key) + .containsExactly("cloud.region", "service.version", "pod.name"); + assertThat(clauses).extracting(ResourceFilterExpression.Clause::operator) + .containsExactly(ResourceFilterExpression.Operator.EQUALS, + ResourceFilterExpression.Operator.NOT_EQUALS, ResourceFilterExpression.Operator.EXISTS); + } + + @Test + void shouldKeepAndInsideQuotedAttributeValue() { + var clauses = ResourceFilterExpression.parse( + "service.version = \"blue AND green\" AND k8s.pod.name NOT EXISTS"); + + assertThat(clauses).hasSize(2); + assertThat(clauses.getFirst().value()).isEqualTo("blue AND green"); + assertThat(clauses.getLast().operator()).isEqualTo(ResourceFilterExpression.Operator.NOT_EXISTS); + } + + @Test + void shouldRejectInvalidResourceExpression() { + assertThatThrownBy(() -> ResourceFilterExpression.parse("cloud.region ~~ prod")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid resource filter expression"); + assertThatThrownBy(() -> ResourceFilterExpression.parse("service.name = \"unterminated")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessage("Invalid resource filter expression"); + } +} diff --git a/web-app/src/app/routes/log/log-manage/log-trend-time.spec.ts b/web-app/src/app/routes/log/log-manage/log-trend-time.spec.ts new file mode 100644 index 0000000000..2459a80281 --- /dev/null +++ b/web-app/src/app/routes/log/log-manage/log-trend-time.spec.ts @@ -0,0 +1,30 @@ +/* + * 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. + */ + +import { trendBucketMillis } from './log-manage.component'; + +describe('log trend time normalization', () => { + it('converts Greptime nanosecond buckets to epoch milliseconds', () => { + expect(trendBucketMillis('1783699200000000000')).toBe(1783699200000); + }); + + it('keeps epoch milliseconds unchanged', () => { + expect(trendBucketMillis('1783699200000')).toBe(1783699200000); + }); +}); diff --git a/web-app/src/app/routes/metrics/metrics-manage.component.spec.ts b/web-app/src/app/routes/metrics/metrics-manage.component.spec.ts new file mode 100644 index 0000000000..304d49fd13 --- /dev/null +++ b/web-app/src/app/routes/metrics/metrics-manage.component.spec.ts @@ -0,0 +1,74 @@ +/* + * 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. + */ + +import { fakeAsync, tick } from '@angular/core/testing'; +import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; +import { of } from 'rxjs'; + +import { ObservabilityService } from '../../service/observability.service'; +import { MetricsManageComponent } from './metrics-manage.component'; + +describe('MetricsManageComponent', () => { + it('loads inventory, queries the selected metric, and derives the sample summary', fakeAsync(() => { + const observability = jasmine.createSpyObj<ObservabilityService>('ObservabilityService', ['metricInventory', 'queryMetrics']); + observability.metricInventory.and.returnValue(of({ code: 0, data: { metricNames: ['http.requests'] }, msg: '' })); + observability.queryMetrics.and.returnValue( + of({ + code: 0, + data: { + query: 'http.requests', + start: 1_000, + end: 2_000, + step: 15, + series: [ + { + labels: { service: 'checkout' }, + points: [ + { timestamp: 1_000, value: 4 }, + { timestamp: 2_000, value: 9 } + ] + } + ] + }, + msg: '' + }) + ); + const route = { + snapshot: { queryParamMap: convertToParamMap({ start: 1_000, end: 2_000, step: 15 }) } + } as unknown as ActivatedRoute; + const router = jasmine.createSpyObj<Router>('Router', ['navigate']); + const component = new MetricsManageComponent(observability, route, router); + + component.ngOnInit(); + tick(250); + + expect(observability.queryMetrics).toHaveBeenCalledWith( + jasmine.objectContaining({ start: 1_000, end: 2_000 }), + 'http.requests', + undefined, + undefined, + undefined, + 15 + ); + expect(component.summary).toEqual({ sampleCount: 2, minimum: 4, maximum: 9, latest: 9 }); + expect(component.rows.length).toBe(2); + expect(router.navigate).toHaveBeenCalledWith([], jasmine.objectContaining({ replaceUrl: true })); + component.ngOnDestroy(); + })); +}); diff --git a/web-app/src/app/routes/observability-navigation.spec.ts b/web-app/src/app/routes/observability-navigation.spec.ts new file mode 100644 index 0000000000..47bbd76503 --- /dev/null +++ b/web-app/src/app/routes/observability-navigation.spec.ts @@ -0,0 +1,84 @@ +/* + * 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. + */ + +import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; + +import { LogService } from '../service/log.service'; +import { ObservabilityService } from '../service/observability.service'; +import { LogManageComponent } from './log/log-manage/log-manage.component'; +import { routes } from './routes-routing.module'; +import { TraceManageComponent } from './trace/trace-manage.component'; + +describe('Observability transition navigation', () => { + it('exposes integration, metrics, logs, and traces while keeping the legacy log route', () => { + const children = routes[0].children || []; + expect(children.map(route => route.path)).toContain('observability/integration'); + expect(children.map(route => route.path)).toContain('metrics/manage'); + expect(children.map(route => route.path)).toContain('trace/manage'); + expect(children.map(route => route.path)).toContain('log'); + }); + + it('keeps time and OTLP resource context when moving from a trace to logs and metrics', () => { + const observability = jasmine.createSpyObj<ObservabilityService>('ObservabilityService', [ + 'queryTraces', + 'traceOverview', + 'traceDetail' + ]); + const route = { snapshot: { queryParamMap: convertToParamMap({}) } } as unknown as ActivatedRoute; + const router = jasmine.createSpyObj<Router>('Router', ['navigate']); + const component = new TraceManageComponent(observability, route, router); + component.selected = { + summary: { + traceId: 'trace-1', + rootSpanId: 'span-1', + serviceName: 'checkout', + serviceNamespace: 'payments', + rootSpanName: 'POST /checkout', + durationNanos: 2_000_000, + status: 'OK', + startTime: 10_000, + errorSpanCount: 0, + resourceAttributes: { 'deployment.environment.name': 'prod' } + }, + spans: [] + }; + + component.viewLogs(); + component.viewMetrics(); + + expect(router.navigate.calls.argsFor(0)[0]).toEqual(['/log/manage']); + expect(router.navigate.calls.argsFor(0)[1]?.queryParams?.['traceId']).toBe('trace-1'); + expect(router.navigate.calls.argsFor(1)[0]).toEqual(['/metrics/manage']); + expect(router.navigate.calls.argsFor(1)[1]?.queryParams?.['operationName']).toBe('POST /checkout'); + }); + + it('opens the matching trace from a log without an entity route', () => { + const logs = jasmine.createSpyObj<LogService>('LogService', ['list', 'overviewStats', 'trendStats']); + const route = { snapshot: { queryParamMap: convertToParamMap({}) } } as unknown as ActivatedRoute; + const router = jasmine.createSpyObj<Router>('Router', ['navigate']); + const component = new LogManageComponent(logs, route, router); + component.timeRange = [new Date(1_000), new Date(2_000)]; + + component.openTrace({ traceId: 'trace-2', resource: { 'service.name': 'catalog' } }); + + expect(router.navigate).toHaveBeenCalledWith(['/trace/manage'], { + queryParams: jasmine.objectContaining({ traceId: 'trace-2', serviceName: 'catalog', start: 1_000, end: 2_000 }) + }); + }); +}); diff --git a/web-app/src/app/routes/observability/signal-query-context.spec.ts b/web-app/src/app/routes/observability/signal-query-context.spec.ts new file mode 100644 index 0000000000..50a66dcec4 --- /dev/null +++ b/web-app/src/app/routes/observability/signal-query-context.spec.ts @@ -0,0 +1,65 @@ +/* + * 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. + */ + +import { ParamMap } from '@angular/router'; + +import { + createSignalTimePreset, + detectSignalTimePreset, + moveSignalTimeRangeToNow, + readSignalTimeRange, + shiftSignalTimeRange, + toSignalTimeContext +} from './signal-query-context'; + +describe('signal query context', () => { + it('reads and writes the shared start and end contract', () => { + const values = new Map([ + ['start', '1000'], + ['end', '2000'] + ]); + const params = { get: (key: string) => values.get(key) || null } as ParamMap; + + const range = readSignalTimeRange(params); + + expect(range.map(value => value.getTime())).toEqual([1_000, 2_000]); + expect(toSignalTimeContext(range)).toEqual({ start: 1_000, end: 2_000 }); + }); + + it('moves a fixed range to now without changing its duration', () => { + const range = moveSignalTimeRangeToNow([new Date(1_000), new Date(61_000)], 121_000); + + expect(range.map(value => value.getTime())).toEqual([61_000, 121_000]); + }); + + it('creates and detects a relative time preset', () => { + const range = createSignalTimePreset('1h', 7_200_000); + + expect(range.map(value => value.getTime())).toEqual([3_600_000, 7_200_000]); + expect(detectSignalTimePreset(range, 7_200_000)).toBe('1h'); + expect(detectSignalTimePreset(range, 7_300_000)).toBe('custom'); + }); + + it('moves a time window backward and clamps forward movement to now', () => { + const range = [new Date(60_000), new Date(120_000)]; + + expect(shiftSignalTimeRange(range, -1, 300_000).map(value => value.getTime())).toEqual([0, 60_000]); + expect(shiftSignalTimeRange(range, 1, 150_000).map(value => value.getTime())).toEqual([90_000, 150_000]); + }); +}); diff --git a/web-app/src/app/routes/observability/signal-time-range.component.spec.ts b/web-app/src/app/routes/observability/signal-time-range.component.spec.ts new file mode 100644 index 0000000000..973cd2ff57 --- /dev/null +++ b/web-app/src/app/routes/observability/signal-time-range.component.spec.ts @@ -0,0 +1,47 @@ +/* + * 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. + */ + +import { fakeAsync, tick } from '@angular/core/testing'; + +import { SignalTimeRangeComponent } from './signal-time-range.component'; + +describe('SignalTimeRangeComponent', () => { + it('applies a relative preset without requiring manual dates', () => { + const component = new SignalTimeRangeComponent(); + const ranges: Date[][] = []; + component.valueChange.subscribe(value => ranges.push(value)); + + component.selectPreset('15m'); + + expect(ranges.length).toBe(1); + expect(ranges[0][1].getTime() - ranges[0][0].getTime()).toBe(15 * 60_000); + }); + + it('emits automatic refresh at the configured interval', fakeAsync(() => { + const component = new SignalTimeRangeComponent(); + let refreshes = 0; + component.refresh.subscribe(() => refreshes++); + + component.setRefreshInterval(10); + tick(10_000); + component.ngOnDestroy(); + + expect(refreshes).toBe(1); + })); +}); diff --git a/web-app/src/app/routes/trace/trace-manage.component.spec.ts b/web-app/src/app/routes/trace/trace-manage.component.spec.ts new file mode 100644 index 0000000000..044a995975 --- /dev/null +++ b/web-app/src/app/routes/trace/trace-manage.component.spec.ts @@ -0,0 +1,126 @@ +/* + * 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. + */ + +import { fakeAsync, tick } from '@angular/core/testing'; +import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'; +import { of } from 'rxjs'; + +import { ObservabilityService, TraceListItem, TraceSpanNode } from '../../service/observability.service'; +import { TraceManageComponent } from './trace-manage.component'; + +describe('TraceManageComponent', () => { + function span(spanId: string, parentSpanId: string, status: string): TraceSpanNode { + return { + traceId: 'trace', + spanId, + parentSpanId, + spanName: spanId, + serviceName: 'service', + status, + spanKind: 'SPAN_KIND_INTERNAL', + statusMessage: '', + durationNanos: 1_000_000, + startTime: 1, + resourceAttributes: {}, + spanAttributes: {}, + spanEvents: [] + }; + } + + function summary(): TraceListItem { + return { + traceId: 'trace', + rootSpanId: 'root', + serviceName: 'service', + serviceNamespace: '', + rootSpanName: 'operation', + durationNanos: 1_000_000, + status: 'STATUS_CODE_OK', + startTime: 1, + errorSpanCount: 0, + resourceAttributes: {} + }; + } + + it('recognizes Greptime status codes and calculates hierarchy depth', () => { + const component = new TraceManageComponent( + jasmine.createSpyObj<ObservabilityService>('ObservabilityService', ['queryTraces', 'traceOverview', 'traceDetail']), + { snapshot: { queryParamMap: convertToParamMap({}) } } as unknown as ActivatedRoute, + jasmine.createSpyObj<Router>('Router', ['navigate']) + ); + const root = span('root', '', 'STATUS_CODE_OK'); + const child = span('child', 'root', 'STATUS_CODE_ERROR'); + const leaf = span('leaf', 'child', 'STATUS_CODE_OK'); + component.selected = { summary: summary(), spans: [root, child, leaf] }; + + expect(component.isErrorSpan(child)).toBeTrue(); + expect(component.spanStatus(child)).toBe('ERROR'); + expect(component.spanDepth(leaf)).toBe(2); + expect(component.waterfallDurationMs()).toBe(1); + }); + + it('restores bounded duration and status filters and persists them after querying', fakeAsync(() => { + const observability = jasmine.createSpyObj<ObservabilityService>('ObservabilityService', [ + 'queryTraces', + 'traceOverview', + 'traceDetail' + ]); + observability.queryTraces.and.returnValue( + of({ code: 0, data: { content: [], pageIndex: 0, pageSize: 20, totalElements: 0 }, msg: '' }) + ); + observability.traceOverview.and.returnValue( + of({ code: 0, data: { totalCount: 0, errorCount: 0, errorRate: 0, averageDurationMillis: 0, p95DurationMillis: 0 }, msg: '' }) + ); + const route = { + snapshot: { + queryParamMap: convertToParamMap({ + start: 1_000, + end: 2_000, + environment: 'staging', + errorOnly: 'true', + minDurationMs: '1', + maxDurationMs: '5000', + refresh: '10' + }) + } + } as unknown as ActivatedRoute; + const router = jasmine.createSpyObj<Router>('Router', ['navigate']); + const component = new TraceManageComponent(observability, route, router); + + component.ngOnInit(); + tick(250); + + expect(observability.queryTraces).toHaveBeenCalledWith( + jasmine.objectContaining({ start: 1_000, end: 2_000, environment: 'staging' }), + 0, + 20, + true, + 1, + 5_000 + ); + expect(router.navigate).toHaveBeenCalledWith( + [], + jasmine.objectContaining({ + queryParams: jasmine.objectContaining({ errorOnly: true, minDurationMs: 1, maxDurationMs: 5_000, refresh: 10 }), + replaceUrl: true + }) + ); + component.ngOnDestroy(); + })); +}); diff --git a/web-app/src/app/service/observability.service.spec.ts b/web-app/src/app/service/observability.service.spec.ts new file mode 100644 index 0000000000..c6b2c30714 --- /dev/null +++ b/web-app/src/app/service/observability.service.spec.ts @@ -0,0 +1,76 @@ +/* + * 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. + */ + +import { HttpClient, HttpContext } from '@angular/common/http'; +import { Observable, of } from 'rxjs'; + +import { SILENT_HTTP_ERROR } from '../core/interceptor/http-context'; +import { ObservabilityService, OtlpConnectionForm, SignalProbeResult } from './observability.service'; + +interface ObservabilityServiceInternals { + failedProbe(signal: SignalProbeResult['signal'], error: unknown): SignalProbeResult; + send(config: OtlpConnectionForm, signal: string, payload: unknown): Observable<unknown>; +} + +interface HttpRequestOptions { + context: HttpContext; +} + +describe('ObservabilityService', () => { + it('maps browser transport failures to a user-facing message key', () => { + const service = new ObservabilityService({} as HttpClient); + const result = (service as unknown as ObservabilityServiceInternals).failedProbe('metrics', new ProgressEvent('error')); + + expect(result.errorKey).toBe('observability.integration.error.network'); + expect(result.error).toBeUndefined(); + }); + + it('maps wrapped HTTP transport failures to the same message key', () => { + const service = new ObservabilityService({} as HttpClient); + const result = (service as unknown as ObservabilityServiceInternals).failedProbe('logs', { + status: 0, + error: new ProgressEvent('error') + }); + + expect(result.errorKey).toBe('observability.integration.error.network'); + }); + + it('preserves a server error message', () => { + const service = new ObservabilityService({} as HttpClient); + const result = (service as unknown as ObservabilityServiceInternals).failedProbe('traces', { + error: { msg: 'Storage unavailable' } + }); + + expect(result.error).toBe('Storage unavailable'); + expect(result.errorKey).toBeUndefined(); + }); + + it('marks probe ingestion requests as locally handled', () => { + const http = jasmine.createSpyObj<HttpClient>('HttpClient', ['post']); + http.post.and.returnValue(of({})); + const service = new ObservabilityService(http); + + (service as unknown as ObservabilityServiceInternals) + .send({ endpoint: 'http://localhost:1157', token: 'token', serviceName: 'service', environment: 'test' }, 'metrics', {}) + .subscribe(); + + const options = http.post.calls.mostRecent().args[2] as HttpRequestOptions; + expect(options.context.get(SILENT_HTTP_ERROR)).toBeTrue(); + }); +}); --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
