diff --git a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java index c2c699b..b3609de 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java +++ b/src/main/java/dev/kruhlmann/imgfloat/controller/ChannelApiController.java @@ -17,6 +17,7 @@ import dev.kruhlmann.imgfloat.model.api.response.TwitchUserProfile; import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest; import dev.kruhlmann.imgfloat.service.AuthorizationService; import dev.kruhlmann.imgfloat.service.ChannelDirectoryService; +import dev.kruhlmann.imgfloat.service.ChannelSettingsService; import dev.kruhlmann.imgfloat.service.TwitchUserLookupService; import dev.kruhlmann.imgfloat.util.LogSanitizer; import io.swagger.v3.oas.annotations.security.SecurityRequirement; @@ -60,6 +61,7 @@ public class ChannelApiController { private static final Logger LOG = LoggerFactory.getLogger(ChannelApiController.class); private final ChannelDirectoryService channelDirectoryService; + private final ChannelSettingsService channelSettingsService; private final OAuth2AuthorizedClientService authorizedClientService; private final OAuth2AuthorizedClientRepository authorizedClientRepository; private final TwitchUserLookupService twitchUserLookupService; @@ -67,12 +69,14 @@ public class ChannelApiController { public ChannelApiController( ChannelDirectoryService channelDirectoryService, + ChannelSettingsService channelSettingsService, OAuth2AuthorizedClientService authorizedClientService, OAuth2AuthorizedClientRepository authorizedClientRepository, TwitchUserLookupService twitchUserLookupService, AuthorizationService authorizationService ) { this.channelDirectoryService = channelDirectoryService; + this.channelSettingsService = channelSettingsService; this.authorizedClientService = authorizedClientService; this.authorizedClientRepository = authorizedClientRepository; this.twitchUserLookupService = twitchUserLookupService; @@ -192,7 +196,7 @@ public class ChannelApiController { @GetMapping("/canvas") public CanvasSettingsRequest getCanvas(@PathVariable("broadcaster") String broadcaster) { - return channelDirectoryService.getCanvasSettings(broadcaster); + return channelSettingsService.getCanvasSettings(broadcaster); } @PutMapping("/canvas") @@ -212,12 +216,12 @@ public class ChannelApiController { request.getWidth(), request.getHeight() ); - return channelDirectoryService.updateCanvasSettings(broadcaster, request, sessionUsername); + return channelSettingsService.updateCanvasSettings(broadcaster, request, sessionUsername); } @GetMapping("/settings") public ChannelScriptSettingsRequest getScriptSettings(@PathVariable("broadcaster") String broadcaster) { - return channelDirectoryService.getChannelScriptSettings(broadcaster); + return channelSettingsService.getChannelScriptSettings(broadcaster); } @PutMapping("/settings") @@ -231,7 +235,7 @@ public class ChannelApiController { String logSessionUsername = LogSanitizer.sanitize(sessionUsername); authorizationService.userMatchesSessionUsernameOrThrowHttpError(broadcaster, sessionUsername); LOG.info("Updating script settings for {} by {}", logBroadcaster, logSessionUsername); - return channelDirectoryService.updateChannelScriptSettings(broadcaster, request, sessionUsername); + return channelSettingsService.updateChannelScriptSettings(broadcaster, request, sessionUsername); } @PostMapping(value = "/assets", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java index ef86cbf..65b540c 100644 --- a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelDirectoryService.java @@ -5,8 +5,6 @@ import static org.springframework.http.HttpStatus.PAYLOAD_TOO_LARGE; import dev.kruhlmann.imgfloat.model.AssetType; import dev.kruhlmann.imgfloat.model.api.request.AssetOrderRequest; -import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest; -import dev.kruhlmann.imgfloat.model.api.request.ChannelScriptSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest; import dev.kruhlmann.imgfloat.model.api.request.PlaybackRequest; import dev.kruhlmann.imgfloat.model.api.request.TransformRequest; @@ -14,7 +12,6 @@ import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest; import dev.kruhlmann.imgfloat.model.api.response.AssetEvent; import dev.kruhlmann.imgfloat.model.api.response.AssetPatch; import dev.kruhlmann.imgfloat.model.api.response.AssetView; -import dev.kruhlmann.imgfloat.model.api.response.CanvasEvent; import dev.kruhlmann.imgfloat.model.api.response.ScriptAssetAttachmentView; import dev.kruhlmann.imgfloat.model.api.response.ScriptMarketplaceEntry; import dev.kruhlmann.imgfloat.model.db.imgfloat.Asset; @@ -198,162 +195,6 @@ public class ChannelDirectoryService { .toList(); } - public CanvasSettingsRequest getCanvasSettings(String broadcaster) { - Channel channel = getOrCreateChannel(broadcaster); - return new CanvasSettingsRequest( - channel.getCanvasWidth(), - channel.getCanvasHeight(), - channel.getMaxVolumeDb() - ); - } - - public CanvasSettingsRequest updateCanvasSettings(String broadcaster, CanvasSettingsRequest req, String actor) { - validateCanvasSettings(req); - Channel channel = getOrCreateChannel(broadcaster); - double beforeWidth = channel.getCanvasWidth(); - double beforeHeight = channel.getCanvasHeight(); - Double beforeMaxVolumeDb = channel.getMaxVolumeDb(); - channel.setCanvasWidth(req.getWidth()); - channel.setCanvasHeight(req.getHeight()); - if (req.getMaxVolumeDb() != null) { - channel.setMaxVolumeDb(req.getMaxVolumeDb()); - } - channelRepository.save(channel); - CanvasSettingsRequest response = new CanvasSettingsRequest( - channel.getCanvasWidth(), - channel.getCanvasHeight(), - channel.getMaxVolumeDb() - ); - messagingTemplate.convertAndSend(topicFor(broadcaster), CanvasEvent.updated(broadcaster, response)); - if ( - beforeWidth != channel.getCanvasWidth() || - beforeHeight != channel.getCanvasHeight() || - !Objects.equals(beforeMaxVolumeDb, channel.getMaxVolumeDb()) - ) { - List changes = new ArrayList<>(); - if (beforeWidth != channel.getCanvasWidth() || beforeHeight != channel.getCanvasHeight()) { - changes.add( - String.format( - Locale.ROOT, - "canvas %.0fx%.0f -> %.0fx%.0f", - beforeWidth, - beforeHeight, - channel.getCanvasWidth(), - channel.getCanvasHeight() - ) - ); - } - if (!Objects.equals(beforeMaxVolumeDb, channel.getMaxVolumeDb())) { - changes.add( - String.format( - Locale.ROOT, - "max volume %.0f dB -> %.0f dB", - beforeMaxVolumeDb == null ? 0.0 : beforeMaxVolumeDb, - channel.getMaxVolumeDb() == null ? 0.0 : channel.getMaxVolumeDb() - ) - ); - } - auditLogService.recordEntry( - channel.getBroadcaster(), - actor, - "CANVAS_UPDATED", - "Canvas settings updated" + (changes.isEmpty() ? "" : " (" + String.join(", ", changes) + ")") - ); - } - return response; - } - - private void validateCanvasSettings(CanvasSettingsRequest req) { - Settings settings = settingsService.get(); - int canvasMaxSizePixels = settings.getMaxCanvasSideLengthPixels(); - - if ( - req.getWidth() <= 0 || - req.getWidth() > canvasMaxSizePixels || - !Double.isFinite(req.getWidth()) || - req.getWidth() % 1 != 0 - ) throw new ResponseStatusException( - BAD_REQUEST, - "Canvas width must be a whole number within [1 to " + canvasMaxSizePixels + "]" - ); - if ( - req.getHeight() <= 0 || - req.getHeight() > canvasMaxSizePixels || - !Double.isFinite(req.getHeight()) || - req.getHeight() % 1 != 0 - ) throw new ResponseStatusException( - BAD_REQUEST, - "Canvas height must be a whole number within [1 to " + canvasMaxSizePixels + "]" - ); - if (req.getMaxVolumeDb() != null) { - double maxVolumeDb = req.getMaxVolumeDb(); - if (!Double.isFinite(maxVolumeDb) || maxVolumeDb < -60 || maxVolumeDb > 0) { - throw new ResponseStatusException( - BAD_REQUEST, - "Max volume must be within [-60 to 0] dB" - ); - } - } - } - - public ChannelScriptSettingsRequest getChannelScriptSettings(String broadcaster) { - Channel channel = getOrCreateChannel(broadcaster); - return new ChannelScriptSettingsRequest( - channel.isAllowChannelEmotesForAssets(), - channel.isAllowSevenTvEmotesForAssets(), - channel.isAllowScriptChatAccess() - ); - } - - public ChannelScriptSettingsRequest updateChannelScriptSettings( - String broadcaster, - ChannelScriptSettingsRequest request, - String actor - ) { - Channel channel = getOrCreateChannel(broadcaster); - boolean beforeChannelEmotes = channel.isAllowChannelEmotesForAssets(); - boolean beforeSevenTv = channel.isAllowSevenTvEmotesForAssets(); - boolean beforeChatAccess = channel.isAllowScriptChatAccess(); - channel.setAllowChannelEmotesForAssets(request.isAllowChannelEmotesForAssets()); - channel.setAllowSevenTvEmotesForAssets(request.isAllowSevenTvEmotesForAssets()); - channel.setAllowScriptChatAccess(request.isAllowScriptChatAccess()); - channelRepository.save(channel); - if ( - beforeChannelEmotes != channel.isAllowChannelEmotesForAssets() || - beforeSevenTv != channel.isAllowSevenTvEmotesForAssets() || - beforeChatAccess != channel.isAllowScriptChatAccess() - ) { - List changes = new ArrayList<>(); - if (beforeChannelEmotes != channel.isAllowChannelEmotesForAssets()) { - changes.add( - "channelEmotes: " + beforeChannelEmotes + " -> " + channel.isAllowChannelEmotesForAssets() - ); - } - if (beforeSevenTv != channel.isAllowSevenTvEmotesForAssets()) { - changes.add( - "sevenTvEmotes: " + beforeSevenTv + " -> " + channel.isAllowSevenTvEmotesForAssets() - ); - } - if (beforeChatAccess != channel.isAllowScriptChatAccess()) { - changes.add( - "scriptChatAccess: " + beforeChatAccess + " -> " + channel.isAllowScriptChatAccess() - ); - } - String detailSuffix = changes.isEmpty() ? "" : " (" + String.join(", ", changes) + ")"; - auditLogService.recordEntry( - channel.getBroadcaster(), - actor, - "SCRIPT_SETTINGS_UPDATED", - "Script settings updated" + detailSuffix - ); - } - return new ChannelScriptSettingsRequest( - channel.isAllowChannelEmotesForAssets(), - channel.isAllowSevenTvEmotesForAssets(), - channel.isAllowScriptChatAccess() - ); - } - @Transactional(rollbackFor = IOException.class) public Optional createAsset(String broadcaster, MultipartFile file, String actor) throws IOException { long fileSize = file.getSize(); diff --git a/src/main/java/dev/kruhlmann/imgfloat/service/ChannelSettingsService.java b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelSettingsService.java new file mode 100644 index 0000000..2bfa297 --- /dev/null +++ b/src/main/java/dev/kruhlmann/imgfloat/service/ChannelSettingsService.java @@ -0,0 +1,203 @@ +package dev.kruhlmann.imgfloat.service; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest; +import dev.kruhlmann.imgfloat.model.api.request.ChannelScriptSettingsRequest; +import dev.kruhlmann.imgfloat.model.api.response.CanvasEvent; +import dev.kruhlmann.imgfloat.model.db.imgfloat.Channel; +import dev.kruhlmann.imgfloat.model.db.imgfloat.Settings; +import dev.kruhlmann.imgfloat.repository.ChannelRepository; +import java.util.ArrayList; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +/** + * Manages per-channel canvas and script-overlay settings. + * Extracted from {@link ChannelDirectoryService} to give it a focused responsibility. + */ +@Service +public class ChannelSettingsService { + + private final ChannelRepository channelRepository; + private final SettingsService settingsService; + private final SimpMessagingTemplate messagingTemplate; + private final AuditLogService auditLogService; + + public ChannelSettingsService( + ChannelRepository channelRepository, + SettingsService settingsService, + SimpMessagingTemplate messagingTemplate, + AuditLogService auditLogService + ) { + this.channelRepository = channelRepository; + this.settingsService = settingsService; + this.messagingTemplate = messagingTemplate; + this.auditLogService = auditLogService; + } + + public CanvasSettingsRequest getCanvasSettings(String broadcaster) { + Channel channel = getOrCreateChannel(broadcaster); + return new CanvasSettingsRequest( + channel.getCanvasWidth(), + channel.getCanvasHeight(), + channel.getMaxVolumeDb() + ); + } + + public CanvasSettingsRequest updateCanvasSettings(String broadcaster, CanvasSettingsRequest req, String actor) { + validateCanvasSettings(req); + Channel channel = getOrCreateChannel(broadcaster); + double beforeWidth = channel.getCanvasWidth(); + double beforeHeight = channel.getCanvasHeight(); + Double beforeMaxVolumeDb = channel.getMaxVolumeDb(); + + channel.setCanvasWidth(req.getWidth()); + channel.setCanvasHeight(req.getHeight()); + if (req.getMaxVolumeDb() != null) { + channel.setMaxVolumeDb(req.getMaxVolumeDb()); + } + channelRepository.save(channel); + + CanvasSettingsRequest response = new CanvasSettingsRequest( + channel.getCanvasWidth(), + channel.getCanvasHeight(), + channel.getMaxVolumeDb() + ); + messagingTemplate.convertAndSend(topicFor(broadcaster), CanvasEvent.updated(broadcaster, response)); + + boolean changed = + beforeWidth != channel.getCanvasWidth() || + beforeHeight != channel.getCanvasHeight() || + !Objects.equals(beforeMaxVolumeDb, channel.getMaxVolumeDb()); + + if (changed) { + List changes = new ArrayList<>(); + if (beforeWidth != channel.getCanvasWidth() || beforeHeight != channel.getCanvasHeight()) { + changes.add( + String.format( + Locale.ROOT, + "canvas %.0fx%.0f -> %.0fx%.0f", + beforeWidth, + beforeHeight, + channel.getCanvasWidth(), + channel.getCanvasHeight() + ) + ); + } + if (!Objects.equals(beforeMaxVolumeDb, channel.getMaxVolumeDb())) { + changes.add( + String.format( + Locale.ROOT, + "max volume %.0f dB -> %.0f dB", + beforeMaxVolumeDb == null ? 0.0 : beforeMaxVolumeDb, + channel.getMaxVolumeDb() == null ? 0.0 : channel.getMaxVolumeDb() + ) + ); + } + auditLogService.recordEntry( + channel.getBroadcaster(), + actor, + "CANVAS_UPDATED", + "Canvas settings updated" + (changes.isEmpty() ? "" : " (" + String.join(", ", changes) + ")") + ); + } + return response; + } + + public ChannelScriptSettingsRequest getChannelScriptSettings(String broadcaster) { + Channel channel = getOrCreateChannel(broadcaster); + return new ChannelScriptSettingsRequest( + channel.isAllowChannelEmotesForAssets(), + channel.isAllowSevenTvEmotesForAssets(), + channel.isAllowScriptChatAccess() + ); + } + + public ChannelScriptSettingsRequest updateChannelScriptSettings( + String broadcaster, + ChannelScriptSettingsRequest request, + String actor + ) { + Channel channel = getOrCreateChannel(broadcaster); + boolean beforeChannelEmotes = channel.isAllowChannelEmotesForAssets(); + boolean beforeSevenTv = channel.isAllowSevenTvEmotesForAssets(); + boolean beforeChatAccess = channel.isAllowScriptChatAccess(); + + channel.setAllowChannelEmotesForAssets(request.isAllowChannelEmotesForAssets()); + channel.setAllowSevenTvEmotesForAssets(request.isAllowSevenTvEmotesForAssets()); + channel.setAllowScriptChatAccess(request.isAllowScriptChatAccess()); + channelRepository.save(channel); + + boolean changed = + beforeChannelEmotes != channel.isAllowChannelEmotesForAssets() || + beforeSevenTv != channel.isAllowSevenTvEmotesForAssets() || + beforeChatAccess != channel.isAllowScriptChatAccess(); + + if (changed) { + List changes = new ArrayList<>(); + if (beforeChannelEmotes != channel.isAllowChannelEmotesForAssets()) { + changes.add("channelEmotes: " + beforeChannelEmotes + " -> " + channel.isAllowChannelEmotesForAssets()); + } + if (beforeSevenTv != channel.isAllowSevenTvEmotesForAssets()) { + changes.add("sevenTvEmotes: " + beforeSevenTv + " -> " + channel.isAllowSevenTvEmotesForAssets()); + } + if (beforeChatAccess != channel.isAllowScriptChatAccess()) { + changes.add("scriptChatAccess: " + beforeChatAccess + " -> " + channel.isAllowScriptChatAccess()); + } + auditLogService.recordEntry( + channel.getBroadcaster(), + actor, + "SCRIPT_SETTINGS_UPDATED", + "Script settings updated" + (changes.isEmpty() ? "" : " (" + String.join(", ", changes) + ")") + ); + } + return new ChannelScriptSettingsRequest( + channel.isAllowChannelEmotesForAssets(), + channel.isAllowSevenTvEmotesForAssets(), + channel.isAllowScriptChatAccess() + ); + } + + private void validateCanvasSettings(CanvasSettingsRequest req) { + Settings settings = settingsService.get(); + int max = settings.getMaxCanvasSideLengthPixels(); + + if (req.getWidth() <= 0 || req.getWidth() > max || !Double.isFinite(req.getWidth()) || req.getWidth() % 1 != 0) { + throw new ResponseStatusException( + BAD_REQUEST, + "Canvas width must be a whole number within [1 to " + max + "]" + ); + } + if (req.getHeight() <= 0 || req.getHeight() > max || !Double.isFinite(req.getHeight()) || req.getHeight() % 1 != 0) { + throw new ResponseStatusException( + BAD_REQUEST, + "Canvas height must be a whole number within [1 to " + max + "]" + ); + } + if (req.getMaxVolumeDb() != null) { + double db = req.getMaxVolumeDb(); + if (!Double.isFinite(db) || db < -60 || db > 0) { + throw new ResponseStatusException(BAD_REQUEST, "Max volume must be within [-60 to 0] dB"); + } + } + } + + private Channel getOrCreateChannel(String broadcaster) { + String normalized = normalize(broadcaster); + return channelRepository.findById(normalized) + .orElseGet(() -> channelRepository.save(new Channel(normalized))); + } + + private String topicFor(String broadcaster) { + return "/topic/channel/" + normalize(broadcaster); + } + + private String normalize(String value) { + return value == null ? null : value.toLowerCase(Locale.ROOT); + } +} diff --git a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java index 2a0ac59..b2a5062 100644 --- a/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java +++ b/src/test/java/dev/kruhlmann/imgfloat/ChannelDirectoryServiceTest.java @@ -11,7 +11,6 @@ import static org.mockito.Mockito.when; import dev.kruhlmann.imgfloat.model.AssetType; import dev.kruhlmann.imgfloat.model.api.request.CodeAssetRequest; -import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest; import dev.kruhlmann.imgfloat.model.api.request.TransformRequest; import dev.kruhlmann.imgfloat.model.api.request.VisibilityRequest; import dev.kruhlmann.imgfloat.model.api.response.AssetView; @@ -247,17 +246,6 @@ class ChannelDirectoryServiceTest { assertThat(saved.allowedDomains()).isEmpty(); } - @Test - void updatesCanvasMaxVolumeDb() { - CanvasSettingsRequest request = new CanvasSettingsRequest(1920, 1080, -12.0); - - CanvasSettingsRequest saved = service.updateCanvasSettings("caster", request, "caster"); - - assertThat(saved.getMaxVolumeDb()).isEqualTo(-12.0); - Channel channel = channelRepository.findById("caster").orElseThrow(); - assertThat(channel.getMaxVolumeDb()).isEqualTo(-12.0); - } - private byte[] samplePng() throws IOException { BufferedImage image = new BufferedImage(2, 2, BufferedImage.TYPE_INT_ARGB); ByteArrayOutputStream out = new ByteArrayOutputStream(); diff --git a/src/test/java/dev/kruhlmann/imgfloat/service/ChannelSettingsServiceTest.java b/src/test/java/dev/kruhlmann/imgfloat/service/ChannelSettingsServiceTest.java new file mode 100644 index 0000000..39c2983 --- /dev/null +++ b/src/test/java/dev/kruhlmann/imgfloat/service/ChannelSettingsServiceTest.java @@ -0,0 +1,143 @@ +package dev.kruhlmann.imgfloat.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +import dev.kruhlmann.imgfloat.model.api.request.CanvasSettingsRequest; +import dev.kruhlmann.imgfloat.model.api.request.ChannelScriptSettingsRequest; +import dev.kruhlmann.imgfloat.model.db.imgfloat.Channel; +import dev.kruhlmann.imgfloat.model.db.imgfloat.Settings; +import dev.kruhlmann.imgfloat.repository.ChannelRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +class ChannelSettingsServiceTest { + + private ChannelRepository channelRepository; + private SettingsService settingsService; + private SimpMessagingTemplate messagingTemplate; + private AuditLogService auditLogService; + private ChannelSettingsService service; + + private final ConcurrentHashMap channels = new ConcurrentHashMap<>(); + + @BeforeEach + void setup() { + channelRepository = mock(ChannelRepository.class); + settingsService = mock(SettingsService.class); + messagingTemplate = mock(SimpMessagingTemplate.class); + auditLogService = mock(AuditLogService.class); + + when(settingsService.get()).thenReturn(Settings.defaults()); + when(channelRepository.findById(anyString())).thenAnswer(inv -> + Optional.ofNullable(channels.get(inv.getArgument(0)))); + when(channelRepository.save(any(Channel.class))).thenAnswer(inv -> { + Channel ch = inv.getArgument(0); + channels.put(ch.getBroadcaster(), ch); + return ch; + }); + + service = new ChannelSettingsService(channelRepository, settingsService, messagingTemplate, auditLogService); + } + + // --- canvas settings --- + + @Test + void getCanvasSettingsReturnsDefaults() { + CanvasSettingsRequest result = service.getCanvasSettings("caster"); + assertThat(result.getWidth()).isGreaterThan(0); + assertThat(result.getHeight()).isGreaterThan(0); + } + + @Test + void updateCanvasPersistsAndBroadcasts() { + CanvasSettingsRequest req = new CanvasSettingsRequest(1920, 1080, -12.0); + CanvasSettingsRequest result = service.updateCanvasSettings("caster", req, "caster"); + + assertThat(result.getWidth()).isEqualTo(1920); + assertThat(result.getHeight()).isEqualTo(1080); + assertThat(result.getMaxVolumeDb()).isEqualTo(-12.0); + verify(messagingTemplate).convertAndSend(anyString(), any(Object.class)); + Channel channel = channels.get("caster"); + assertThat(channel.getMaxVolumeDb()).isEqualTo(-12.0); + } + + @Test + void updateCanvasRecordsAuditEntry() { + service.updateCanvasSettings("caster", new CanvasSettingsRequest(1280, 720, null), "admin"); + verify(auditLogService).recordEntry(eq("caster"), eq("admin"), eq("CANVAS_UPDATED"), anyString()); + } + + @Test + void updateCanvasNoAuditEntryWhenNothingChanged() { + service.updateCanvasSettings("caster", new CanvasSettingsRequest(1280, 720, null), "admin"); + clearInvocations(auditLogService); + // Second call with same values — nothing changed, no audit + service.updateCanvasSettings("caster", new CanvasSettingsRequest(1280, 720, null), "admin"); + verify(auditLogService, never()).recordEntry(any(), any(), any(), any()); + } + + @Test + void updateCanvasRejectsZeroWidth() { + assertThatThrownBy(() -> service.updateCanvasSettings("caster", new CanvasSettingsRequest(0, 720, null), "admin")) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("width"); + } + + @Test + void updateCanvasRejectsInvalidVolumeDb() { + assertThatThrownBy(() -> service.updateCanvasSettings("caster", new CanvasSettingsRequest(1280, 720, 5.0), "admin")) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("volume"); + } + + @Test + void updateCanvasAcceptsBoundaryVolumeValues() { + // -60 and 0 are the inclusive bounds + assertThat(service.updateCanvasSettings("caster", new CanvasSettingsRequest(1280, 720, -60.0), "admin") + .getMaxVolumeDb()).isEqualTo(-60.0); + assertThat(service.updateCanvasSettings("caster", new CanvasSettingsRequest(1280, 720, 0.0), "admin") + .getMaxVolumeDb()).isEqualTo(0.0); + } + + // --- script settings --- + + @Test + void getScriptSettingsReturnsDefaults() { + ChannelScriptSettingsRequest result = service.getChannelScriptSettings("caster"); + assertThat(result).isNotNull(); + } + + @Test + void updateScriptSettingsPersistsChanges() { + ChannelScriptSettingsRequest req = new ChannelScriptSettingsRequest(true, false, true); + ChannelScriptSettingsRequest result = service.updateChannelScriptSettings("caster", req, "admin"); + + assertThat(result.isAllowChannelEmotesForAssets()).isTrue(); + assertThat(result.isAllowSevenTvEmotesForAssets()).isFalse(); + assertThat(result.isAllowScriptChatAccess()).isTrue(); + } + + @Test + void updateScriptSettingsRecordsAuditEntry() { + service.updateChannelScriptSettings("caster", new ChannelScriptSettingsRequest(true, false, true), "admin"); + verify(auditLogService).recordEntry(eq("caster"), eq("admin"), eq("SCRIPT_SETTINGS_UPDATED"), anyString()); + } + + @Test + void updateScriptSettingsNoAuditWhenNothingChanged() { + service.updateChannelScriptSettings("caster", new ChannelScriptSettingsRequest(false, false, false), "admin"); + clearInvocations(auditLogService); + service.updateChannelScriptSettings("caster", new ChannelScriptSettingsRequest(false, false, false), "admin"); + verify(auditLogService, never()).recordEntry(any(), any(), any(), any()); + } +}