[pushover] Improved exception handling (#12023)

* Improved exception handling

Signed-off-by: Christoph Weitkamp <github@christophweitkamp.de>
This commit is contained in:
Christoph Weitkamp 2022-01-16 18:07:36 +01:00 committed by GitHub
parent b0765271d3
commit 8b3bb313eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 119 additions and 210 deletions

View File

@ -31,4 +31,10 @@ public class PushoverBindingConstants {
public static final String DEFAULT_SOUND = "default";
public static final String DEFAULT_TITLE = "openHAB";
public static final String TEXT_OFFLINE_COMMUNICATION_ERROR = "@text/offline.communication-error";
public static final String TEXT_OFFLINE_CONF_ERROR_MISSING_APIKEY = "@text/offline.conf-error-missing-apikey";
public static final String TEXT_OFFLINE_CONF_ERROR_MISSING_USER = "@text/offline.conf-error-missing-user";
public static final String TEXT_OFFLINE_CONF_ERROR_UNKNOWN = "@text/offline.conf-error-unknown";
public static final String TEXT_ERROR_SKIP_SENDING_MESSAGE = "@text/error.skip-sending-message";
}

View File

@ -12,9 +12,10 @@
*/
package org.openhab.binding.pushover.internal.connection;
import static org.openhab.binding.pushover.internal.PushoverBindingConstants.*;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
@ -32,13 +33,16 @@ import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
import org.openhab.binding.pushover.internal.config.PushoverAccountConfiguration;
import org.openhab.binding.pushover.internal.dto.Sound;
import org.openhab.core.cache.ExpiringCacheMap;
import org.openhab.core.cache.ExpiringCache;
import org.openhab.core.i18n.CommunicationException;
import org.openhab.core.i18n.ConfigurationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
import com.google.gson.JsonParser;
import com.google.gson.JsonSyntaxException;
/**
* The {@link PushoverAPIConnection} is responsible for handling the connections to Pushover Messages API.
@ -48,62 +52,74 @@ import com.google.gson.JsonParser;
@NonNullByDefault
public class PushoverAPIConnection {
private static final String JSON_VALUE_ERRORS = "errors";
private static final String JSON_VALUE_RECEIPT = "receipt";
private static final String JSON_VALUE_SOUNDS = "sounds";
private static final String JSON_VALUE_STATUS = "status";
private final Logger logger = LoggerFactory.getLogger(PushoverAPIConnection.class);
private static final String VALIDATE_URL = "https://api.pushover.net/1/users/validate.json";
private static final String MESSAGE_URL = "https://api.pushover.net/1/messages.json";
private static final String CANCEL_MESSAGE_URL = "https://api.pushover.net/1/receipts/{receipt}/cancel.json";
private static final String CANCEL_MESSAGE_URL = "https://api.pushover.net/1/receipts/%s/cancel.json";
private static final String SOUNDS_URL = "https://api.pushover.net/1/sounds.json";
private final HttpClient httpClient;
private final PushoverAccountConfiguration config;
private final ExpiringCacheMap<String, String> cache = new ExpiringCacheMap<>(TimeUnit.DAYS.toMillis(1));
private final ExpiringCache<List<Sound>> cache = new ExpiringCache<>(TimeUnit.DAYS.toMillis(1),
this::getSoundsFromSource);
public PushoverAPIConnection(HttpClient httpClient, PushoverAccountConfiguration config) {
this.httpClient = httpClient;
this.config = config;
}
public boolean validateUser() throws PushoverCommunicationException, PushoverConfigurationException {
public boolean validateUser() throws CommunicationException, ConfigurationException {
return getMessageStatus(
post(VALIDATE_URL, PushoverMessageBuilder.getInstance(config.apikey, config.user).build()));
}
public boolean sendMessage(PushoverMessageBuilder message)
throws PushoverCommunicationException, PushoverConfigurationException {
public boolean sendMessage(PushoverMessageBuilder message) throws CommunicationException, ConfigurationException {
return getMessageStatus(post(MESSAGE_URL, message.build()));
}
public String sendPriorityMessage(PushoverMessageBuilder message)
throws PushoverCommunicationException, PushoverConfigurationException {
throws CommunicationException, ConfigurationException {
final JsonObject json = JsonParser.parseString(post(MESSAGE_URL, message.build())).getAsJsonObject();
return getMessageStatus(json) && json.has("receipt") ? json.get("receipt").getAsString() : "";
return getMessageStatus(json) && json.has(JSON_VALUE_RECEIPT) ? json.get(JSON_VALUE_RECEIPT).getAsString() : "";
}
public boolean cancelPriorityMessage(String receipt)
throws PushoverCommunicationException, PushoverConfigurationException {
return getMessageStatus(post(CANCEL_MESSAGE_URL.replace("{receipt}", receipt),
public boolean cancelPriorityMessage(String receipt) throws CommunicationException, ConfigurationException {
return getMessageStatus(post(String.format(CANCEL_MESSAGE_URL, receipt),
PushoverMessageBuilder.getInstance(config.apikey, config.user).build()));
}
public List<Sound> getSounds() throws PushoverCommunicationException, PushoverConfigurationException {
public @Nullable List<Sound> getSounds() {
return cache.getValue();
}
private List<Sound> getSoundsFromSource() throws CommunicationException, ConfigurationException {
final String localApikey = config.apikey;
if (localApikey == null || localApikey.isEmpty()) {
throw new PushoverConfigurationException("@text/offline.conf-error-missing-apikey");
if (localApikey == null || localApikey.isBlank()) {
throw new ConfigurationException(TEXT_OFFLINE_CONF_ERROR_MISSING_APIKEY);
}
final Map<String, String> params = new HashMap<>(1);
params.put(PushoverMessageBuilder.MESSAGE_KEY_TOKEN, localApikey);
// TODO do not cache the response, cache the parsed list of sounds
final String content = getFromCache(buildURL(SOUNDS_URL, params));
final JsonObject json = content == null ? null : JsonParser.parseString(content).getAsJsonObject();
final JsonObject sounds = json == null || !json.has("sounds") ? null : json.get("sounds").getAsJsonObject();
return sounds == null ? List.of()
: sounds.entrySet().stream().map(entry -> new Sound(entry.getKey(), entry.getValue().getAsString()))
try {
final String content = get(
buildURL(SOUNDS_URL, Map.of(PushoverMessageBuilder.MESSAGE_KEY_TOKEN, localApikey)));
final JsonObject json = JsonParser.parseString(content).getAsJsonObject();
final JsonObject sounds = json.has(JSON_VALUE_SOUNDS) ? json.get(JSON_VALUE_SOUNDS).getAsJsonObject()
: null;
if (sounds != null) {
return sounds.entrySet().stream()
.map(entry -> new Sound(entry.getKey(), entry.getValue().getAsString()))
.collect(Collectors.toUnmodifiableList());
}
} catch (JsonSyntaxException e) {
// do nothing
}
return List.of();
}
private String buildURL(String url, Map<String, String> requestParams) {
@ -115,21 +131,16 @@ public class PushoverAPIConnection {
return value == null ? "" : URLEncoder.encode(value, StandardCharsets.UTF_8);
}
private @Nullable String getFromCache(String url) {
return cache.putIfAbsentAndGet(url, () -> get(url));
}
private String get(String url) throws PushoverCommunicationException, PushoverConfigurationException {
private String get(String url) throws CommunicationException, ConfigurationException {
return executeRequest(HttpMethod.GET, url, null);
}
private String post(String url, ContentProvider body)
throws PushoverCommunicationException, PushoverConfigurationException {
private String post(String url, ContentProvider body) throws CommunicationException, ConfigurationException {
return executeRequest(HttpMethod.POST, url, body);
}
private synchronized String executeRequest(HttpMethod httpMethod, String url, @Nullable ContentProvider body)
throws PushoverCommunicationException, PushoverConfigurationException {
throws CommunicationException, ConfigurationException {
logger.trace("Pushover request: {} - URL = '{}'", httpMethod, url);
try {
final Request request = httpClient.newRequest(url).method(httpMethod).timeout(config.timeout,
@ -152,35 +163,43 @@ public class PushoverAPIConnection {
return content;
case HttpStatus.BAD_REQUEST_400:
logger.debug("Pushover server responded with status code {}: {}", httpStatus, content);
throw new PushoverConfigurationException(getMessageError(content));
throw new ConfigurationException(getMessageError(content));
default:
logger.debug("Pushover server responded with status code {}: {}", httpStatus, content);
throw new PushoverCommunicationException(content);
throw new CommunicationException(content);
}
} catch (ExecutionException e) {
logger.debug("Exception occurred during execution: {}", e.getLocalizedMessage(), e);
throw new PushoverCommunicationException(e.getLocalizedMessage(), e.getCause());
} catch (InterruptedException | TimeoutException e) {
logger.debug("Exception occurred during execution: {}", e.getLocalizedMessage(), e);
throw new PushoverCommunicationException(e.getLocalizedMessage());
String message = e.getMessage();
logger.debug("ExecutionException occurred during execution: {}", message, e);
throw new CommunicationException(message == null ? TEXT_OFFLINE_COMMUNICATION_ERROR : message,
e.getCause());
} catch (TimeoutException e) {
String message = e.getMessage();
logger.debug("TimeoutException occurred during execution: {}", message, e);
throw new CommunicationException(message == null ? TEXT_OFFLINE_COMMUNICATION_ERROR : message);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
String message = e.getMessage();
logger.debug("InterruptedException occurred during execution: {}", message, e);
throw new CommunicationException(message == null ? TEXT_OFFLINE_COMMUNICATION_ERROR : message);
}
}
private String getMessageError(String content) {
final JsonObject json = JsonParser.parseString(content).getAsJsonObject();
final JsonElement errorsElement = json.get("errors");
final JsonElement errorsElement = json.get(JSON_VALUE_ERRORS);
if (errorsElement != null && errorsElement.isJsonArray()) {
return errorsElement.getAsJsonArray().toString();
}
return "@text/offline.conf-error-unknown";
return TEXT_OFFLINE_CONF_ERROR_UNKNOWN;
}
private boolean getMessageStatus(String content) {
final JsonObject json = JsonParser.parseString(content).getAsJsonObject();
return json.has("status") ? json.get("status").getAsInt() == 1 : false;
return json.has(JSON_VALUE_STATUS) ? json.get(JSON_VALUE_STATUS).getAsInt() == 1 : false;
}
private boolean getMessageStatus(JsonObject json) {
return json.has("status") ? json.get("status").getAsInt() == 1 : false;
return json.has(JSON_VALUE_STATUS) ? json.get(JSON_VALUE_STATUS).getAsInt() == 1 : false;
}
}

View File

@ -1,62 +0,0 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.pushover.internal.connection;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* The {@link PushoverCommunicationException} is a configuration exception for the connections to Pushover Messages API.
*
* @author Christoph Weitkamp - Initial contribution
*/
@NonNullByDefault
public class PushoverCommunicationException extends RuntimeException {
private static final long serialVersionUID = 1L;
/**
* Constructs a new exception with null as its detail message.
*/
public PushoverCommunicationException() {
super();
}
/**
* Constructs a new exception with the specified detail message.
*
* @param message Detail message
*/
public PushoverCommunicationException(@Nullable String message) {
super(message);
}
/**
* Constructs a new exception with the specified cause.
*
* @param cause The cause
*/
public PushoverCommunicationException(@Nullable Throwable cause) {
super(cause);
}
/**
* Constructs a new exception with the specified detail message and cause.
*
* @param message Detail message
* @param cause The cause
*/
public PushoverCommunicationException(@Nullable String message, @Nullable Throwable cause) {
super(message, cause);
}
}

View File

@ -1,61 +0,0 @@
/**
* Copyright (c) 2010-2022 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.pushover.internal.connection;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* The {@link PushoverConfigurationException} is a configuration exception for the connections to Pushover Messages API.
*
* @author Christoph Weitkamp - Initial contribution
*/
@NonNullByDefault
public class PushoverConfigurationException extends IllegalArgumentException {
private static final long serialVersionUID = 1L;
/**
* Constructs a new exception with null as its detail message.
*/
public PushoverConfigurationException() {
super();
}
/**
* Constructs a new exception with the specified detail message.
*
* @param message Detail message
*/
public PushoverConfigurationException(String message) {
super(message);
}
/**
* Constructs a new exception with the specified cause.
*
* @param cause The cause
*/
public PushoverConfigurationException(Throwable cause) {
super(cause);
}
/**
* Constructs a new exception with the specified detail message and cause.
*
* @param message Detail message
* @param cause The cause
*/
public PushoverConfigurationException(String message, Throwable cause) {
super(message, cause);
}
}

View File

@ -12,6 +12,8 @@
*/
package org.openhab.binding.pushover.internal.connection;
import static org.openhab.binding.pushover.internal.PushoverBindingConstants.*;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
@ -25,6 +27,8 @@ import org.eclipse.jetty.client.api.ContentProvider;
import org.eclipse.jetty.client.util.MultiPartContentProvider;
import org.eclipse.jetty.client.util.PathContentProvider;
import org.eclipse.jetty.client.util.StringContentProvider;
import org.openhab.core.i18n.CommunicationException;
import org.openhab.core.i18n.ConfigurationException;
import org.openhab.core.io.net.http.HttpUtil;
import org.openhab.core.library.types.RawType;
import org.slf4j.Logger;
@ -83,19 +87,19 @@ public class PushoverMessageBuilder {
private boolean html = false;
private boolean monospace = false;
private PushoverMessageBuilder(String apikey, String user) throws PushoverConfigurationException {
private PushoverMessageBuilder(String apikey, String user) throws ConfigurationException {
body.addFieldPart(MESSAGE_KEY_TOKEN, new StringContentProvider(apikey), null);
body.addFieldPart(MESSAGE_KEY_USER, new StringContentProvider(user), null);
}
public static PushoverMessageBuilder getInstance(@Nullable String apikey, @Nullable String user)
throws PushoverConfigurationException {
if (apikey == null || apikey.isEmpty()) {
throw new PushoverConfigurationException("@text/offline.conf-error-missing-apikey");
throws ConfigurationException {
if (apikey == null || apikey.isBlank()) {
throw new ConfigurationException(TEXT_OFFLINE_CONF_ERROR_MISSING_APIKEY);
}
if (user == null || user.isEmpty()) {
throw new PushoverConfigurationException("@text/offline.conf-error-missing-user");
if (user == null || user.isBlank()) {
throw new ConfigurationException(TEXT_OFFLINE_CONF_ERROR_MISSING_USER);
}
return new PushoverMessageBuilder(apikey, user);
@ -166,7 +170,7 @@ public class PushoverMessageBuilder {
return this;
}
public ContentProvider build() throws PushoverCommunicationException {
public ContentProvider build() throws CommunicationException {
if (message != null) {
if (message.length() > MAX_MESSAGE_LENGTH) {
throw new IllegalArgumentException(String.format(
@ -279,27 +283,24 @@ public class PushoverMessageBuilder {
return body;
}
private Path createTempFile(byte[] data) throws PushoverCommunicationException {
private Path createTempFile(byte[] data) throws CommunicationException {
try {
Path tmpFile = Files.createTempFile("pushover-", ".tmp");
return Files.write(tmpFile, data);
} catch (IOException e) {
logger.debug("IOException occurred while creating temp file - skip sending message: {}",
e.getLocalizedMessage(), e);
throw new PushoverCommunicationException(
String.format("Skip sending the message: %s", e.getLocalizedMessage()), e);
logger.debug("IOException occurred while creating temp file - skip sending the message: {}", e.getMessage(),
e);
throw new CommunicationException(TEXT_ERROR_SKIP_SENDING_MESSAGE, e.getCause(), e.getLocalizedMessage());
}
}
private void addFilePart(Path path, @Nullable String contentType) throws PushoverCommunicationException {
private void addFilePart(Path path, @Nullable String contentType) throws CommunicationException {
try {
body.addFilePart(MESSAGE_KEY_ATTACHMENT, path.toFile().getName(),
new PathContentProvider(contentType == null ? DEFAULT_CONTENT_TYPE : contentType, path), null);
} catch (IOException e) {
logger.debug("IOException occurred while adding content - skip sending message: {}",
e.getLocalizedMessage(), e);
throw new PushoverCommunicationException(
String.format("Skip sending the message: %s", e.getLocalizedMessage()), e);
logger.debug("IOException occurred while adding content - skip sending the message: {}", e.getMessage(), e);
throw new CommunicationException(TEXT_ERROR_SKIP_SENDING_MESSAGE, e.getCause(), e.getLocalizedMessage());
}
}
}

View File

@ -12,7 +12,7 @@
*/
package org.openhab.binding.pushover.internal.handler;
import static org.openhab.binding.pushover.internal.PushoverBindingConstants.DEFAULT_SOUND;
import static org.openhab.binding.pushover.internal.PushoverBindingConstants.*;
import java.util.Collection;
import java.util.List;
@ -25,10 +25,10 @@ import org.openhab.binding.pushover.internal.actions.PushoverActions;
import org.openhab.binding.pushover.internal.config.PushoverAccountConfiguration;
import org.openhab.binding.pushover.internal.config.PushoverConfigOptionProvider;
import org.openhab.binding.pushover.internal.connection.PushoverAPIConnection;
import org.openhab.binding.pushover.internal.connection.PushoverCommunicationException;
import org.openhab.binding.pushover.internal.connection.PushoverConfigurationException;
import org.openhab.binding.pushover.internal.connection.PushoverMessageBuilder;
import org.openhab.binding.pushover.internal.dto.Sound;
import org.openhab.core.i18n.CommunicationException;
import org.openhab.core.i18n.ConfigurationException;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
@ -69,15 +69,15 @@ public class PushoverAccountHandler extends BaseThingHandler {
boolean configValid = true;
final String apikey = config.apikey;
if (apikey == null || apikey.isEmpty()) {
if (apikey == null || apikey.isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/offline.conf-error-missing-apikey");
TEXT_OFFLINE_CONF_ERROR_MISSING_APIKEY);
configValid = false;
}
final String user = config.user;
if (user == null || user.isEmpty()) {
if (user == null || user.isBlank()) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
"@text/offline.conf-error-missing-user");
TEXT_OFFLINE_CONF_ERROR_MISSING_USER);
configValid = false;
}
@ -101,11 +101,16 @@ public class PushoverAccountHandler extends BaseThingHandler {
*/
public List<Sound> getSounds() {
try {
return connection != null ? connection.getSounds() : PushoverAccountConfiguration.DEFAULT_SOUNDS;
} catch (PushoverCommunicationException e) {
if (connection != null) {
List<Sound> sounds = connection.getSounds();
if (sounds != null) {
return sounds;
}
}
} catch (CommunicationException e) {
// do nothing, causing exception is already logged
} catch (PushoverConfigurationException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
} catch (ConfigurationException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getRawMessage());
}
return PushoverAccountConfiguration.DEFAULT_SOUNDS;
}
@ -143,10 +148,10 @@ public class PushoverAccountHandler extends BaseThingHandler {
if (connection != null) {
try {
return connection.sendMessage(messageBuilder);
} catch (PushoverCommunicationException e) {
} catch (CommunicationException e) {
// do nothing, causing exception is already logged
} catch (PushoverConfigurationException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
} catch (ConfigurationException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getRawMessage());
}
return false;
} else {
@ -158,10 +163,10 @@ public class PushoverAccountHandler extends BaseThingHandler {
if (connection != null) {
try {
return connection.sendPriorityMessage(messageBuilder);
} catch (PushoverCommunicationException e) {
} catch (CommunicationException e) {
// do nothing, causing exception is already logged
} catch (PushoverConfigurationException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
} catch (ConfigurationException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getRawMessage());
}
return "";
} else {
@ -173,10 +178,10 @@ public class PushoverAccountHandler extends BaseThingHandler {
if (connection != null) {
try {
return connection.cancelPriorityMessage(receipt);
} catch (PushoverCommunicationException e) {
} catch (CommunicationException e) {
// do nothing, causing exception is already logged
} catch (PushoverConfigurationException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
} catch (ConfigurationException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getRawMessage());
}
return false;
} else {
@ -189,8 +194,8 @@ public class PushoverAccountHandler extends BaseThingHandler {
try {
connection.validateUser();
updateStatus(ThingStatus.ONLINE);
} catch (PushoverCommunicationException | PushoverConfigurationException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
} catch (CommunicationException | ConfigurationException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getRawMessage());
}
}
}

View File

@ -32,9 +32,11 @@ thing-type.config.pushover.pushover-account.user.description = Your user key or
# user defined messages
offline.communication-error = An unexpected exception occurred during execution.
offline.conf-error-missing-apikey = The 'apikey' parameter must be configured.
offline.conf-error-missing-user = The 'user' parameter must be configured.
offline.conf-error-unknown = An unknown error occurred.
error.skip-sending-message = Skip sending the message: {0}.
# actions

View File

@ -37,6 +37,7 @@ import org.openhab.core.thing.binding.ThingHandler;
*
* @author Christoph Weitkamp - Initial contribution
*/
@NonNullByDefault
@ExtendWith(MockitoExtension.class)
@MockitoSettings(strictness = Strictness.WARN)
public class PushoverActionsTest {
@ -47,7 +48,6 @@ public class PushoverActionsTest {
private static final String URL_TITLE = "Some Link";
private static final String RECEIPT = "12345";
@NonNullByDefault
private final ThingActions thingActionsStub = new ThingActions() {
@Override
public void setThingHandler(ThingHandler handler) {
@ -59,9 +59,8 @@ public class PushoverActionsTest {
}
};
private @Mock PushoverAccountHandler mockPushoverAccountHandler;
private PushoverActions pushoverThingActions;
private @NonNullByDefault({}) @Mock PushoverAccountHandler mockPushoverAccountHandler;
private @NonNullByDefault({}) PushoverActions pushoverThingActions;
@BeforeEach
public void setUp() {