diff --git a/README.md b/README.md index ff706319..0f34e7a3 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ [![en](https://img.shields.io/badge/lang-en-red.svg)](https://github.com/qa-guru/allure-notifications/blob/master/README.en.md) # Allure notifications -**Allure notifications** - это библиотека, позволяющая выполнять автоматическое оповещение о результатах прохождения автотестов, которое направляется в нужный вам мессенджер (Telegram, Slack, ~~Skype~~, Email, Mattermost, Discord, Loop, Rocket.Chat, Zoho Cliq). +**Allure notifications** - это библиотека, позволяющая выполнять автоматическое оповещение о результатах прохождения автотестов, которое направляется в нужный вам мессенджер (Telegram, Slack, ~~Skype~~, Email, Mattermost, Discord, Loop, Rocket.Chat, Zoho Cliq) или TSDB InfluxDB. Languages: 🇬🇧 🇫🇷 🇷🇺 🇺🇦 🇧🇾 🇨🇳 @@ -141,6 +141,18 @@ Languages: 🇬🇧 🇫🇷 🇷🇺 🇺🇦 🇧🇾 🇨🇳 "port": 0, "username": "", "password": "" + }, + "influxdb": { + "url": "", + "org": "", + "bucket": "", + "token": "", + "measurement": "", + "tags": { + "tag1": "val1", + "tag2": "val2", + "tag3": "val3" + } } } ``` @@ -351,3 +363,18 @@ java "-DconfigFile=notifications/config.json" -jar ../allure-notifications-4.2.1 Для получения дополнительной информации об API Zoho Cliq посетите официальную документацию. ++
+ Influxdb config + Для включения отправки в InfluxDB необходимо предоставить следующие параметры конфигурации: + + Fields, которые отправляются в InfluxDB, включают поля из класса Statistic. + Timestamp берется из поля stop класса Time. +
diff --git a/allure-notifications-api/build.gradle b/allure-notifications-api/build.gradle index ff018b23..7e478236 100644 --- a/allure-notifications-api/build.gradle +++ b/allure-notifications-api/build.gradle @@ -18,6 +18,8 @@ dependencies { implementation('com.fasterxml.jackson.core:jackson-databind') implementation('com.fasterxml.jackson.dataformat:jackson-dataformat-properties') + implementation('com.influxdb:influxdb-client-java:7.3.0') + testImplementation('org.junit.jupiter:junit-jupiter:5.13.4') testRuntimeOnly('org.junit.platform:junit-platform-launcher') testImplementation('net.bytebuddy:byte-buddy:1.17.7') diff --git a/allure-notifications-api/src/main/java/guru/qa/allure/notifications/clients/ClientFactory.java b/allure-notifications-api/src/main/java/guru/qa/allure/notifications/clients/ClientFactory.java index bc37a9a4..d90d95a5 100644 --- a/allure-notifications-api/src/main/java/guru/qa/allure/notifications/clients/ClientFactory.java +++ b/allure-notifications-api/src/main/java/guru/qa/allure/notifications/clients/ClientFactory.java @@ -1,5 +1,6 @@ package guru.qa.allure.notifications.clients; +import guru.qa.allure.notifications.clients.influxdb.InfluxdbClient; import guru.qa.allure.notifications.clients.rocket.RocketChatClient; import java.util.ArrayList; import java.util.List; @@ -41,6 +42,9 @@ public static List from(Config config) { if (config.getCliq() != null) { notifiers.add(new CliqClient(config.getCliq(), config.getProxy())); } + if (config.getInfluxdb() != null && Boolean.TRUE.equals(config.getInfluxdb().getEnabled())) { + notifiers.add(new InfluxdbClient(config.getInfluxdb())); + } return notifiers; } } diff --git a/allure-notifications-api/src/main/java/guru/qa/allure/notifications/clients/influxdb/InfluxdbClient.java b/allure-notifications-api/src/main/java/guru/qa/allure/notifications/clients/influxdb/InfluxdbClient.java new file mode 100644 index 00000000..74dfd9ef --- /dev/null +++ b/allure-notifications-api/src/main/java/guru/qa/allure/notifications/clients/influxdb/InfluxdbClient.java @@ -0,0 +1,71 @@ +package guru.qa.allure.notifications.clients.influxdb; + +import com.influxdb.client.InfluxDBClient; +import com.influxdb.client.InfluxDBClientFactory; +import com.influxdb.client.WriteApiBlocking; +import com.influxdb.client.domain.WritePrecision; +import com.influxdb.client.write.Point; +import guru.qa.allure.notifications.clients.Notifier; +import guru.qa.allure.notifications.config.influxdb.Influxdb; +import guru.qa.allure.notifications.exceptions.MessagingException; +import guru.qa.allure.notifications.model.summary.Statistic; +import guru.qa.allure.notifications.model.summary.Time; +import guru.qa.allure.notifications.template.data.MessageData; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +import java.util.Arrays; + +@Slf4j +public class InfluxdbClient implements Notifier { + private final Influxdb influxdb; + + public InfluxdbClient(Influxdb influxdb) { + this.influxdb = influxdb; + } + + @Override + public void sendText(MessageData messageData) throws MessagingException { + send(messageData); + } + + @Override + public void sendPhoto(MessageData messageData, byte[] chartImage) throws MessagingException { + send(messageData); + } + + @SneakyThrows + private void send(MessageData messageData) { + try (InfluxDBClient influxDBClient = InfluxDBClientFactory.create(influxdb.getUrl(), influxdb.getToken().toCharArray(), influxdb.getOrg(), influxdb.getBucket());){ + WriteApiBlocking writeApi = influxDBClient.getWriteApiBlocking(); + Point point = buildPoint(messageData); + log.info("Sending data to InfluxDB {}", influxdb.getUrl()); + writeApi.writePoint(point); + } catch (Exception e) { + log.error("error", e); + } + } + + private Point buildPoint(MessageData messageData) { + Point point = Point.measurement(influxdb.getMeasurement()); + influxdb.getTags().forEach(point::addTag); + Statistic stat = (Statistic) messageData.getValues().get("statistic"); + Time time = (Time) messageData.getValues().get("timeData"); + + Arrays.stream(stat.getClass().getDeclaredFields()).forEach(field -> { + field.setAccessible(true); + try { + point.addField(field.getName(), (Integer) field.get(stat)); + } catch (IllegalAccessException e) { + throw new RuntimeException(e); + } + }); + + point.addField("duration", time.getDuration()); + + if (time.getStop() != null) point.time(time.getStop(), WritePrecision.MS); + + return point; + } + +} diff --git a/allure-notifications-api/src/main/java/guru/qa/allure/notifications/config/Config.java b/allure-notifications-api/src/main/java/guru/qa/allure/notifications/config/Config.java index 8805e716..b4a5eab2 100644 --- a/allure-notifications-api/src/main/java/guru/qa/allure/notifications/config/Config.java +++ b/allure-notifications-api/src/main/java/guru/qa/allure/notifications/config/Config.java @@ -3,6 +3,7 @@ import guru.qa.allure.notifications.config.base.Base; import guru.qa.allure.notifications.config.cliq.Cliq; import guru.qa.allure.notifications.config.discord.Discord; +import guru.qa.allure.notifications.config.influxdb.Influxdb; import guru.qa.allure.notifications.config.loop.Loop; import guru.qa.allure.notifications.config.mail.Mail; import guru.qa.allure.notifications.config.mattermost.Mattermost; @@ -29,4 +30,5 @@ public class Config { private RocketChat rocketChat; private Cliq cliq; private Proxy proxy; + private Influxdb influxdb; } diff --git a/allure-notifications-api/src/main/java/guru/qa/allure/notifications/config/influxdb/Influxdb.java b/allure-notifications-api/src/main/java/guru/qa/allure/notifications/config/influxdb/Influxdb.java new file mode 100644 index 00000000..0bb16dab --- /dev/null +++ b/allure-notifications-api/src/main/java/guru/qa/allure/notifications/config/influxdb/Influxdb.java @@ -0,0 +1,16 @@ +package guru.qa.allure.notifications.config.influxdb; + +import lombok.Data; + +import java.util.Map; + +@Data +public class Influxdb { + private Boolean enabled = true; + private String url; + private String token; + private String org; + private String bucket; + private String measurement; + private Map tags; +} diff --git a/allure-notifications-api/src/main/java/guru/qa/allure/notifications/model/summary/Time.java b/allure-notifications-api/src/main/java/guru/qa/allure/notifications/model/summary/Time.java index c0960b3b..77ec629b 100644 --- a/allure-notifications-api/src/main/java/guru/qa/allure/notifications/model/summary/Time.java +++ b/allure-notifications-api/src/main/java/guru/qa/allure/notifications/model/summary/Time.java @@ -12,4 +12,6 @@ public class Time { @SerializedName("duration") private Long duration; + @SerializedName("stop") + private Long stop; } diff --git a/allure-notifications-api/src/main/java/guru/qa/allure/notifications/template/data/MessageData.java b/allure-notifications-api/src/main/java/guru/qa/allure/notifications/template/data/MessageData.java index ba176793..b242fc67 100644 --- a/allure-notifications-api/src/main/java/guru/qa/allure/notifications/template/data/MessageData.java +++ b/allure-notifications-api/src/main/java/guru/qa/allure/notifications/template/data/MessageData.java @@ -44,6 +44,7 @@ public Map getValues() { data.put("customData", base.getCustomData()); data.put("time", Formatters.formatDuration(summary.getTime().getDuration(), base.getDurationFormat())); + data.put("timeData", summary.getTime()); data.put("statistic", summary.getStatistic()); data.put("suitesSummaryJson", suitesSummaryJson); diff --git a/allure-notifications-api/src/test/java/guru/qa/allure/notifications/clients/influxdb/ClientFactoryInfluxdbTest.java b/allure-notifications-api/src/test/java/guru/qa/allure/notifications/clients/influxdb/ClientFactoryInfluxdbTest.java new file mode 100644 index 00000000..33c3e2af --- /dev/null +++ b/allure-notifications-api/src/test/java/guru/qa/allure/notifications/clients/influxdb/ClientFactoryInfluxdbTest.java @@ -0,0 +1,65 @@ +package guru.qa.allure.notifications.clients.influxdb; + +import guru.qa.allure.notifications.clients.ClientFactory; +import guru.qa.allure.notifications.clients.Notifier; +import guru.qa.allure.notifications.config.Config; +import guru.qa.allure.notifications.config.influxdb.Influxdb; +import org.junit.jupiter.api.Test; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; + +class ClientFactoryInfluxdbTest { + + @Test + void shouldCreateInfluxdbClientWhenEnabled() { + Config config = new Config(); + Influxdb influxdb = buildInfluxConfig(true); + config.setInfluxdb(influxdb); + + List notifiers = ClientFactory.from(config); + + assertEquals(1, notifiers.size(), "Only InfluxdbClient expected"); + assertInstanceOf(InfluxdbClient.class, notifiers.get(0)); + } + + @Test + void shouldNotCreateInfluxdbClientWhenDisabled() { + Config config = new Config(); + Influxdb influxdb = buildInfluxConfig(false); + influxdb.setEnabled(false); + config.setInfluxdb(influxdb); + + List notifiers = ClientFactory.from(config); + + assertTrue(notifiers.isEmpty(), "No clients expected when Influxdb disabled"); + } + + @Test + void shouldNotCreateInfluxdbClientWhenConfigNull() { + Config config = new Config(); + + List notifiers = ClientFactory.from(config); + + assertTrue(notifiers.isEmpty(), "No clients expected when Influxdb config absent"); + } + + private Influxdb buildInfluxConfig(boolean enabled) { + Map tags = new HashMap<>(); + tags.put("env", "test"); + + Influxdb influxdb = new Influxdb(); + influxdb.setEnabled(enabled); + influxdb.setUrl("http://localhost:8086"); + influxdb.setToken("test-token"); + influxdb.setOrg("test-org"); + influxdb.setBucket("test-bucket"); + influxdb.setMeasurement("test-measurement"); + influxdb.setTags(tags); + return influxdb; + } +} + diff --git a/allure-notifications-api/src/test/java/guru/qa/allure/notifications/clients/influxdb/InfluxdbClientTest.java b/allure-notifications-api/src/test/java/guru/qa/allure/notifications/clients/influxdb/InfluxdbClientTest.java new file mode 100644 index 00000000..c8d94828 --- /dev/null +++ b/allure-notifications-api/src/test/java/guru/qa/allure/notifications/clients/influxdb/InfluxdbClientTest.java @@ -0,0 +1,144 @@ +package guru.qa.allure.notifications.clients.influxdb; + +import com.influxdb.client.InfluxDBClient; +import com.influxdb.client.InfluxDBClientFactory; +import com.influxdb.client.WriteApiBlocking; +import com.influxdb.client.write.Point; +import guru.qa.allure.notifications.config.influxdb.Influxdb; +import guru.qa.allure.notifications.template.data.MessageData; +import guru.qa.allure.notifications.model.summary.Statistic; +import guru.qa.allure.notifications.model.summary.Time; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.MockedStatic; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class InfluxdbClientTest { + + @Mock private InfluxDBClient influxDBClient; + @Mock private WriteApiBlocking writeApi; + @Mock private MessageData messageData; + + @Test + void sendText_shouldWritePoint() throws Exception { + Influxdb cfg = buildInfluxConfig(); + Map values = prepareMessageValues(true); // with stop + when(messageData.getValues()).thenReturn(values); + when(influxDBClient.getWriteApiBlocking()).thenReturn(writeApi); + + try (MockedStatic factoryMock = mockStatic(InfluxDBClientFactory.class)) { + factoryMock.when(() -> InfluxDBClientFactory.create(cfg.getUrl(), cfg.getToken().toCharArray(), cfg.getOrg(), cfg.getBucket())) + .thenReturn(influxDBClient); + + InfluxdbClient client = new InfluxdbClient(cfg); + assertDoesNotThrow(() -> client.sendText(messageData)); + + verify(influxDBClient).getWriteApiBlocking(); + ArgumentCaptor pointCaptor = ArgumentCaptor.forClass(Point.class); + verify(writeApi).writePoint(pointCaptor.capture()); + verify(influxDBClient).close(); + + Point p = pointCaptor.getValue(); + String lp = p.toLineProtocol(); + assertTrue(lp.startsWith(cfg.getMeasurement()+",")); + assertTrue(lp.contains("env=test")); + assertTrue(lp.contains("passed=10i")); + assertTrue(lp.contains("failed=2i")); + assertTrue(lp.contains("broken=1i")); + assertTrue(lp.contains("skipped=3i")); + assertTrue(lp.contains("unknown=0i")); + assertTrue(lp.contains("total=16i")); + assertTrue(lp.contains("duration=5000i")); + // timestamp (stop) should be present after a space + assertTrue(lp.matches(".* \\d+"), "Expected timestamp at end when stop present"); + } + } + + @Test + void sendPhoto_shouldWritePoint() throws Exception { + Influxdb cfg = buildInfluxConfig(); + Map values = prepareMessageValues(false); // stop null + when(messageData.getValues()).thenReturn(values); + when(influxDBClient.getWriteApiBlocking()).thenReturn(writeApi); + + try (MockedStatic factoryMock = mockStatic(InfluxDBClientFactory.class)) { + factoryMock.when(() -> InfluxDBClientFactory.create(cfg.getUrl(), cfg.getToken().toCharArray(), cfg.getOrg(), cfg.getBucket())) + .thenReturn(influxDBClient); + + InfluxdbClient client = new InfluxdbClient(cfg); + assertDoesNotThrow(() -> client.sendPhoto(messageData, new byte[]{})); + + ArgumentCaptor pointCaptor = ArgumentCaptor.forClass(Point.class); + verify(writeApi).writePoint(pointCaptor.capture()); + Point p = pointCaptor.getValue(); + String lp = p.toLineProtocol(); + assertFalse(lp.matches(".* \\d+"), "Expected no timestamp when stop is null"); + } + } + + @Test + void sendText_shouldSwallowExceptions() throws Exception { + Influxdb cfg = buildInfluxConfig(); + + RuntimeException failure = new RuntimeException("boom"); + + try (MockedStatic factoryMock = mockStatic(InfluxDBClientFactory.class)) { + when(influxDBClient.getWriteApiBlocking()).thenThrow(failure); + factoryMock.when(() -> InfluxDBClientFactory.create(cfg.getUrl(), cfg.getToken().toCharArray(), cfg.getOrg(), cfg.getBucket())) + .thenReturn(influxDBClient); + InfluxdbClient client = new InfluxdbClient(cfg); + assertDoesNotThrow(() -> client.sendText(messageData)); + } + } + + private Map prepareMessageValues(boolean withStop) throws Exception { + Statistic stat = new Statistic(); + setField(stat, "passed", 10); + setField(stat, "failed", 2); + setField(stat, "broken", 1); + setField(stat, "skipped", 3); + setField(stat, "unknown", 0); + setField(stat, "total", 16); + + Time time = new Time(); + setField(time, "duration", 5000L); + if (withStop) setField(time, "stop", 1700000000000L); + + Map map = new HashMap<>(); + map.put("statistic", stat); + map.put("timeData", time); + return map; + } + + private void setField(Object target, String name, Object value) throws Exception { + Field f = target.getClass().getDeclaredField(name); + f.setAccessible(true); + f.set(target, value); + } + + private Influxdb buildInfluxConfig() { + Map tags = new HashMap<>(); + tags.put("env", "test"); + + Influxdb influxdb = new Influxdb(); + influxdb.setEnabled(true); + influxdb.setUrl("http://localhost:8086"); + influxdb.setToken("token"); + influxdb.setOrg("org"); + influxdb.setBucket("bucket"); + influxdb.setMeasurement("allure_stats"); + influxdb.setTags(tags); + return influxdb; + } +}