From 68fe55fd1eadd128ea38dba8fc57a6e842711013 Mon Sep 17 00:00:00 2001 From: "Jan N. Klug" Date: Sat, 11 Dec 2021 20:00:05 +0100 Subject: [PATCH] [addonservices] allow uninstalling of removed addons Signed-off-by: Jan N. Klug --- .../pom.xml | 6 + .../AbstractRemoteAddonService.java | 205 +++++++++++++++++ .../CommunityMarketplaceAddonService.java | 209 +++++------------- .../model/DiscourseCategoryResponseDTO.java | 40 ++-- .../model/DiscourseTopicResponseDTO.java | 61 +++-- .../internal/json/JsonAddonService.java | 167 +++----------- .../internal/json/model/AddonEntryDTO.java | 2 + .../test/AbstractRemoteAddonServiceTest.java | 103 +++++++++ .../core/addon/test/TestAddonService.java | 96 ++++++++ .../core/addon/test/VirtualAddonHandler.java | 63 ++++++ 10 files changed, 621 insertions(+), 331 deletions(-) create mode 100644 bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java create mode 100644 bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/AbstractRemoteAddonServiceTest.java create mode 100644 bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/TestAddonService.java create mode 100644 bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/VirtualAddonHandler.java diff --git a/bundles/org.openhab.core.addon.marketplace/pom.xml b/bundles/org.openhab.core.addon.marketplace/pom.xml index 396bf0fbb61..1c6ddc6c2f4 100644 --- a/bundles/org.openhab.core.addon.marketplace/pom.xml +++ b/bundles/org.openhab.core.addon.marketplace/pom.xml @@ -38,6 +38,12 @@ ${project.version} compile + + org.openhab.core.bundles + org.openhab.core.test + ${project.version} + test + diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java new file mode 100644 index 00000000000..22d0191c571 --- /dev/null +++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/AbstractRemoteAddonService.java @@ -0,0 +1,205 @@ +/** + * 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.core.addon.marketplace; + +import java.io.IOException; +import java.net.URI; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Dictionary; +import java.util.HashSet; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.eclipse.jdt.annotation.Nullable; +import org.openhab.core.addon.Addon; +import org.openhab.core.addon.AddonEventFactory; +import org.openhab.core.addon.AddonService; +import org.openhab.core.addon.AddonType; +import org.openhab.core.cache.ExpiringCache; +import org.openhab.core.events.Event; +import org.openhab.core.events.EventPublisher; +import org.openhab.core.storage.Storage; +import org.openhab.core.storage.StorageService; +import org.osgi.service.cm.Configuration; +import org.osgi.service.cm.ConfigurationAdmin; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; + +/** + * The {@link AbstractRemoteAddonService} implements basic functionality of a remote add-on-service + * + * @author Jan N. Klug - Initial contribution + */ +@NonNullByDefault +public abstract class AbstractRemoteAddonService implements AddonService { + protected static final Map TAG_ADDON_TYPE_MAP = Map.of( // + "automation", new AddonType("automation", "Automation"), // + "binding", new AddonType("binding", "Bindings"), // + "misc", new AddonType("misc", "Misc"), // + "persistence", new AddonType("persistence", "Persistence"), // + "transformation", new AddonType("transformation", "Transformations"), // + "ui", new AddonType("ui", "User Interfaces"), // + "voice", new AddonType("voice", "Voice")); + + protected final Gson gson = new GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").create(); + protected final Set addonHandlers = new HashSet<>(); + protected final Storage installedAddonStorage; + protected final EventPublisher eventPublisher; + protected final ConfigurationAdmin configurationAdmin; + protected final ExpiringCache> cachedRemoteAddons = new ExpiringCache<>(Duration.ofMinutes(15), + this::getRemoteAddons); + protected List cachedAddons = List.of(); + protected List installedAddons = List.of(); + + public AbstractRemoteAddonService(EventPublisher eventPublisher, ConfigurationAdmin configurationAdmin, + StorageService storageService, String servicePid) { + this.eventPublisher = eventPublisher; + this.configurationAdmin = configurationAdmin; + this.installedAddonStorage = storageService.getStorage(servicePid); + } + + @Override + public void refreshSource() { + List addons = new ArrayList<>(); + installedAddonStorage.stream().map(e -> Objects.requireNonNull(gson.fromJson(e.getValue(), Addon.class))) + .forEach(addons::add); + addons.forEach(a -> a.setInstalled(true)); + + // create lookup list to make sure installed addons take precedence + List installedAddons = addons.stream().map(Addon::getId).collect(Collectors.toList()); + + if (remoteEnabled()) { + List remoteAddons = Objects.requireNonNullElse(cachedRemoteAddons.getValue(), List.of()); + remoteAddons.stream().filter(a -> !installedAddons.contains(a.getId())).forEach(addons::add); + } + + cachedAddons = addons; + this.installedAddons = installedAddons; + } + + /** + * get all addons from remote + * + * @return a list of {@link Addon} that are available on the remote side + */ + protected abstract List getRemoteAddons(); + + @Override + public List getAddons(@Nullable Locale locale) { + refreshSource(); + return cachedAddons; + } + + @Override + public abstract @Nullable Addon getAddon(String id, @Nullable Locale locale); + + @Override + public List getTypes(@Nullable Locale locale) { + return new ArrayList<>(TAG_ADDON_TYPE_MAP.values()); + } + + @Override + public void install(String id) { + Addon addon = getAddon(id, null); + if (addon != null) { + for (MarketplaceAddonHandler handler : addonHandlers) { + if (handler.supports(addon.getType(), addon.getContentType())) { + if (!handler.isInstalled(addon.getId())) { + try { + handler.install(addon); + installedAddonStorage.put(id, gson.toJson(addon)); + cachedRemoteAddons.invalidateValue(); + postInstalledEvent(addon.getId()); + } catch (MarketplaceHandlerException e) { + postFailureEvent(addon.getId(), e.getMessage()); + } + } else { + postFailureEvent(addon.getId(), "Add-on is already installed."); + } + return; + } + } + } + postFailureEvent(id, "Add-on not known."); + } + + @Override + public void uninstall(String id) { + Addon addon = getAddon(id, null); + if (addon != null) { + for (MarketplaceAddonHandler handler : addonHandlers) { + if (handler.supports(addon.getType(), addon.getContentType())) { + if (handler.isInstalled(addon.getId())) { + try { + handler.uninstall(addon); + installedAddonStorage.remove(id); + cachedRemoteAddons.invalidateValue(); + postUninstalledEvent(addon.getId()); + } catch (MarketplaceHandlerException e) { + postFailureEvent(addon.getId(), e.getMessage()); + } + } else { + installedAddonStorage.remove(id); + postFailureEvent(addon.getId(), "Add-on is not installed."); + } + return; + } + } + } + postFailureEvent(id, "Add-on not known."); + } + + @Override + public abstract @Nullable String getAddonId(URI addonURI); + + /** + * check if remote services are enabled + * + * @return true if network access is allowed + */ + protected boolean remoteEnabled() { + try { + Configuration configuration = configurationAdmin.getConfiguration("org.openhab.addons", null); + Dictionary properties = configuration.getProperties(); + if (properties == null) { + // if we can't determine a set property, we use true (default is remote enabled) + return true; + } + return (boolean) Objects.requireNonNullElse(properties.get("remote"), true); + } catch (IOException e) { + return true; + } + } + + private void postInstalledEvent(String extensionId) { + Event event = AddonEventFactory.createAddonInstalledEvent(extensionId); + eventPublisher.post(event); + } + + private void postUninstalledEvent(String extensionId) { + Event event = AddonEventFactory.createAddonUninstalledEvent(extensionId); + eventPublisher.post(event); + } + + private void postFailureEvent(String extensionId, @Nullable String msg) { + Event event = AddonEventFactory.createAddonFailureEvent(extensionId, msg); + eventPublisher.post(event); + } +} diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java index 912a58e4605..daa47f4181d 100644 --- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java +++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/CommunityMarketplaceAddonService.java @@ -14,7 +14,6 @@ import static org.openhab.core.addon.Addon.CODE_MATURITY_LEVELS; -import java.io.IOException; import java.io.InputStreamReader; import java.io.Reader; import java.net.URI; @@ -24,23 +23,20 @@ import java.util.Arrays; import java.util.Date; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.Objects; -import java.util.Set; import java.util.stream.Collectors; import java.util.stream.Stream; import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.addon.Addon; -import org.openhab.core.addon.AddonEventFactory; import org.openhab.core.addon.AddonService; import org.openhab.core.addon.AddonType; +import org.openhab.core.addon.marketplace.AbstractRemoteAddonService; import org.openhab.core.addon.marketplace.MarketplaceAddonHandler; -import org.openhab.core.addon.marketplace.MarketplaceHandlerException; import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponseDTO; import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponseDTO.DiscoursePosterInfo; import org.openhab.core.addon.marketplace.internal.community.model.DiscourseCategoryResponseDTO.DiscourseTopicItem; @@ -48,10 +44,9 @@ import org.openhab.core.addon.marketplace.internal.community.model.DiscourseTopicResponseDTO; import org.openhab.core.addon.marketplace.internal.community.model.DiscourseTopicResponseDTO.DiscoursePostLink; import org.openhab.core.config.core.ConfigurableService; -import org.openhab.core.events.Event; import org.openhab.core.events.EventPublisher; +import org.openhab.core.storage.StorageService; import org.osgi.framework.Constants; -import org.osgi.service.cm.Configuration; import org.osgi.service.cm.ConfigurationAdmin; import org.osgi.service.component.annotations.Activate; import org.osgi.service.component.annotations.Component; @@ -62,19 +57,17 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.google.gson.Gson; -import com.google.gson.GsonBuilder; - /** - * This class is a {@link AddonService} retrieving posts on community.openhab.org (Discourse). + * This class is an {@link org.openhab.core.addon.AddonService} retrieving posts on community.openhab.org (Discourse). * * @author Yannick Schaus - Initial contribution */ -@Component(immediate = true, configurationPid = "org.openhab.marketplace", // - property = Constants.SERVICE_PID + "=org.openhab.marketplace") -@ConfigurableService(category = "system", label = "Community Marketplace", description_uri = CommunityMarketplaceAddonService.CONFIG_URI) +@Component(immediate = true, configurationPid = CommunityMarketplaceAddonService.SERVICE_PID, // + property = Constants.SERVICE_PID + "=" + + CommunityMarketplaceAddonService.SERVICE_PID, service = AddonService.class) +@ConfigurableService(category = "system", label = CommunityMarketplaceAddonService.SERVICE_NAME, description_uri = CommunityMarketplaceAddonService.CONFIG_URI) @NonNullByDefault -public class CommunityMarketplaceAddonService implements AddonService { +public class CommunityMarketplaceAddonService extends AbstractRemoteAddonService { public static final String JAR_CONTENT_TYPE = "application/vnd.openhab.bundle"; public static final String KAR_CONTENT_TYPE = "application/vnd.openhab.feature;type=karfile"; public static final String RULETEMPLATES_CONTENT_TYPE = "application/vnd.openhab.ruletemplate"; @@ -82,6 +75,8 @@ public class CommunityMarketplaceAddonService implements AddonService { public static final String BLOCKLIBRARIES_CONTENT_TYPE = "application/vnd.openhab.uicomponent;type=blocks"; // constants for the configuration properties + static final String SERVICE_NAME = "Community Marketplace"; + static final String SERVICE_PID = "org.openhab.marketplace"; static final String CONFIG_URI = "system:marketplace"; static final String CONFIG_API_KEY = "apiKey"; static final String CONFIG_SHOW_UNPUBLISHED_ENTRIES_KEY = "showUnpublished"; @@ -90,7 +85,8 @@ public class CommunityMarketplaceAddonService implements AddonService { private static final String COMMUNITY_MARKETPLACE_URL = COMMUNITY_BASE_URL + "/c/marketplace/69/l/latest"; private static final String COMMUNITY_TOPIC_URL = COMMUNITY_BASE_URL + "/t/"; - private static final String ADDON_ID_PREFIX = "marketplace:"; + private static final String SERVICE_ID = "marketplace"; + private static final String ADDON_ID_PREFIX = SERVICE_ID + ":"; private static final String JSON_CODE_MARKUP_START = "
";
     private static final String YAML_CODE_MARKUP_START = "
";
@@ -103,44 +99,28 @@ public class CommunityMarketplaceAddonService implements AddonService {
 
     private static final String PUBLISHED_TAG = "published";
 
-    private static final Map TAG_ADDON_TYPE_MAP = Map.of( //
-            "automation", new AddonType("automation", "Automation"), //
-            "binding", new AddonType("binding", "Bindings"), //
-            "misc", new AddonType("misc", "Misc"), //
-            "persistence", new AddonType("persistence", "Persistence"), //
-            "transformation", new AddonType("transformation", "Transformations"), //
-            "ui", new AddonType("ui", "User Interfaces"), //
-            "voice", new AddonType("voice", "Voice"));
-
     private final Logger logger = LoggerFactory.getLogger(CommunityMarketplaceAddonService.class);
-    private final Gson gson = new GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").create();
-    private final Set addonHandlers = new HashSet<>();
-
-    private final EventPublisher eventPublisher;
-    private final ConfigurationAdmin configurationAdmin;
 
     private @Nullable String apiKey = null;
     private boolean showUnpublished = false;
 
     @Activate
     public CommunityMarketplaceAddonService(final @Reference EventPublisher eventPublisher,
-            @Reference ConfigurationAdmin configurationAdmin) {
-        this.eventPublisher = eventPublisher;
-        this.configurationAdmin = configurationAdmin;
-    }
-
-    @Activate
-    protected void activate(Map config) {
+            @Reference ConfigurationAdmin configurationAdmin, @Reference StorageService storageService,
+            Map config) {
+        super(eventPublisher, configurationAdmin, storageService, SERVICE_PID);
         modified(config);
     }
 
     @Modified
-    void modified(@Nullable Map config) {
+    public void modified(@Nullable Map config) {
         if (config != null) {
             this.apiKey = (String) config.get(CONFIG_API_KEY);
             Object showUnpublishedConfigValue = config.get(CONFIG_SHOW_UNPUBLISHED_ENTRIES_KEY);
             this.showUnpublished = showUnpublishedConfigValue != null
                     && "true".equals(showUnpublishedConfigValue.toString());
+            cachedRemoteAddons.invalidateValue();
+            refreshSource();
         }
     }
 
@@ -155,24 +135,17 @@ protected void removeAddonHandler(MarketplaceAddonHandler handler) {
 
     @Override
     public String getId() {
-        return "marketplace";
+        return SERVICE_ID;
     }
 
     @Override
     public String getName() {
-        return "Community Marketplace";
+        return SERVICE_NAME;
     }
 
     @Override
-    public void refreshSource() {
-    }
-
-    @Override
-    public List getAddons(@Nullable Locale locale) {
-        if (!remoteEnabled()) {
-            return List.of();
-        }
-
+    protected List getRemoteAddons() {
+        List addons = new ArrayList<>();
         try {
             List pages = new ArrayList<>();
 
@@ -187,11 +160,11 @@ public List getAddons(@Nullable Locale locale) {
 
                 try (Reader reader = new InputStreamReader(connection.getInputStream())) {
                     DiscourseCategoryResponseDTO parsed = gson.fromJson(reader, DiscourseCategoryResponseDTO.class);
-                    if (parsed.topic_list.topics.length != 0) {
+                    if (parsed.topicList.topics.length != 0) {
                         pages.add(parsed);
                     }
 
-                    if (parsed.topic_list.more_topics_url != null) {
+                    if (parsed.topicList.moreTopicsUrl != null) {
                         // Discourse URL for next page is wrong
                         url = new URL(COMMUNITY_MARKETPLACE_URL + "?page=" + pageNb++);
                     } else {
@@ -201,24 +174,31 @@ public List getAddons(@Nullable Locale locale) {
             }
 
             List users = pages.stream().flatMap(p -> Stream.of(p.users)).collect(Collectors.toList());
-            return pages.stream().flatMap(p -> Stream.of(p.topic_list.topics))
+            pages.stream().flatMap(p -> Stream.of(p.topicList.topics))
                     .filter(t -> showUnpublished || Arrays.asList(t.tags).contains(PUBLISHED_TAG))
-                    .map(t -> convertTopicItemToAddon(t, users)).collect(Collectors.toList());
+                    .map(t -> convertTopicItemToAddon(t, users)).forEach(addons::add);
         } catch (Exception e) {
             logger.error("Unable to retrieve marketplace add-ons", e);
-            return List.of();
         }
+        return addons;
     }
 
     @Override
     public @Nullable Addon getAddon(String id, @Nullable Locale locale) {
+        String fullId = ADDON_ID_PREFIX + id;
+        // check if it is an installed add-on (cachedAddons also contains possibly incomplete results from the remote
+        // side, we need to retrieve them from Discourse)
+        if (installedAddons.contains(id)) {
+            return cachedAddons.stream().filter(e -> fullId.equals(e.getId())).findAny().orElse(null);
+        }
+
         if (!remoteEnabled()) {
             return null;
         }
 
-        URL url;
+        // retrieve from remote
         try {
-            url = new URL(String.format("%s%s", COMMUNITY_TOPIC_URL, id.replace(ADDON_ID_PREFIX, "")));
+            URL url = new URL(String.format("%s%s", COMMUNITY_TOPIC_URL, id.replace(ADDON_ID_PREFIX, "")));
             URLConnection connection = url.openConnection();
             connection.addRequestProperty("Accept", "application/json");
             if (this.apiKey != null) {
@@ -234,57 +214,6 @@ public List getAddons(@Nullable Locale locale) {
         }
     }
 
-    @Override
-    public List getTypes(@Nullable Locale locale) {
-        return new ArrayList<>(TAG_ADDON_TYPE_MAP.values());
-    }
-
-    @Override
-    public void install(String id) {
-        Addon addon = getAddon(id, null);
-        if (addon != null) {
-            for (MarketplaceAddonHandler handler : addonHandlers) {
-                if (handler.supports(addon.getType(), addon.getContentType())) {
-                    if (!handler.isInstalled(addon.getId())) {
-                        try {
-                            handler.install(addon);
-                            postInstalledEvent(id);
-                        } catch (MarketplaceHandlerException e) {
-                            postFailureEvent(id, e.getMessage());
-                        }
-                    } else {
-                        postFailureEvent(id, "Add-on is already installed.");
-                    }
-                    return;
-                }
-            }
-        }
-        postFailureEvent(id, "Add-on not known.");
-    }
-
-    @Override
-    public void uninstall(String id) {
-        Addon addon = getAddon(id, null);
-        if (addon != null) {
-            for (MarketplaceAddonHandler handler : addonHandlers) {
-                if (handler.supports(addon.getType(), addon.getContentType())) {
-                    if (handler.isInstalled(addon.getId())) {
-                        try {
-                            handler.uninstall(addon);
-                            postUninstalledEvent(id);
-                        } catch (MarketplaceHandlerException e) {
-                            postFailureEvent(id, e.getMessage());
-                        }
-                    } else {
-                        postFailureEvent(id, "Add-on is not installed.");
-                    }
-                    return;
-                }
-            }
-        }
-        postFailureEvent(id, "Add-on not known.");
-    }
-
     @Override
     public @Nullable String getAddonId(URI addonURI) {
         if (addonURI.toString().startsWith(COMMUNITY_TOPIC_URL)) {
@@ -341,20 +270,20 @@ private Addon convertTopicItemToAddon(DiscourseTopicItem topic, List tags = Arrays.asList(Objects.requireNonNullElse(topic.tags, new String[0]));
 
         String id = ADDON_ID_PREFIX + topic.id.toString();
-        AddonType addonType = getAddonType(topic.category_id, tags);
+        AddonType addonType = getAddonType(topic.categoryId, tags);
         String type = (addonType != null) ? addonType.getId() : "";
-        String contentType = getContentType(topic.category_id, tags);
+        String contentType = getContentType(topic.categoryId, tags);
 
         String title = topic.title;
         String link = COMMUNITY_TOPIC_URL + topic.id.toString();
-        int likeCount = topic.like_count;
+        int likeCount = topic.likeCount;
         int views = topic.views;
-        int postsCount = topic.posts_count;
-        Date createdDate = topic.created_at;
+        int postsCount = topic.postsCount;
+        Date createdDate = topic.createdAt;
         String author = "";
         for (DiscoursePosterInfo posterInfo : topic.posters) {
             if (posterInfo.description.contains("Original Poster")) {
-                author = users.stream().filter(u -> u.id.equals(posterInfo.user_id)).findFirst().get().name;
+                author = users.stream().filter(u -> u.id.equals(posterInfo.userId)).findFirst().get().name;
             }
         }
 
@@ -370,7 +299,7 @@ private Addon convertTopicItemToAddon(DiscourseTopicItem topic, List handler.supports(type, contentType) && handler.isInstalled(id));
 
-        return Addon.create(id).withType(type).withContentType(contentType).withImageLink(topic.image_url)
+        return Addon.create(id).withType(type).withContentType(contentType).withImageLink(topic.imageUrl)
                 .withAuthor(author).withProperties(properties).withLabel(title).withInstalled(installed)
                 .withMaturity(maturity).withLink(link).build();
     }
@@ -396,16 +325,16 @@ private Addon convertTopicToAddon(DiscourseTopicResponseDTO topic) {
         String id = ADDON_ID_PREFIX + topic.id.toString();
         List tags = Arrays.asList(Objects.requireNonNullElse(topic.tags, new String[0]));
 
-        AddonType addonType = getAddonType(topic.category_id, tags);
+        AddonType addonType = getAddonType(topic.categoryId, tags);
         String type = (addonType != null) ? addonType.getId() : "";
-        String contentType = getContentType(topic.category_id, tags);
+        String contentType = getContentType(topic.categoryId, tags);
 
-        int likeCount = topic.like_count;
+        int likeCount = topic.likeCount;
         int views = topic.views;
-        int postsCount = topic.posts_count;
-        Date createdDate = topic.post_stream.posts[0].created_at;
-        Date updatedDate = topic.post_stream.posts[0].updated_at;
-        Date lastPostedDate = topic.last_posted;
+        int postsCount = topic.postsCount;
+        Date createdDate = topic.postStream.posts[0].createdAt;
+        Date updatedDate = topic.postStream.posts[0].updatedAt;
+        Date lastPostedDate = topic.lastPosted;
 
         String maturity = tags.stream().filter(CODE_MATURITY_LEVELS::contains).findAny().orElse(null);
 
@@ -418,11 +347,11 @@ private Addon convertTopicToAddon(DiscourseTopicResponseDTO topic) {
         properties.put("posts_count", postsCount);
         properties.put("tags", tags.toArray(String[]::new));
 
-        String detailedDescription = topic.post_stream.posts[0].cooked;
+        String detailedDescription = topic.postStream.posts[0].cooked;
 
         // try to extract contents or links
-        if (topic.post_stream.posts[0].link_counts != null) {
-            for (DiscoursePostLink postLink : topic.post_stream.posts[0].link_counts) {
+        if (topic.postStream.posts[0].linkCounts != null) {
+            for (DiscoursePostLink postLink : topic.postStream.posts[0].linkCounts) {
                 if (postLink.url.endsWith(".jar")) {
                     properties.put("jar_download_url", postLink.url);
                 }
@@ -455,37 +384,9 @@ private Addon convertTopicToAddon(DiscourseTopicResponseDTO topic) {
                 .anyMatch(handler -> handler.supports(type, contentType) && handler.isInstalled(id));
 
         return Addon.create(id).withType(type).withContentType(contentType).withLabel(topic.title)
-                .withImageLink(topic.image_url).withLink(COMMUNITY_TOPIC_URL + topic.id.toString())
-                .withAuthor(topic.post_stream.posts[0].display_username).withMaturity(maturity)
+                .withImageLink(topic.imageUrl).withLink(COMMUNITY_TOPIC_URL + topic.id.toString())
+                .withAuthor(topic.postStream.posts[0].displayUsername).withMaturity(maturity)
                 .withDetailedDescription(detailedDescription).withInstalled(installed).withProperties(properties)
                 .build();
     }
-
-    private void postInstalledEvent(String extensionId) {
-        Event event = AddonEventFactory.createAddonInstalledEvent(extensionId);
-        eventPublisher.post(event);
-    }
-
-    private void postUninstalledEvent(String extensionId) {
-        Event event = AddonEventFactory.createAddonUninstalledEvent(extensionId);
-        eventPublisher.post(event);
-    }
-
-    private void postFailureEvent(String extensionId, @Nullable String msg) {
-        Event event = AddonEventFactory.createAddonFailureEvent(extensionId, msg);
-        eventPublisher.post(event);
-    }
-
-    private boolean remoteEnabled() {
-        try {
-            Configuration configuration = configurationAdmin.getConfiguration("org.openhab.addons", null);
-            if (configuration.getProperties() != null) {
-                return (boolean) Objects.requireNonNullElse(configuration.getProperties().get("remote"), true);
-            } else {
-                return true;
-            }
-        } catch (IOException e) {
-            return true;
-        }
-    }
 }
diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/model/DiscourseCategoryResponseDTO.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/model/DiscourseCategoryResponseDTO.java
index 286956f4f86..7d227c80cac 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/model/DiscourseCategoryResponseDTO.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/model/DiscourseCategoryResponseDTO.java
@@ -14,6 +14,8 @@
 
 import java.util.Date;
 
+import com.google.gson.annotations.SerializedName;
+
 /**
  * A DTO class mapped to the Discourse category topic list API.
  *
@@ -21,38 +23,48 @@
  */
 public class DiscourseCategoryResponseDTO {
     public DiscourseUser[] users;
-    public DiscourseTopicList topic_list;
+    @SerializedName("topic_list")
+    public DiscourseTopicList topicList;
 
-    public class DiscourseUser {
+    public static class DiscourseUser {
         public Integer id;
         public String username;
         public String name;
-        public String avatar_template;
+        @SerializedName("avatar_template")
+        public String avatarTemplate;
     }
 
-    public class DiscourseTopicList {
-        public String more_topics_url;
-        public Integer per_page;
+    public static class DiscourseTopicList {
+        @SerializedName("more_topics_url")
+        public String moreTopicsUrl;
+        @SerializedName("per_page")
+        public Integer perPage;
         public DiscourseTopicItem[] topics;
     }
 
-    public class DiscoursePosterInfo {
+    public static class DiscoursePosterInfo {
         public String extras;
         public String description;
-        public Integer user_id;
+        @SerializedName("user_id")
+        public Integer userId;
     }
 
-    public class DiscourseTopicItem {
+    public static class DiscourseTopicItem {
         public Integer id;
         public String title;
         public String slug;
         public String[] tags;
-        public Integer posts_count;
-        public String image_url;
-        public Date created_at;
-        public Integer like_count;
+        @SerializedName("posts_count")
+        public Integer postsCount;
+        @SerializedName("image_url")
+        public String imageUrl;
+        @SerializedName("created_at")
+        public Date createdAt;
+        @SerializedName("like_count")
+        public Integer likeCount;
         public Integer views;
-        public Integer category_id;
+        @SerializedName("category_id")
+        public Integer categoryId;
         public DiscoursePosterInfo[] posters;
     }
 }
diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/model/DiscourseTopicResponseDTO.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/model/DiscourseTopicResponseDTO.java
index 90ed5c510e9..22b0894f743 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/model/DiscourseTopicResponseDTO.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/community/model/DiscourseTopicResponseDTO.java
@@ -14,6 +14,8 @@
 
 import java.util.Date;
 
+import com.google.gson.annotations.SerializedName;
+
 /**
  * A DTO class mapped to the Discourse topic API.
  *
@@ -22,57 +24,72 @@
 public class DiscourseTopicResponseDTO {
     public Integer id;
 
-    public DiscoursePostStream post_stream;
+    @SerializedName("post_stream")
+    public DiscoursePostStream postStream;
 
     public String title;
-    public Integer posts_count;
-    public String image_url;
-
-    public Date created_at;
-    public Date updated_at;
-    public Date last_posted;
-
-    public Integer like_count;
+    @SerializedName("posts_count")
+    public Integer postsCount;
+    @SerializedName("image_url")
+    public String imageUrl;
+
+    @SerializedName("created_at")
+    public Date createdAt;
+    @SerializedName("updated_at")
+    public Date updatedAt;
+    @SerializedName("last_posted")
+    public Date lastPosted;
+
+    @SerializedName("like_count")
+    public Integer likeCount;
     public Integer views;
 
     public String[] tags;
-    public Integer category_id;
+    @SerializedName("category_id")
+    public Integer categoryId;
 
     public DiscourseTopicDetails details;
 
-    public class DiscoursePostAuthor {
+    public static class DiscoursePostAuthor {
         public Integer id;
         public String username;
-        public String avatar_template;
+        @SerializedName("avatar_template")
+        public String avatarTemplate;
     }
 
-    public class DiscoursePostLink {
+    public static class DiscoursePostLink {
         public String url;
         public Boolean internal;
         public Integer clicks;
     }
 
-    public class DiscoursePostStream {
+    public static class DiscoursePostStream {
         public DiscoursePost[] posts;
     }
 
-    public class DiscoursePost {
+    public static class DiscoursePost {
         public Integer id;
 
         public String username;
-        public String display_username;
+        @SerializedName("display_username")
+        public String displayUsername;
 
-        public Date created_at;
-        public Date updated_at;
+        @SerializedName("created_at")
+        public Date createdAt;
+        @SerializedName("updated_at")
+        public Date updatedAt;
 
         public String cooked;
 
-        public DiscoursePostLink[] link_counts;
+        @SerializedName("link_counts")
+        public DiscoursePostLink[] linkCounts;
     }
 
-    public class DiscourseTopicDetails {
-        public DiscoursePostAuthor created_by;
-        public DiscoursePostAuthor last_poster;
+    public static class DiscourseTopicDetails {
+        @SerializedName("created_by")
+        public DiscoursePostAuthor createdBy;
+        @SerializedName("last_poster")
+        public DiscoursePostAuthor lastPoster;
 
         public DiscoursePostLink[] links;
     }
diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java
index c73bd3f60b0..39835d1546f 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/JsonAddonService.java
@@ -19,31 +19,25 @@
 import java.net.URI;
 import java.net.URL;
 import java.net.URLConnection;
-import java.util.ArrayList;
 import java.util.Arrays;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
 import java.util.Objects;
-import java.util.Set;
 import java.util.stream.Collectors;
 
 import org.eclipse.jdt.annotation.NonNullByDefault;
 import org.eclipse.jdt.annotation.Nullable;
 import org.openhab.core.addon.Addon;
-import org.openhab.core.addon.AddonEventFactory;
 import org.openhab.core.addon.AddonService;
-import org.openhab.core.addon.AddonType;
+import org.openhab.core.addon.marketplace.AbstractRemoteAddonService;
 import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
-import org.openhab.core.addon.marketplace.MarketplaceHandlerException;
 import org.openhab.core.addon.marketplace.internal.json.model.AddonEntryDTO;
 import org.openhab.core.config.core.ConfigurableService;
-import org.openhab.core.events.Event;
 import org.openhab.core.events.EventPublisher;
+import org.openhab.core.storage.StorageService;
 import org.osgi.framework.Constants;
-import org.osgi.service.cm.Configuration;
 import org.osgi.service.cm.ConfigurationAdmin;
 import org.osgi.service.component.annotations.Activate;
 import org.osgi.service.component.annotations.Component;
@@ -51,28 +45,23 @@
 import org.osgi.service.component.annotations.Reference;
 import org.osgi.service.component.annotations.ReferenceCardinality;
 import org.osgi.service.component.annotations.ReferencePolicy;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
-import com.google.gson.Gson;
-import com.google.gson.GsonBuilder;
 import com.google.gson.reflect.TypeToken;
 
 /**
- * This class is a {@link AddonService} retrieving JSON marketplace information.
+ * This class implements an {@link org.openhab.core.addon.AddonService} retrieving JSON marketplace information.
  *
  * @author Yannick Schaus - Initial contribution
  * @author Jan N. Klug - Refactored for JSON marketplaces
  */
-@Component(immediate = true, configurationPid = { "org.openhab.jsonaddonservice" }, //
-        property = Constants.SERVICE_PID + "=org.openhab.jsonaddonservice")
+@Component(immediate = true, configurationPid = JsonAddonService.SERVICE_PID, //
+        property = Constants.SERVICE_PID + "=" + JsonAddonService.SERVICE_PID, service = AddonService.class)
 @ConfigurableService(category = "system", label = JsonAddonService.SERVICE_NAME, description_uri = JsonAddonService.CONFIG_URI)
 @NonNullByDefault
-public class JsonAddonService implements AddonService {
-    private final Logger logger = LoggerFactory.getLogger(JsonAddonService.class);
-
+public class JsonAddonService extends AbstractRemoteAddonService {
     static final String SERVICE_NAME = "Json 3rd Party Add-on Service";
     static final String CONFIG_URI = "system:jsonaddonservice";
+    static final String SERVICE_PID = "org.openhab.jsonaddonservice";
 
     private static final String SERVICE_ID = "json";
     private static final String ADDON_ID_PREFIX = SERVICE_ID + ":";
@@ -80,40 +69,25 @@ public class JsonAddonService implements AddonService {
     private static final String CONFIG_URLS = "urls";
     private static final String CONFIG_SHOW_UNSTABLE = "showUnstable";
 
-    private static final Map TAG_ADDON_TYPE_MAP = Map.of( //
-            "automation", new AddonType("automation", "Automation"), //
-            "binding", new AddonType("binding", "Bindings"), //
-            "misc", new AddonType("misc", "Misc"), //
-            "persistence", new AddonType("persistence", "Persistence"), //
-            "transformation", new AddonType("transformation", "Transformations"), //
-            "ui", new AddonType("ui", "User Interfaces"), //
-            "voice", new AddonType("voice", "Voice"));
-
-    private final Gson gson = new GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").create();
-    private final Set addonHandlers = new HashSet<>();
-
-    private List addonserviceUrls = List.of();
-    private List cachedAddons = List.of();
-
+    private List addonServiceUrls = List.of();
     private boolean showUnstable = false;
 
-    private final EventPublisher eventPublisher;
-    private final ConfigurationAdmin configurationAdmin;
-
     @Activate
-    public JsonAddonService(@Reference EventPublisher eventPublisher, @Reference ConfigurationAdmin configurationAdmin,
-            Map config) {
-        this.eventPublisher = eventPublisher;
-        this.configurationAdmin = configurationAdmin;
+    public JsonAddonService(@Reference EventPublisher eventPublisher, @Reference StorageService storageService,
+            @Reference ConfigurationAdmin configurationAdmin, Map config) {
+        super(eventPublisher, configurationAdmin, storageService, SERVICE_PID);
         modified(config);
     }
 
     @Modified
-    public void modified(Map config) {
-        String urls = Objects.requireNonNullElse((String) config.get(CONFIG_URLS), "");
-        addonserviceUrls = Arrays.asList(urls.split("\\|"));
-        showUnstable = (Boolean) config.getOrDefault(CONFIG_SHOW_UNSTABLE, false);
-        refreshSource();
+    public void modified(@Nullable Map config) {
+        if (config != null) {
+            String urls = Objects.requireNonNullElse((String) config.get(CONFIG_URLS), "");
+            addonServiceUrls = Arrays.asList(urls.split("\\|"));
+            showUnstable = (Boolean) config.getOrDefault(CONFIG_SHOW_UNSTABLE, false);
+            cachedRemoteAddons.invalidateValue();
+            refreshSource();
+        }
     }
 
     @Reference(cardinality = ReferenceCardinality.AT_LEAST_ONE, policy = ReferencePolicy.DYNAMIC)
@@ -137,13 +111,8 @@ public String getName() {
 
     @Override
     @SuppressWarnings("unchecked")
-    public void refreshSource() {
-        if (!remoteEnabled()) {
-            cachedAddons = List.of();
-            return;
-        }
-
-        cachedAddons = (List) addonserviceUrls.stream().map(urlString -> {
+    protected List getRemoteAddons() {
+        return addonServiceUrls.stream().map(urlString -> {
             try {
                 URL url = new URL(urlString);
                 URLConnection connection = url.openConnection();
@@ -155,72 +124,15 @@ public void refreshSource() {
             } catch (IOException e) {
                 return List.of();
             }
-        }).flatMap(List::stream).filter(e -> showUnstable || "stable".equals(((AddonEntryDTO) e).maturity))
+        }).flatMap(List::stream).filter(Objects::nonNull).map(e -> (AddonEntryDTO) e)
+                .filter(e -> showUnstable || "stable".equals(e.maturity)).map(this::fromAddonEntry)
                 .collect(Collectors.toList());
     }
 
-    @Override
-    public List getAddons(@Nullable Locale locale) {
-        refreshSource();
-        return cachedAddons.stream().map(this::fromAddonEntry).collect(Collectors.toList());
-    }
-
     @Override
     public @Nullable Addon getAddon(String id, @Nullable Locale locale) {
-        String remoteId = id.replace(ADDON_ID_PREFIX, "");
-        return cachedAddons.stream().filter(e -> remoteId.equals(e.id)).map(this::fromAddonEntry).findAny()
-                .orElse(null);
-    }
-
-    @Override
-    public List getTypes(@Nullable Locale locale) {
-        return new ArrayList<>(TAG_ADDON_TYPE_MAP.values());
-    }
-
-    @Override
-    public void install(String id) {
-        Addon addon = getAddon(id, null);
-        if (addon != null) {
-            for (MarketplaceAddonHandler handler : addonHandlers) {
-                if (handler.supports(addon.getType(), addon.getContentType())) {
-                    if (!handler.isInstalled(addon.getId())) {
-                        try {
-                            handler.install(addon);
-                            postInstalledEvent(addon.getId());
-                        } catch (MarketplaceHandlerException e) {
-                            postFailureEvent(addon.getId(), e.getMessage());
-                        }
-                    } else {
-                        postFailureEvent(addon.getId(), "Add-on is already installed.");
-                    }
-                    return;
-                }
-            }
-        }
-        postFailureEvent(id, "Add-on not known.");
-    }
-
-    @Override
-    public void uninstall(String id) {
-        Addon addon = getAddon(id, null);
-        if (addon != null) {
-            for (MarketplaceAddonHandler handler : addonHandlers) {
-                if (handler.supports(addon.getType(), addon.getContentType())) {
-                    if (handler.isInstalled(addon.getId())) {
-                        try {
-                            handler.uninstall(addon);
-                            postUninstalledEvent(addon.getId());
-                        } catch (MarketplaceHandlerException e) {
-                            postFailureEvent(addon.getId(), e.getMessage());
-                        }
-                    } else {
-                        postFailureEvent(addon.getId(), "Add-on is not installed.");
-                    }
-                    return;
-                }
-            }
-        }
-        postFailureEvent(id, "Add-on not known.");
+        String fullId = ADDON_ID_PREFIX + id;
+        return cachedAddons.stream().filter(e -> fullId.equals(e.getId())).findAny().orElse(null);
     }
 
     @Override
@@ -248,34 +160,7 @@ private Addon fromAddonEntry(AddonEntryDTO addonEntry) {
                 .withDetailedDescription(addonEntry.description).withContentType(addonEntry.contentType)
                 .withAuthor(addonEntry.author).withVersion(addonEntry.version).withLabel(addonEntry.title)
                 .withMaturity(addonEntry.maturity).withProperties(properties).withLink(addonEntry.link)
-                .withConfigDescriptionURI(addonEntry.configDescriptionURI).build();
-    }
 
-    private void postInstalledEvent(String extensionId) {
-        Event event = AddonEventFactory.createAddonInstalledEvent(extensionId);
-        eventPublisher.post(event);
-    }
-
-    private void postUninstalledEvent(String extensionId) {
-        Event event = AddonEventFactory.createAddonUninstalledEvent(extensionId);
-        eventPublisher.post(event);
-    }
-
-    private void postFailureEvent(String extensionId, @Nullable String msg) {
-        Event event = AddonEventFactory.createAddonFailureEvent(extensionId, msg);
-        eventPublisher.post(event);
-    }
-
-    private boolean remoteEnabled() {
-        try {
-            Configuration configuration = configurationAdmin.getConfiguration("org.openhab.addons", null);
-            if (configuration.getProperties() != null) {
-                return (boolean) Objects.requireNonNullElse(configuration.getProperties().get("remote"), true);
-            } else {
-                return true;
-            }
-        } catch (IOException e) {
-            return true;
-        }
+                .withImageLink(addonEntry.imageUrl).withConfigDescriptionURI(addonEntry.configDescriptionURI).build();
     }
 }
diff --git a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/model/AddonEntryDTO.java b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/model/AddonEntryDTO.java
index 68b859e24a2..d764ab2e5d2 100644
--- a/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/model/AddonEntryDTO.java
+++ b/bundles/org.openhab.core.addon.marketplace/src/main/java/org/openhab/core/addon/marketplace/internal/json/model/AddonEntryDTO.java
@@ -31,5 +31,7 @@ public class AddonEntryDTO {
     public String maturity = "unstable";
     @SerializedName("content_type")
     public String contentType = "";
+    @SerializedName("image_url")
+    public String imageUrl;
     public String url = "";
 }
diff --git a/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/AbstractRemoteAddonServiceTest.java b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/AbstractRemoteAddonServiceTest.java
new file mode 100644
index 00000000000..87f2d5bd81a
--- /dev/null
+++ b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/AbstractRemoteAddonServiceTest.java
@@ -0,0 +1,103 @@
+/**
+ * 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.core.addon.test;
+
+import java.io.IOException;
+import java.util.Dictionary;
+import java.util.Hashtable;
+import java.util.List;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Assertions;
+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.Mockito;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.mockito.junit.jupiter.MockitoSettings;
+import org.mockito.quality.Strictness;
+import org.openhab.core.addon.Addon;
+import org.openhab.core.events.Event;
+import org.openhab.core.events.EventPublisher;
+import org.openhab.core.storage.Storage;
+import org.openhab.core.storage.StorageService;
+import org.openhab.core.test.storage.VolatileStorage;
+import org.osgi.service.cm.Configuration;
+import org.osgi.service.cm.ConfigurationAdmin;
+
+/**
+ * The {@link AbstractRemoteAddonServiceTest} is a
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@ExtendWith(MockitoExtension.class)
+@MockitoSettings(strictness = Strictness.WARN)
+@NonNullByDefault
+public class AbstractRemoteAddonServiceTest {
+
+    private @Mock @NonNullByDefault({}) StorageService storageService;
+    private @Mock @NonNullByDefault({}) ConfigurationAdmin configurationAdmin;
+    private @Mock @NonNullByDefault({}) EventPublisher eventPublisher;
+    private @Mock @NonNullByDefault({}) Configuration configuration;
+
+    private @NonNullByDefault({}) Storage storage;
+    private @NonNullByDefault({}) TestAddonService addonService;
+
+    private final Dictionary properties = new Hashtable<>();
+
+    @BeforeEach
+    public void initialize() throws IOException {
+        storage = new VolatileStorage<>();
+        Mockito.doReturn(storage).when(storageService).getStorage(TestAddonService.SERVICE_PID);
+        Mockito.doReturn(configuration).when(configurationAdmin).getConfiguration("org.openhab.addons", null);
+        Mockito.doReturn(properties).when(configuration).getProperties();
+
+        addonService = new TestAddonService(eventPublisher, configurationAdmin, storageService);
+        addonService.addAddonHandler(new VirtualAddonHandler());
+    }
+
+    @Test
+    public void testRemoteDisabledBlocksRemoteCalls() {
+        properties.put("remote", false);
+        List addons = addonService.getAddons(null);
+        Assertions.assertEquals(0, addons.size());
+        Assertions.assertEquals(0, addonService.getRemoteCalls());
+    }
+
+    @Test
+    public void testAddonResultsAreCached() {
+        List addons = addonService.getAddons(null);
+        Assertions.assertEquals(TestAddonService.REMOTE_ADDONS.size(), addons.size());
+        addons = addonService.getAddons(null);
+        Assertions.assertEquals(TestAddonService.REMOTE_ADDONS.size(), addons.size());
+        Assertions.assertEquals(1, addonService.getRemoteCalls());
+    }
+
+    @Test
+    public void testAddonInstallation() {
+        addonService.install(TestAddonService.TEST_ADDON);
+
+        ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(Event.class);
+        Mockito.verify(eventPublisher).post(eventCaptor.capture());
+
+        Event postInstallationEvent = eventCaptor.getValue();
+        Assertions.assertEquals("openhab/addons/" + getFullAddonId(TestAddonService.TEST_ADDON) + "/installed",
+                postInstallationEvent.getTopic());
+    }
+
+    private String getFullAddonId(String id) {
+        return TestAddonService.SERVICE_PID + ":" + id;
+    }
+}
diff --git a/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/TestAddonService.java b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/TestAddonService.java
new file mode 100644
index 00000000000..06577376aae
--- /dev/null
+++ b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/TestAddonService.java
@@ -0,0 +1,96 @@
+/**
+ * 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.core.addon.test;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.addon.Addon;
+import org.openhab.core.addon.marketplace.AbstractRemoteAddonService;
+import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
+import org.openhab.core.events.EventPublisher;
+import org.openhab.core.storage.StorageService;
+import org.osgi.service.cm.ConfigurationAdmin;
+
+/**
+ * The {@link TestAddonService} is a
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public class TestAddonService extends AbstractRemoteAddonService {
+    public static final String TEST_ADDON = "testAddon";
+    public static final String INSTALL_EXCEPTION_ADDON = "installException";
+    public static final String UNINSTALL_EXCEPTION_ADDON = "uninstallException";
+
+    public static final String SERVICE_PID = "testAddonService";
+
+    public static final Map REMOTE_ADDONS = Stream
+            .of(TEST_ADDON, INSTALL_EXCEPTION_ADDON, UNINSTALL_EXCEPTION_ADDON)
+            .map(id -> Addon.create(SERVICE_PID + ":" + id).withType("binding")
+                    .withContentType(VirtualAddonHandler.TEST_ADDON_CONTENT_TYPE).build())
+            .collect(Collectors.toMap(Addon::getId, a -> a));
+
+    private int remoteCalls = 0;
+
+    public TestAddonService(EventPublisher eventPublisher, ConfigurationAdmin configurationAdmin,
+            StorageService storageService) {
+        super(eventPublisher, configurationAdmin, storageService, SERVICE_PID);
+    }
+
+    public void addAddonHandler(MarketplaceAddonHandler handler) {
+        this.addonHandlers.add(handler);
+    }
+
+    public void removeAddonHandler(MarketplaceAddonHandler handler) {
+        this.addonHandlers.remove(handler);
+    }
+
+    @Override
+    protected List getRemoteAddons() {
+        remoteCalls++;
+        return new ArrayList<>(REMOTE_ADDONS.values());
+    }
+
+    @Override
+    public String getId() {
+        return SERVICE_PID;
+    }
+
+    @Override
+    public String getName() {
+        return "Test Addon Service";
+    }
+
+    @Override
+    public @Nullable Addon getAddon(String id, @Nullable Locale locale) {
+        String remoteId = SERVICE_PID + ":" + id;
+        return REMOTE_ADDONS.get(remoteId);
+    }
+
+    @Override
+    public @Nullable String getAddonId(URI addonURI) {
+        return null;
+    }
+
+    public int getRemoteCalls() {
+        return remoteCalls;
+    }
+}
diff --git a/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/VirtualAddonHandler.java b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/VirtualAddonHandler.java
new file mode 100644
index 00000000000..da274da20b1
--- /dev/null
+++ b/bundles/org.openhab.core.addon.marketplace/src/test/java/org/openhab/core/addon/test/VirtualAddonHandler.java
@@ -0,0 +1,63 @@
+/**
+ * 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.core.addon.test;
+
+import static org.openhab.core.addon.test.TestAddonService.INSTALL_EXCEPTION_ADDON;
+import static org.openhab.core.addon.test.TestAddonService.UNINSTALL_EXCEPTION_ADDON;
+
+import java.util.HashSet;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.addon.Addon;
+import org.openhab.core.addon.marketplace.MarketplaceAddonHandler;
+import org.openhab.core.addon.marketplace.MarketplaceHandlerException;
+
+/**
+ * The {@link VirtualAddonHandler} is a
+ *
+ * @author Jan N. Klug - Initial contribution
+ */
+@NonNullByDefault
+public class VirtualAddonHandler implements MarketplaceAddonHandler {
+    private static final Set SUPPORTED_ADDON_TYPES = Set.of("binding", "automation");
+    public static final String TEST_ADDON_CONTENT_TYPE = "testAddonContentType";
+
+    private final Set installedAddons = new HashSet<>();
+
+    @Override
+    public boolean supports(String type, String contentType) {
+        return SUPPORTED_ADDON_TYPES.contains(type) && TEST_ADDON_CONTENT_TYPE.equals(contentType);
+    }
+
+    @Override
+    public boolean isInstalled(String id) {
+        return installedAddons.contains(id);
+    }
+
+    @Override
+    public void install(Addon addon) throws MarketplaceHandlerException {
+        if (INSTALL_EXCEPTION_ADDON.equals(addon.getId())) {
+            throw new MarketplaceHandlerException("Installation failed", null);
+        }
+        installedAddons.add(addon.getId());
+    }
+
+    @Override
+    public void uninstall(Addon addon) throws MarketplaceHandlerException {
+        if (UNINSTALL_EXCEPTION_ADDON.equals(addon.getId())) {
+            throw new MarketplaceHandlerException("Installation failed", null);
+        }
+        installedAddons.remove(addon.getId());
+    }
+}