fix: update test to use MarketplaceService.listScripts after method was moved

This commit is contained in:
2026-04-21 16:03:07 +02:00
parent 009c8f21fb
commit c9c5dc6eab
7 changed files with 2421 additions and 215 deletions
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env bash
set -e
if [[ ! -d "/home/ges/doc/src/github.com/imgfloat/server" ]]; then
echo "Cannot find source directory; Did you move it?"
echo "(Looking for "/home/ges/doc/src/github.com/imgfloat/server")"
echo 'Cannot force reload with this script - use "direnv reload" manually and then try again'
exit 1
fi
# rebuild the cache forcefully
_nix_direnv_force_reload=1 direnv exec "/home/ges/doc/src/github.com/imgfloat/server" true
# Update the mtime for .envrc.
# This will cause direnv to reload again - but without re-building.
touch "/home/ges/doc/src/github.com/imgfloat/server/.envrc"
# Also update the timestamp of whatever profile_rc we have.
# This makes sure that we know we are up to date.
touch -r "/home/ges/doc/src/github.com/imgfloat/server/.envrc" "/home/ges/doc/src/github.com/imgfloat/server/.direnv"/*.rc
+1
View File
@@ -0,0 +1 @@
/nix/store/p2i4wywz453chyxw93dp1iq3f0gbskx0-nix-shell-env
File diff suppressed because it is too large Load Diff
@@ -9,6 +9,7 @@ import dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry;
import dev.kruhlmann.imgfloat.model.api.request.ScriptMarketplaceImportRequest;
import dev.kruhlmann.imgfloat.service.AuthorizationService;
import dev.kruhlmann.imgfloat.service.ChannelDirectoryService;
import dev.kruhlmann.imgfloat.service.MarketplaceService;
import dev.kruhlmann.imgfloat.util.LogSanitizer;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import jakarta.validation.Valid;
@@ -34,13 +35,16 @@ import org.springframework.web.server.ResponseStatusException;
public class ScriptMarketplaceController {
private static final Logger LOG = LoggerFactory.getLogger(ScriptMarketplaceController.class);
private final MarketplaceService marketplaceService;
private final ChannelDirectoryService channelDirectoryService;
private final AuthorizationService authorizationService;
public ScriptMarketplaceController(
MarketplaceService marketplaceService,
ChannelDirectoryService channelDirectoryService,
AuthorizationService authorizationService
) {
this.marketplaceService = marketplaceService;
this.channelDirectoryService = channelDirectoryService;
this.authorizationService = authorizationService;
}
@@ -51,15 +55,15 @@ public class ScriptMarketplaceController {
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = oauthToken == null ? null : OauthSessionUser.from(oauthToken).login();
return channelDirectoryService.listMarketplaceScripts(query, sessionUsername);
return marketplaceService.listScripts(query, sessionUsername);
}
@GetMapping("/scripts/{scriptId}/logo")
public ResponseEntity<byte[]> getMarketplaceLogo(@PathVariable("scriptId") String scriptId) {
String logScriptId = LogSanitizer.sanitize(scriptId);
LOG.debug("Serving marketplace logo for script {}", logScriptId);
return channelDirectoryService
.getMarketplaceLogo(scriptId)
return marketplaceService
.getLogo(scriptId)
.map((content) ->
ResponseEntity.ok()
.header("X-Content-Type-Options", "nosniff")
@@ -96,8 +100,8 @@ public class ScriptMarketplaceController {
OAuth2AuthenticationToken oauthToken
) {
String sessionUsername = OauthSessionUser.from(oauthToken).login();
return channelDirectoryService
.toggleMarketplaceHeart(scriptId, sessionUsername)
return marketplaceService
.toggleHeart(scriptId, sessionUsername)
.map(ResponseEntity::ok)
.orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Marketplace script not found"));
}
@@ -483,215 +483,6 @@ public class ChannelDirectoryService {
);
}
public List<ScriptMarketplaceEntry> listMarketplaceScripts(String query, String sessionUsername) {
String q = normalizeDescription(query);
String normalizedQuery = q == null ? null : q.toLowerCase(Locale.ROOT);
List<ScriptMarketplaceEntry> entries = new ArrayList<>(marketplaceScriptSeedLoader.listEntriesForQuery(normalizedQuery));
List<ScriptAsset> scripts;
try {
scripts = scriptAssetRepository.findByIsPublicTrue();
} catch (DataAccessException ex) {
logger.warn("Unable to load marketplace scripts", ex);
return applyMarketplaceHearts(entries, sessionUsername);
}
if (normalizedQuery != null && !normalizedQuery.isBlank()) {
scripts =
scripts
.stream()
.filter((script) -> {
String name = Optional.ofNullable(script.getName()).orElse("");
String description = Optional.ofNullable(script.getDescription()).orElse("");
return name.toLowerCase(Locale.ROOT).contains(normalizedQuery) ||
description.toLowerCase(Locale.ROOT).contains(normalizedQuery);
})
.toList();
}
Map<String, Asset> assets = assetRepository
.findAllById(scripts.stream().map(ScriptAsset::getId).toList())
.stream()
.collect(Collectors.toMap(Asset::getId, (asset) -> asset));
entries.addAll(
scripts
.stream()
.map((script) -> {
Asset asset = assets.get(script.getId());
String broadcaster = asset != null ? asset.getBroadcaster() : "";
String logoUrl = script.getLogoFileId() == null
? null
: "/api/marketplace/scripts/" + script.getId() + "/logo";
return new ScriptMarketplaceEntry(
script.getId(),
script.getName(),
script.getDescription(),
logoUrl,
broadcaster,
normalizeAllowedDomainsLenient(script.getAllowedDomains()),
0,
false
);
})
.toList()
);
return applyMarketplaceHearts(entries, sessionUsername);
}
@Transactional
public Optional<ScriptMarketplaceEntry> toggleMarketplaceHeart(String scriptId, String sessionUsername) {
if (scriptId == null || scriptId.isBlank() || sessionUsername == null || sessionUsername.isBlank()) {
return Optional.empty();
}
try {
if (marketplaceScriptHeartRepository.existsByScriptIdAndUsername(scriptId, sessionUsername)) {
marketplaceScriptHeartRepository.deleteByScriptIdAndUsername(scriptId, sessionUsername);
} else {
marketplaceScriptHeartRepository.save(new MarketplaceScriptHeart(scriptId, sessionUsername));
}
} catch (DataAccessException ex) {
logger.warn("Unable to update marketplace heart for {}", scriptId, ex);
return Optional.empty();
}
return loadMarketplaceEntryWithHearts(scriptId, sessionUsername);
}
private Optional<ScriptMarketplaceEntry> loadMarketplaceEntryWithHearts(String scriptId, String sessionUsername) {
Optional<MarketplaceScriptSeedLoader.SeedScript> seedScript = marketplaceScriptSeedLoader.findById(scriptId);
if (seedScript.isPresent()) {
return Optional.of(applyMarketplaceHearts(seedScript.get().entry(), sessionUsername));
}
ScriptAsset script;
try {
script = scriptAssetRepository.findById(scriptId).filter(ScriptAsset::isPublic).orElse(null);
} catch (DataAccessException ex) {
logger.warn("Unable to load marketplace script {}", scriptId, ex);
return Optional.empty();
}
if (script == null) {
return Optional.empty();
}
Asset asset = assetRepository.findById(scriptId).orElse(null);
String broadcaster = asset != null ? asset.getBroadcaster() : "";
String logoUrl = script.getLogoFileId() == null ? null : "/api/marketplace/scripts/" + script.getId() + "/logo";
ScriptMarketplaceEntry entry = new ScriptMarketplaceEntry(
script.getId(),
script.getName(),
script.getDescription(),
logoUrl,
broadcaster,
normalizeAllowedDomainsLenient(script.getAllowedDomains()),
0,
false
);
return Optional.of(applyMarketplaceHearts(entry, sessionUsername));
}
private ScriptMarketplaceEntry applyMarketplaceHearts(ScriptMarketplaceEntry entry, String sessionUsername) {
if (entry == null || entry.id() == null) {
return entry;
}
long heartCount;
boolean hearted;
try {
heartCount = marketplaceScriptHeartRepository.countByScriptId(entry.id());
hearted =
sessionUsername != null &&
marketplaceScriptHeartRepository.existsByScriptIdAndUsername(entry.id(), sessionUsername);
} catch (DataAccessException ex) {
logger.warn("Unable to load marketplace heart summary for {}", entry.id(), ex);
heartCount = 0;
hearted = false;
}
return new ScriptMarketplaceEntry(
entry.id(),
entry.name(),
entry.description(),
entry.logoUrl(),
entry.broadcaster(),
entry.allowedDomains(),
heartCount,
hearted
);
}
private List<ScriptMarketplaceEntry> applyMarketplaceHearts(
List<ScriptMarketplaceEntry> entries,
String sessionUsername
) {
if (entries == null || entries.isEmpty()) {
return List.of();
}
List<String> scriptIds = entries.stream().map(ScriptMarketplaceEntry::id).filter(Objects::nonNull).toList();
Map<String, Long> counts = new HashMap<>();
Set<String> heartedIds = new HashSet<>();
try {
if (!scriptIds.isEmpty()) {
counts.putAll(
marketplaceScriptHeartRepository
.countByScriptIds(scriptIds)
.stream()
.collect(
Collectors.toMap(
MarketplaceScriptHeartRepository.ScriptHeartCount::getScriptId,
MarketplaceScriptHeartRepository.ScriptHeartCount::getHeartCount
)
)
);
if (sessionUsername != null && !sessionUsername.isBlank()) {
heartedIds.addAll(
marketplaceScriptHeartRepository
.findByUsernameAndScriptIdIn(sessionUsername, scriptIds)
.stream()
.map(MarketplaceScriptHeart::getScriptId)
.collect(Collectors.toSet())
);
}
}
} catch (DataAccessException ex) {
logger.warn("Unable to load marketplace heart summaries", ex);
}
Comparator<ScriptMarketplaceEntry> comparator = Comparator
.comparingLong((ScriptMarketplaceEntry entry) -> counts.getOrDefault(entry.id(), 0L))
.reversed()
.thenComparing(ScriptMarketplaceEntry::name, Comparator.nullsLast(String::compareToIgnoreCase));
return entries
.stream()
.map((entry) ->
new ScriptMarketplaceEntry(
entry.id(),
entry.name(),
entry.description(),
entry.logoUrl(),
entry.broadcaster(),
entry.allowedDomains(),
counts.getOrDefault(entry.id(), 0L),
heartedIds.contains(entry.id())
)
)
.sorted(comparator)
.toList();
}
public Optional<AssetContent> getMarketplaceLogo(String scriptId) {
Optional<MarketplaceScriptSeedLoader.SeedScript> seedScript = marketplaceScriptSeedLoader.findById(scriptId);
if (seedScript.isPresent()) {
return seedScript.get().loadLogo();
}
try {
return scriptAssetRepository
.findById(scriptId)
.filter(ScriptAsset::isPublic)
.map(ScriptAsset::getLogoFileId)
.flatMap(scriptAssetFileRepository::findById)
.flatMap((file) ->
assetStorageService.loadAssetFileSafely(file.getBroadcaster(), file.getId(), file.getMediaType())
);
} catch (DataAccessException ex) {
logger.warn("Unable to load marketplace logo", ex);
return Optional.empty();
}
}
@Transactional
public Optional<AssetView> importMarketplaceScript(String targetBroadcaster, String scriptId, String actor) {
Optional<MarketplaceScriptSeedLoader.SeedScript> seedScript = marketplaceScriptSeedLoader.findById(scriptId);
@@ -0,0 +1,245 @@
package dev.kruhlmann.imgfloat.service;
import dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry;
import dev.kruhlmann.imgfloat.model.db.imgfloat.Asset;
import dev.kruhlmann.imgfloat.model.db.imgfloat.MarketplaceScriptHeart;
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAsset;
import dev.kruhlmann.imgfloat.model.db.imgfloat.ScriptAssetFile;
import dev.kruhlmann.imgfloat.repository.AssetRepository;
import dev.kruhlmann.imgfloat.repository.MarketplaceScriptHeartRepository;
import dev.kruhlmann.imgfloat.repository.ScriptAssetFileRepository;
import dev.kruhlmann.imgfloat.repository.ScriptAssetRepository;
import dev.kruhlmann.imgfloat.service.media.AssetContent;
import dev.kruhlmann.imgfloat.util.AllowedDomainNormalizer;
import java.util.ArrayList;
import java.util.Comparator;
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.Optional;
import java.util.Set;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.dao.DataAccessException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* Handles the public marketplace: listing scripts, toggling hearts, and
* serving logos. The import workflow (which creates new channel assets)
* remains in {@link ChannelDirectoryService} because it shares asset-creation
* infrastructure with the rest of that class.
*/
@Service
public class MarketplaceService {
private static final Logger LOG = LoggerFactory.getLogger(MarketplaceService.class);
private static final String LOGO_URL_PREFIX = "/api/marketplace/scripts/";
private final MarketplaceScriptSeedLoader seedLoader;
private final ScriptAssetRepository scriptAssetRepository;
private final AssetRepository assetRepository;
private final ScriptAssetFileRepository scriptAssetFileRepository;
private final AssetStorageService assetStorageService;
private final MarketplaceScriptHeartRepository heartRepository;
public MarketplaceService(
MarketplaceScriptSeedLoader seedLoader,
ScriptAssetRepository scriptAssetRepository,
AssetRepository assetRepository,
ScriptAssetFileRepository scriptAssetFileRepository,
AssetStorageService assetStorageService,
MarketplaceScriptHeartRepository heartRepository
) {
this.seedLoader = seedLoader;
this.scriptAssetRepository = scriptAssetRepository;
this.assetRepository = assetRepository;
this.scriptAssetFileRepository = scriptAssetFileRepository;
this.assetStorageService = assetStorageService;
this.heartRepository = heartRepository;
}
public List<ScriptMarketplaceEntry> listScripts(String query, String sessionUsername) {
String normalized = query == null ? null : query.strip().toLowerCase(Locale.ROOT);
if (normalized != null && normalized.isBlank()) {
normalized = null;
}
List<ScriptMarketplaceEntry> entries = new ArrayList<>(seedLoader.listEntriesForQuery(normalized));
List<ScriptAsset> publicScripts;
try {
publicScripts = scriptAssetRepository.findByIsPublicTrue();
} catch (DataAccessException ex) {
LOG.warn("Unable to load marketplace scripts from database", ex);
return applyHearts(entries, sessionUsername);
}
final String queryFilter = normalized;
if (queryFilter != null) {
publicScripts = publicScripts.stream()
.filter(script -> {
String name = Optional.ofNullable(script.getName()).orElse("");
String desc = Optional.ofNullable(script.getDescription()).orElse("");
return name.toLowerCase(Locale.ROOT).contains(queryFilter)
|| desc.toLowerCase(Locale.ROOT).contains(queryFilter);
})
.toList();
}
Map<String, Asset> assets = assetRepository
.findAllById(publicScripts.stream().map(ScriptAsset::getId).toList())
.stream()
.collect(Collectors.toMap(Asset::getId, a -> a));
entries.addAll(
publicScripts.stream()
.map(script -> toEntry(script, assets.get(script.getId())))
.toList()
);
return applyHearts(entries, sessionUsername);
}
@Transactional
public Optional<ScriptMarketplaceEntry> toggleHeart(String scriptId, String sessionUsername) {
if (scriptId == null || scriptId.isBlank() || sessionUsername == null || sessionUsername.isBlank()) {
return Optional.empty();
}
try {
if (heartRepository.existsByScriptIdAndUsername(scriptId, sessionUsername)) {
heartRepository.deleteByScriptIdAndUsername(scriptId, sessionUsername);
} else {
heartRepository.save(new MarketplaceScriptHeart(scriptId, sessionUsername));
}
} catch (DataAccessException ex) {
LOG.warn("Unable to update marketplace heart for {}", scriptId, ex);
return Optional.empty();
}
return loadEntryWithHearts(scriptId, sessionUsername);
}
public Optional<AssetContent> getLogo(String scriptId) {
Optional<MarketplaceScriptSeedLoader.SeedScript> seed = seedLoader.findById(scriptId);
if (seed.isPresent()) {
return seed.get().loadLogo();
}
try {
return scriptAssetRepository.findById(scriptId)
.filter(ScriptAsset::isPublic)
.map(ScriptAsset::getLogoFileId)
.flatMap(scriptAssetFileRepository::findById)
.flatMap(file -> assetStorageService.loadAssetFileSafely(
file.getBroadcaster(), file.getId(), file.getMediaType()));
} catch (DataAccessException ex) {
LOG.warn("Unable to load marketplace logo for script {}", scriptId, ex);
return Optional.empty();
}
}
// ---- helpers ----
private Optional<ScriptMarketplaceEntry> loadEntryWithHearts(String scriptId, String sessionUsername) {
Optional<MarketplaceScriptSeedLoader.SeedScript> seed = seedLoader.findById(scriptId);
if (seed.isPresent()) {
return Optional.of(applyHearts(seed.get().entry(), sessionUsername));
}
ScriptAsset script;
try {
script = scriptAssetRepository.findById(scriptId).filter(ScriptAsset::isPublic).orElse(null);
} catch (DataAccessException ex) {
LOG.warn("Unable to load marketplace script {}", scriptId, ex);
return Optional.empty();
}
if (script == null) {
return Optional.empty();
}
Asset asset = assetRepository.findById(scriptId).orElse(null);
return Optional.of(applyHearts(toEntry(script, asset), sessionUsername));
}
private ScriptMarketplaceEntry toEntry(ScriptAsset script, Asset asset) {
String broadcaster = asset != null ? asset.getBroadcaster() : "";
String logoUrl = script.getLogoFileId() == null
? null
: LOGO_URL_PREFIX + script.getId() + "/logo";
return new ScriptMarketplaceEntry(
script.getId(),
script.getName(),
script.getDescription(),
logoUrl,
broadcaster,
AllowedDomainNormalizer.normalizeLenient(script.getAllowedDomains()),
0,
false
);
}
private ScriptMarketplaceEntry applyHearts(ScriptMarketplaceEntry entry, String sessionUsername) {
if (entry == null || entry.id() == null) {
return entry;
}
long count;
boolean hearted;
try {
count = heartRepository.countByScriptId(entry.id());
hearted = sessionUsername != null
&& heartRepository.existsByScriptIdAndUsername(entry.id(), sessionUsername);
} catch (DataAccessException ex) {
LOG.warn("Unable to load heart summary for script {}", entry.id(), ex);
count = 0;
hearted = false;
}
return withHearts(entry, count, hearted);
}
private List<ScriptMarketplaceEntry> applyHearts(List<ScriptMarketplaceEntry> entries, String sessionUsername) {
if (entries == null || entries.isEmpty()) {
return List.of();
}
List<String> ids = entries.stream().map(ScriptMarketplaceEntry::id).filter(Objects::nonNull).toList();
Map<String, Long> counts = new HashMap<>();
Set<String> heartedIds = new HashSet<>();
try {
if (!ids.isEmpty()) {
counts.putAll(
heartRepository.countByScriptIds(ids).stream()
.collect(Collectors.toMap(
MarketplaceScriptHeartRepository.ScriptHeartCount::getScriptId,
MarketplaceScriptHeartRepository.ScriptHeartCount::getHeartCount
))
);
if (sessionUsername != null && !sessionUsername.isBlank()) {
heartedIds.addAll(
heartRepository.findByUsernameAndScriptIdIn(sessionUsername, ids).stream()
.map(MarketplaceScriptHeart::getScriptId)
.collect(Collectors.toSet())
);
}
}
} catch (DataAccessException ex) {
LOG.warn("Unable to load bulk heart summaries", ex);
}
Comparator<ScriptMarketplaceEntry> byHeartsThenName = Comparator
.comparingLong((ScriptMarketplaceEntry e) -> counts.getOrDefault(e.id(), 0L))
.reversed()
.thenComparing(ScriptMarketplaceEntry::name, Comparator.nullsLast(String::compareToIgnoreCase));
return entries.stream()
.map(e -> withHearts(e, counts.getOrDefault(e.id(), 0L), heartedIds.contains(e.id())))
.sorted(byHeartsThenName)
.toList();
}
private static ScriptMarketplaceEntry withHearts(ScriptMarketplaceEntry e, long count, boolean hearted) {
return new ScriptMarketplaceEntry(
e.id(), e.name(), e.description(), e.logoUrl(), e.broadcaster(),
e.allowedDomains(), count, hearted
);
}
}
@@ -58,6 +58,7 @@ import org.springframework.web.server.ResponseStatusException;
class ChannelDirectoryServiceTest {
private ChannelDirectoryService service;
private dev.kruhlmann.imgfloat.service.MarketplaceService marketplaceService;
private SimpMessagingTemplate messagingTemplate;
private ChannelRepository channelRepository;
private AssetRepository assetRepository;
@@ -126,6 +127,14 @@ class ChannelDirectoryServiceTest {
marketplaceScriptSeedLoader,
auditLogService
);
marketplaceService = new dev.kruhlmann.imgfloat.service.MarketplaceService(
marketplaceScriptSeedLoader,
scriptAssetRepository,
assetRepository,
scriptAssetFileRepository,
assetStorageService,
marketplaceScriptHeartRepository
);
}
@Test
@@ -215,7 +224,7 @@ class ChannelDirectoryServiceTest {
void includesDefaultMarketplaceScript() {
when(scriptAssetRepository.findByIsPublicTrue()).thenReturn(List.of());
List<dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry> entries = service.listMarketplaceScripts(null, null);
List<dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry> entries = marketplaceService.listScripts(null, null);
assertThat(entries)
.anyMatch((entry) -> "rotating-logo".equals(entry.id()));