diff --git a/README.MD b/README.MD index 5640388..cf1fd96 100644 --- a/README.MD +++ b/README.MD @@ -23,6 +23,7 @@ problem! - [Get the current player state](docs/How-To-Fetch-Player-State.MD) - [Start or resume playback](docs/How-To-Start-Playback.MD) - [Pause playback](docs/How-To-Pause-Playback.MD) +- [Change playback volume](docs/How-To-Change-Volume.MD) # Device authentication diff --git a/docs/How-To-Change-Volume.MD b/docs/How-To-Change-Volume.MD new file mode 100644 index 0000000..39e7169 --- /dev/null +++ b/docs/How-To-Change-Volume.MD @@ -0,0 +1,26 @@ +### Change volume of the player + +Set the volume for the user’s current playback device. + +Note: the order of the commands are not guaranteed while using Player API. + +#### Request + +```http request +PUT /player/volume +Authorization: Bearer user_access_token +``` + +Query parameters: +- volume_percent - an integer from 0 to 100, where 0 means that device is muted and 100 max volume + +#### Response + +Status: +- 204 No Content - command has been received +- 400 Bad Request - invalid body, request or there is no active device. All responses with this status code have 'reason_code' in body, that can be used to determine type of the error +- 500 Server Error - should never happen, but if so - please, create a new GitHub issue that can be used to reproduce the issue. + +See the [tests](../src/test/java/com/odeyalo/sonata/connect/controller/ChangePlayerVolumeEndpointTest.java) for further info + + diff --git a/src/main/java/com/odeyalo/sonata/connect/controller/PlayerController.java b/src/main/java/com/odeyalo/sonata/connect/controller/PlayerController.java index abeb1f5..4cb56c4 100644 --- a/src/main/java/com/odeyalo/sonata/connect/controller/PlayerController.java +++ b/src/main/java/com/odeyalo/sonata/connect/controller/PlayerController.java @@ -91,6 +91,13 @@ public Mono> switchDevices(User user, @RequestBody DeviceSwitc .thenReturn(default204Response()); } + @PutMapping(value = "/volume", produces = MediaType.APPLICATION_JSON_VALUE) + public Mono> changePlayerVolume(@NotNull final Volume volume, + @NotNull final User user) { + return playerOperations.changeVolume(user, volume) + .map(it -> default204Response()); + } + @DeleteMapping(value = "/device") public Mono> disconnectDevice(@RequestParam("device_id") String deviceId, User user) { return playerOperations.getDeviceOperations() @@ -98,6 +105,7 @@ public Mono> disconnectDevice(@RequestParam("device_id") Strin .thenReturn(default204Response()); } + @NotNull private CurrentlyPlayingPlayerStateDto convertToCurrentlyPlayingStateDto(CurrentlyPlayingPlayerState state) { return currentlyPlayingPlayerStateDtoConverter.convertTo(state); } diff --git a/src/main/java/com/odeyalo/sonata/connect/dto/PlayerStateDto.java b/src/main/java/com/odeyalo/sonata/connect/dto/PlayerStateDto.java index f0cd612..c7918e8 100644 --- a/src/main/java/com/odeyalo/sonata/connect/dto/PlayerStateDto.java +++ b/src/main/java/com/odeyalo/sonata/connect/dto/PlayerStateDto.java @@ -19,6 +19,7 @@ public class PlayerStateDto { RepeatState repeatState = RepeatState.OFF; @JsonProperty("shuffle_state") boolean shuffleState; + int volume; @JsonProperty("currently_playing_type") String currentlyPlayingType; @JsonProperty("progress_ms") diff --git a/src/main/java/com/odeyalo/sonata/connect/entity/factory/DefaultPlayerStateEntityFactory.java b/src/main/java/com/odeyalo/sonata/connect/entity/factory/DefaultPlayerStateEntityFactory.java index 89a9ffd..0fdc4cf 100644 --- a/src/main/java/com/odeyalo/sonata/connect/entity/factory/DefaultPlayerStateEntityFactory.java +++ b/src/main/java/com/odeyalo/sonata/connect/entity/factory/DefaultPlayerStateEntityFactory.java @@ -26,6 +26,7 @@ public PlayerStateEntity create(@NotNull final CurrentPlayerState state) { final PlayerStateEntity.PlayerStateEntityBuilder builder = PlayerStateEntity.builder() .id(state.getId()) .playing(state.isPlaying()) + .volume(state.getVolume().asInt()) .repeatState(state.getRepeatState()) .shuffleState(state.getShuffleState()) .user(UserEntity.builder().id(state.getUser().getId()).build()) diff --git a/src/main/java/com/odeyalo/sonata/connect/exception/GlobalExceptionHandlerController.java b/src/main/java/com/odeyalo/sonata/connect/exception/GlobalExceptionHandlerController.java index 2095620..d15d3fb 100644 --- a/src/main/java/com/odeyalo/sonata/connect/exception/GlobalExceptionHandlerController.java +++ b/src/main/java/com/odeyalo/sonata/connect/exception/GlobalExceptionHandlerController.java @@ -64,4 +64,10 @@ public ResponseEntity handleMalformedContextUriException(MalformedContextUriE ExceptionMessage message = ExceptionMessage.of(ex.getMessage()); return badRequest().body(message); } + + @ExceptionHandler(InvalidVolumeException.class) + public ResponseEntity handleInvalidVolumeException(final InvalidVolumeException ex) { + return ResponseEntity.badRequest() + .body(ReasonCodeAwareExceptionMessage.of("invalid_volume", ex.getMessage())); + } } diff --git a/src/main/java/com/odeyalo/sonata/connect/exception/InvalidVolumeException.java b/src/main/java/com/odeyalo/sonata/connect/exception/InvalidVolumeException.java new file mode 100644 index 0000000..f200dfe --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/connect/exception/InvalidVolumeException.java @@ -0,0 +1,28 @@ +package com.odeyalo.sonata.connect.exception; + +public final class InvalidVolumeException extends RuntimeException { + + public static InvalidVolumeException withCustomMessage(final String message) { + return new InvalidVolumeException(message); + } + + public static InvalidVolumeException withMessageAndCause(final String message, final Throwable cause) { + return new InvalidVolumeException(message, cause); + } + + public InvalidVolumeException() { + super(); + } + + public InvalidVolumeException(final String message) { + super(message); + } + + public InvalidVolumeException(final String message, final Throwable cause) { + super(message, cause); + } + + public InvalidVolumeException(final Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/odeyalo/sonata/connect/exception/NoActiveDeviceException.java b/src/main/java/com/odeyalo/sonata/connect/exception/NoActiveDeviceException.java index b066784..f0a6a5d 100644 --- a/src/main/java/com/odeyalo/sonata/connect/exception/NoActiveDeviceException.java +++ b/src/main/java/com/odeyalo/sonata/connect/exception/NoActiveDeviceException.java @@ -1,13 +1,42 @@ package com.odeyalo.sonata.connect.exception; import lombok.Getter; -import lombok.experimental.StandardException; +import org.jetbrains.annotations.NotNull; /** * Exception that should be thrown when there is no active device found for the given user */ -@StandardException @Getter -public class NoActiveDeviceException extends RuntimeException implements ReasonCodeAware { - final String reasonCode = "no_active_device"; +public class NoActiveDeviceException extends PlayerCommandException { + static final String REASON_CODE = "no_active_device"; + static final String DEFAULT_MESSAGE = "At least one connected device is required to execute this command"; + + @NotNull + public static NoActiveDeviceException defaultException() { + return withCustomMessage(DEFAULT_MESSAGE); + } + + @NotNull + public static NoActiveDeviceException withCustomMessage(@NotNull final String message) { + return new NoActiveDeviceException(message); + } + + @NotNull + public static NoActiveDeviceException withMessageAndCause(@NotNull final String message, + @NotNull final Throwable cause) { + return new NoActiveDeviceException(message, cause); + } + + public NoActiveDeviceException() { + super(DEFAULT_MESSAGE, REASON_CODE); + } + + public NoActiveDeviceException(@NotNull final String message) { + super(message, REASON_CODE); + } + + private NoActiveDeviceException(@NotNull final String message, + @NotNull final Throwable cause) { + super(message, REASON_CODE, cause); + } } \ No newline at end of file diff --git a/src/main/java/com/odeyalo/sonata/connect/exception/PlayerCommandException.java b/src/main/java/com/odeyalo/sonata/connect/exception/PlayerCommandException.java new file mode 100644 index 0000000..1a8fc44 --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/connect/exception/PlayerCommandException.java @@ -0,0 +1,20 @@ +package com.odeyalo.sonata.connect.exception; + +public class PlayerCommandException extends RuntimeException implements ReasonCodeAware { + private final String reasonCode; + + public PlayerCommandException(final String message, final String reasonCode) { + super(message); + this.reasonCode = reasonCode; + } + + public PlayerCommandException(final String message, final String reasonCode, final Throwable cause) { + super(message, cause); + this.reasonCode = reasonCode; + } + + @Override + public String getReasonCode() { + return reasonCode; + } +} diff --git a/src/main/java/com/odeyalo/sonata/connect/model/CurrentPlayerState.java b/src/main/java/com/odeyalo/sonata/connect/model/CurrentPlayerState.java index 417c10b..928ad5f 100644 --- a/src/main/java/com/odeyalo/sonata/connect/model/CurrentPlayerState.java +++ b/src/main/java/com/odeyalo/sonata/connect/model/CurrentPlayerState.java @@ -1,10 +1,8 @@ package com.odeyalo.sonata.connect.model; import com.odeyalo.sonata.connect.service.player.TargetDeactivationDevice; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Value; -import lombok.With; +import com.odeyalo.sonata.connect.service.player.TargetDevice; +import lombok.*; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; @@ -15,7 +13,7 @@ */ @Value @AllArgsConstructor(staticName = "of") -@Builder +@Builder(toBuilder = true) @With public class CurrentPlayerState { long id; @@ -37,6 +35,9 @@ public class CurrentPlayerState { PlayableItem playableItem; @NotNull User user; + @NotNull + @Builder.Default + Volume volume = Volume.muted(); long lastPauseTime = 0; long playStartTime = 0; @@ -48,10 +49,12 @@ public static CurrentPlayerState emptyFor(@NotNull final User user) { .build(); } + @NotNull public ShuffleMode getShuffleState() { return shuffleState; } + @Nullable public PlayableItem getPlayingItem() { return playableItem; } @@ -69,9 +72,36 @@ public CurrentPlayerState disconnectDevice(@NotNull final TargetDeactivationDevi return withDevices(updatedDevices); } + public boolean hasActiveDevice() { + return getDevices().hasActiveDevice(); + } + + public boolean hasDevice(@NotNull final TargetDevice searchTarget) { + return devices.hasDevice(searchTarget); + } + @NotNull public CurrentPlayerState disconnectDevice(@NotNull final String deviceId) { final TargetDeactivationDevice deactivationTarget = TargetDeactivationDevice.of(deviceId); return disconnectDevice(deactivationTarget); } + + @NotNull + public CurrentPlayerState changeVolume(@NotNull final Volume volume) { + + final Devices devices = this.devices.changeVolume(volume); + + // For performance + return toBuilder() + .volume(volume) + .devices(devices) + .build(); + } + + @NotNull + public CurrentPlayerState transferPlayback(@NotNull final TargetDevice deviceToTransferPlayback) { + final var updatedDevices = devices.transferPlayback(deviceToTransferPlayback); + + return withDevices(updatedDevices); + } } diff --git a/src/main/java/com/odeyalo/sonata/connect/model/DeviceSpec.java b/src/main/java/com/odeyalo/sonata/connect/model/DeviceSpec.java index 86f589a..98470b2 100644 --- a/src/main/java/com/odeyalo/sonata/connect/model/DeviceSpec.java +++ b/src/main/java/com/odeyalo/sonata/connect/model/DeviceSpec.java @@ -1,7 +1,6 @@ package com.odeyalo.sonata.connect.model; import org.jetbrains.annotations.NotNull; -import org.springframework.util.Assert; /** * Specification that describes a device that connected to Sonata-Connect @@ -51,30 +50,6 @@ default boolean isIdle() { return getStatus().isIdle(); } - /** - * Represent a volume for the device - * Volume MUST BE in range from 0 to 100 - * - * @param value - an integer that represent a volume - */ - record Volume(int value) { - /** - * @throws IllegalStateException if a volume is in invalid range - */ - public Volume { - Assert.state(value >= 0, "Volume cannot be negative!"); - Assert.state(value <= 100, "Volume must be in range 0 - 100!"); - } - - public static Volume from(int value) { - return new Volume(value); - } - - public int asInt() { - return value; - } - } - /** * Represent a current status of the device. */ diff --git a/src/main/java/com/odeyalo/sonata/connect/model/Devices.java b/src/main/java/com/odeyalo/sonata/connect/model/Devices.java index 709ec7f..cce0f4b 100644 --- a/src/main/java/com/odeyalo/sonata/connect/model/Devices.java +++ b/src/main/java/com/odeyalo/sonata/connect/model/Devices.java @@ -1,6 +1,7 @@ package com.odeyalo.sonata.connect.model; import com.odeyalo.sonata.connect.exception.DeviceNotFoundException; +import com.odeyalo.sonata.connect.exception.NoActiveDeviceException; import com.odeyalo.sonata.connect.service.player.TargetDeactivationDevice; import com.odeyalo.sonata.connect.service.player.TargetDevice; import lombok.*; @@ -126,6 +127,22 @@ public Devices deactivateDevice(@NotNull final Device deviceToDeactivate) { .addDevice(deactivatedDevice); } + /** + * Change the volume for ACTIVE device + * @param volume - volume to set for active deivce + * @return - updated {@link Devices} + * @throws NoActiveDeviceException - if there is no active device present + */ + @NotNull + public Devices changeVolume(@NotNull final Volume volume) { + final Device device = getActiveDevice() + .orElseThrow(NoActiveDeviceException::defaultException); + + return removeDevice(device.getId()) + .addDevice( + device.withVolume(volume) + ); + } @NotNull private Device findDeviceToActivate(@NotNull final TargetDevice searchTarget) { diff --git a/src/main/java/com/odeyalo/sonata/connect/model/Volume.java b/src/main/java/com/odeyalo/sonata/connect/model/Volume.java new file mode 100644 index 0000000..9f8175a --- /dev/null +++ b/src/main/java/com/odeyalo/sonata/connect/model/Volume.java @@ -0,0 +1,39 @@ +package com.odeyalo.sonata.connect.model; + +import org.jetbrains.annotations.NotNull; +import org.springframework.util.Assert; + +/** + * Represent a volume for the device + * Volume MUST BE in range from 0 to 100 + * + * @param value - an integer that represent a volume + */ +public record Volume(int value) { + /** + * @throws IllegalStateException if a volume is in invalid range + */ + public Volume { + Assert.state(value >= 0, "Volume cannot be negative!"); + Assert.state(value <= 100, "Volume must be in range 0 - 100!"); + } + + @NotNull + public static Volume from(final int value) { + return new Volume(value); + } + + @NotNull + public static Volume fromInt(final int value) { + return from(value); + } + + @NotNull + public static Volume muted() { + return new Volume(0); + } + + public int asInt() { + return value; + } +} diff --git a/src/main/java/com/odeyalo/sonata/connect/service/player/BasicPlayerOperations.java b/src/main/java/com/odeyalo/sonata/connect/service/player/BasicPlayerOperations.java index 16a1b36..23a999f 100644 --- a/src/main/java/com/odeyalo/sonata/connect/service/player/BasicPlayerOperations.java +++ b/src/main/java/com/odeyalo/sonata/connect/service/player/BasicPlayerOperations.java @@ -1,9 +1,6 @@ package com.odeyalo.sonata.connect.service.player; -import com.odeyalo.sonata.connect.model.CurrentPlayerState; -import com.odeyalo.sonata.connect.model.CurrentlyPlayingPlayerState; -import com.odeyalo.sonata.connect.model.ShuffleMode; -import com.odeyalo.sonata.connect.model.User; +import com.odeyalo.sonata.connect.model.*; import org.jetbrains.annotations.NotNull; import org.jetbrains.annotations.Nullable; import reactor.core.publisher.Mono; @@ -21,7 +18,8 @@ public interface BasicPlayerOperations { * @param user - user that owns the player state * @return - mono wrapped with player state, never returns empty mono. */ - Mono currentState(User user); + @NotNull + Mono currentState(@NotNull User user); /** * Return the currently playing state, if nothing is playing right now empty mono should be returned. @@ -29,7 +27,8 @@ public interface BasicPlayerOperations { * @param user - user to get the current player state * @return - currently playing state or empty mono */ - Mono currentlyPlayingState(User user); + @NotNull + Mono currentlyPlayingState(@NotNull User user); /** * Create or return the player state for the user. @@ -38,7 +37,8 @@ public interface BasicPlayerOperations { * @param user - user to create state to * @return - mono with created player state or state that already present */ - default Mono createState(User user) { + @NotNull + default Mono createState(@NotNull User user) { return currentState(user); } @@ -50,8 +50,10 @@ default Mono createState(User user) { * @param shuffleMode - mode to update shuffle state * @return mono with updated player state */ - Mono changeShuffle(User user, ShuffleMode shuffleMode); + @NotNull + Mono changeShuffle(@NotNull User user, @NotNull ShuffleMode shuffleMode); + @NotNull DeviceOperations getDeviceOperations(); /** @@ -78,9 +80,13 @@ Mono playOrResume(@NotNull User user, @NotNull Mono pause(@NotNull User user); + @NotNull + Mono changeVolume(@NotNull User user, @NotNull Volume volume); + /** * Alias for #changeShuffle(User, true) method call */ + @NotNull default Mono enableShuffle(User user) { return changeShuffle(user, ShuffleMode.ENABLED); } @@ -88,6 +94,7 @@ default Mono enableShuffle(User user) { /** * Alias for #changeShuffle(User, false) method call */ + @NotNull default Mono disableShuffle(User user) { return changeShuffle(user, ShuffleMode.OFF); } diff --git a/src/main/java/com/odeyalo/sonata/connect/service/player/DefaultPlayerOperations.java b/src/main/java/com/odeyalo/sonata/connect/service/player/DefaultPlayerOperations.java index c50af61..8cfea17 100644 --- a/src/main/java/com/odeyalo/sonata/connect/service/player/DefaultPlayerOperations.java +++ b/src/main/java/com/odeyalo/sonata/connect/service/player/DefaultPlayerOperations.java @@ -1,9 +1,7 @@ package com.odeyalo.sonata.connect.service.player; -import com.odeyalo.sonata.connect.model.CurrentPlayerState; -import com.odeyalo.sonata.connect.model.CurrentlyPlayingPlayerState; -import com.odeyalo.sonata.connect.model.ShuffleMode; -import com.odeyalo.sonata.connect.model.User; +import com.odeyalo.sonata.connect.exception.NoActiveDeviceException; +import com.odeyalo.sonata.connect.model.*; import com.odeyalo.sonata.connect.service.player.handler.PauseCommandHandlerDelegate; import com.odeyalo.sonata.connect.service.player.handler.PlayCommandHandlerDelegate; import com.odeyalo.sonata.connect.service.support.mapper.CurrentPlayerState2CurrentlyPlayingPlayerStateConverter; @@ -19,7 +17,7 @@ @Component @RequiredArgsConstructor -public class DefaultPlayerOperations implements BasicPlayerOperations { +public final class DefaultPlayerOperations implements BasicPlayerOperations { private final DeviceOperations deviceOperations; private final PlayCommandHandlerDelegate playCommandHandlerDelegate; private final PauseCommandHandlerDelegate pauseCommandHandlerDelegate; @@ -28,6 +26,7 @@ public class DefaultPlayerOperations implements BasicPlayerOperations { private final Logger logger = LoggerFactory.getLogger(DefaultPlayerOperations.class); + @Override @NotNull public Mono currentState(@NotNull final User user) { @@ -72,6 +71,32 @@ public Mono pause(@NotNull User user) { return pauseCommandHandlerDelegate.pause(user); } + @Override + @NotNull + public Mono changeVolume(@NotNull final User user, + @NotNull final Volume volume) { + + return playerStateService.loadPlayerState(user) + .flatMap(playerState -> executeChangeVolumeCommand(playerState, volume)) + .flatMap(playerStateService::save); + } + + @NotNull + private static Mono executeChangeVolumeCommand(@NotNull final CurrentPlayerState playerState, + @NotNull final Volume volume) { + + // If we don't have active device, then we don't have connected devices at all + if ( playerState.hasActiveDevice() ) { + return Mono.just( + playerState.changeVolume(volume) + ); + } + + return Mono.defer( + () -> Mono.error(NoActiveDeviceException.defaultException()) + ); + } + @NotNull private Mono saveEmptyPlayerStateFor(@NotNull final User user) { final CurrentPlayerState state = CurrentPlayerState.emptyFor(user); diff --git a/src/main/java/com/odeyalo/sonata/connect/service/player/EventPublisherPlayerOperationsDecorator.java b/src/main/java/com/odeyalo/sonata/connect/service/player/EventPublisherPlayerOperationsDecorator.java index 7488710..a390ce8 100644 --- a/src/main/java/com/odeyalo/sonata/connect/service/player/EventPublisherPlayerOperationsDecorator.java +++ b/src/main/java/com/odeyalo/sonata/connect/service/player/EventPublisherPlayerOperationsDecorator.java @@ -13,7 +13,7 @@ /** * Decorator that can publish event to PlayerSynchronizationManager */ -public class EventPublisherPlayerOperationsDecorator implements BasicPlayerOperations { +public final class EventPublisherPlayerOperationsDecorator implements BasicPlayerOperations { private final BasicPlayerOperations delegate; private final PlayerSynchronizationManager synchronizationManager; private final DeviceOperations deviceOperations; @@ -25,21 +25,25 @@ public EventPublisherPlayerOperationsDecorator(BasicPlayerOperations delegate, P } @Override - public Mono currentState(User user) { + @NotNull + public Mono currentState(@NotNull User user) { return delegate.currentState(user); } @Override - public Mono currentlyPlayingState(User user) { + @NotNull + public Mono currentlyPlayingState(@NotNull User user) { return delegate.currentlyPlayingState(user); } @Override - public Mono changeShuffle(User user, ShuffleMode shuffleMode) { + @NotNull + public Mono changeShuffle(@NotNull User user, @NotNull ShuffleMode shuffleMode) { return delegate.changeShuffle(user, shuffleMode); } @Override + @NotNull public DeviceOperations getDeviceOperations() { return deviceOperations; } @@ -47,8 +51,8 @@ public DeviceOperations getDeviceOperations() { @Override @NotNull public Mono playOrResume(@NotNull final User user, - @Nullable final PlayCommandContext context, - @Nullable final TargetDevice targetDevice) { + @Nullable final PlayCommandContext context, + @Nullable final TargetDevice targetDevice) { return delegate.playOrResume(user, context, targetDevice) .flatMap(it -> publishEvent(it, PLAYER_STATE_UPDATED, user)); } @@ -60,12 +64,20 @@ public Mono pause(@NotNull User user) { .flatMap(it -> publishEvent(it, PLAYER_STATE_UPDATED, user)); } + @Override + @NotNull + public Mono changeVolume(@NotNull final User user, + @NotNull final Volume volume) { + return delegate.changeVolume(user, volume) + .flatMap(state -> publishEvent(state, PLAYER_STATE_UPDATED, user)); + } + @NotNull private Mono publishEvent(@NotNull CurrentPlayerState currentPlayerState, @NotNull PlayerEvent.EventType eventType, @NotNull User user) { Device activeDevice = getActiveDevice(currentPlayerState); - if (activeDevice == null) { + if ( activeDevice == null ) { return Mono.just(currentPlayerState); } diff --git a/src/main/java/com/odeyalo/sonata/connect/service/player/handler/SingleDeviceOnlyTransferPlaybackCommandHandlerDelegate.java b/src/main/java/com/odeyalo/sonata/connect/service/player/handler/SingleDeviceOnlyTransferPlaybackCommandHandlerDelegate.java index 6b224ee..bd63c52 100644 --- a/src/main/java/com/odeyalo/sonata/connect/service/player/handler/SingleDeviceOnlyTransferPlaybackCommandHandlerDelegate.java +++ b/src/main/java/com/odeyalo/sonata/connect/service/player/handler/SingleDeviceOnlyTransferPlaybackCommandHandlerDelegate.java @@ -5,7 +5,6 @@ import com.odeyalo.sonata.connect.exception.SingleTargetDeactivationDeviceRequiredException; import com.odeyalo.sonata.connect.exception.TargetDeviceRequiredException; import com.odeyalo.sonata.connect.model.CurrentPlayerState; -import com.odeyalo.sonata.connect.model.Devices; import com.odeyalo.sonata.connect.model.User; import com.odeyalo.sonata.connect.service.player.PlayerStateService; import com.odeyalo.sonata.connect.service.player.SwitchDeviceCommandArgs; @@ -29,20 +28,6 @@ public SingleDeviceOnlyTransferPlaybackCommandHandlerDelegate(PlayerStateService this.playerStateService = playerStateService; } - @NotNull - private Mono delegateTransferPlayback(@NotNull final TargetDevices targetDevices, - @NotNull final CurrentPlayerState state) { - - final Devices connectedDevices = state.getDevices(); - final TargetDevice targetDevice = targetDevices.peekFirst(); - - if ( connectedDevices.hasDevice(targetDevice) ) { - return doTransferPlayback(state, targetDevice, connectedDevices); - } - - return Mono.error(DeviceNotFoundException.defaultException()); - } - @NotNull @Override public Mono transferPlayback(@NotNull final User user, @@ -61,14 +46,24 @@ public Mono transferPlayback(@NotNull final User user, } @NotNull - private Mono doTransferPlayback(@NotNull final CurrentPlayerState state, - @NotNull final TargetDevice deviceToTransferPlayback, - @NotNull final Devices connectedDevices) { + private Mono delegateTransferPlayback(@NotNull final TargetDevices targetDevices, + @NotNull final CurrentPlayerState playerState) { + + final TargetDevice transferPlaybackTarget = targetDevices.peekFirst(); - final var updatedDevices = connectedDevices.transferPlayback(deviceToTransferPlayback); + if ( playerState.hasDevice(transferPlaybackTarget) ) { + return doTransferPlayback(playerState, transferPlaybackTarget); + } + + return Mono.error(DeviceNotFoundException.defaultException()); + } + + @NotNull + private Mono doTransferPlayback(@NotNull final CurrentPlayerState state, + @NotNull final TargetDevice deviceToTransferPlayback) { return playerStateService.save( - state.withDevices(updatedDevices) + state.transferPlayback(deviceToTransferPlayback) ); } diff --git a/src/main/java/com/odeyalo/sonata/connect/service/support/mapper/DeviceEntity2DeviceConverter.java b/src/main/java/com/odeyalo/sonata/connect/service/support/mapper/DeviceEntity2DeviceConverter.java index 4a0b4c0..fbc6d0d 100644 --- a/src/main/java/com/odeyalo/sonata/connect/service/support/mapper/DeviceEntity2DeviceConverter.java +++ b/src/main/java/com/odeyalo/sonata/connect/service/support/mapper/DeviceEntity2DeviceConverter.java @@ -3,6 +3,7 @@ import com.odeyalo.sonata.connect.entity.DeviceEntity; import com.odeyalo.sonata.connect.model.Device; import com.odeyalo.sonata.connect.model.DeviceSpec; +import com.odeyalo.sonata.connect.model.Volume; import org.mapstruct.Mapper; import org.mapstruct.Mapping; @@ -10,7 +11,7 @@ * Convert {@link DeviceEntity} to {@link Device} */ @Mapper(componentModel = "spring", imports = { - DeviceSpec.Volume.class, + Volume.class, DeviceSpec.DeviceStatus.class }) public interface DeviceEntity2DeviceConverter extends Converter { diff --git a/src/main/java/com/odeyalo/sonata/connect/service/support/mapper/PlayerState2CurrentPlayerStateConverter.java b/src/main/java/com/odeyalo/sonata/connect/service/support/mapper/PlayerState2CurrentPlayerStateConverter.java index bedb3ea..b60051a 100644 --- a/src/main/java/com/odeyalo/sonata/connect/service/support/mapper/PlayerState2CurrentPlayerStateConverter.java +++ b/src/main/java/com/odeyalo/sonata/connect/service/support/mapper/PlayerState2CurrentPlayerStateConverter.java @@ -2,6 +2,7 @@ import com.odeyalo.sonata.connect.entity.PlayerStateEntity; import com.odeyalo.sonata.connect.model.CurrentPlayerState; +import com.odeyalo.sonata.connect.model.Volume; import org.mapstruct.InjectionStrategy; import org.mapstruct.Mapper; import org.mapstruct.Mapping; @@ -9,14 +10,17 @@ /** * Convert {@link PlayerStateEntity} entity to {@link CurrentPlayerState} */ -@Mapper(componentModel = "spring", uses = { +@Mapper(componentModel = "spring", uses = { DevicesEntity2DevicesConverter.class, PlayableItemEntity2PlayableItemConverter.class, UserConverter.class +}, imports = { + Volume.class }, injectionStrategy = InjectionStrategy.CONSTRUCTOR) public interface PlayerState2CurrentPlayerStateConverter extends Converter { @Mapping(target = "playableItem", source = "currentlyPlayingItem") + @Mapping(target = "volume", expression = "java( Volume.from(state.getVolume()) )") CurrentPlayerState convertTo(PlayerStateEntity state); } diff --git a/src/main/java/com/odeyalo/sonata/connect/service/support/mapper/dto/ConnectDeviceRequest2DeviceConverter.java b/src/main/java/com/odeyalo/sonata/connect/service/support/mapper/dto/ConnectDeviceRequest2DeviceConverter.java index f9d7268..5c0fd94 100644 --- a/src/main/java/com/odeyalo/sonata/connect/service/support/mapper/dto/ConnectDeviceRequest2DeviceConverter.java +++ b/src/main/java/com/odeyalo/sonata/connect/service/support/mapper/dto/ConnectDeviceRequest2DeviceConverter.java @@ -3,12 +3,13 @@ import com.odeyalo.sonata.connect.dto.ConnectDeviceRequest; import com.odeyalo.sonata.connect.model.Device; import com.odeyalo.sonata.connect.model.DeviceSpec; +import com.odeyalo.sonata.connect.model.Volume; import com.odeyalo.sonata.connect.service.support.mapper.Converter; import org.mapstruct.Mapper; import org.mapstruct.Mapping; @Mapper(componentModel = "spring", imports = { - DeviceSpec.Volume.class, + Volume.class, DeviceSpec.DeviceStatus.class }) public interface ConnectDeviceRequest2DeviceConverter extends Converter { diff --git a/src/main/java/com/odeyalo/sonata/connect/service/support/mapper/dto/CurrentPlayerState2PlayerStateDtoConverter.java b/src/main/java/com/odeyalo/sonata/connect/service/support/mapper/dto/CurrentPlayerState2PlayerStateDtoConverter.java index 0aa6271..9a743d5 100644 --- a/src/main/java/com/odeyalo/sonata/connect/service/support/mapper/dto/CurrentPlayerState2PlayerStateDtoConverter.java +++ b/src/main/java/com/odeyalo/sonata/connect/service/support/mapper/dto/CurrentPlayerState2PlayerStateDtoConverter.java @@ -25,5 +25,6 @@ public interface CurrentPlayerState2PlayerStateDtoConverter extends Converter INVALID_VOLUME_ERROR = Mono.error( + InvalidVolumeException.withCustomMessage("Volume required to be: 0-100") + ); + + @Override + public boolean supportsParameter(@NotNull final MethodParameter parameter) { + return parameter.getParameterType().isAssignableFrom(Volume.class); + } + + @Override + @NotNull + public Mono resolveArgument(@NotNull final MethodParameter parameter, + @NotNull final BindingContext bindingContext, + @NotNull final ServerWebExchange exchange) { + + final String volumePercent = exchange.getRequest().getQueryParams().getFirst("volume_percent"); + + if ( !NumberUtils.isParsable(volumePercent) ) { + return INVALID_VOLUME_ERROR; + } + + final int volume = NumberUtils.createInteger(volumePercent); + + if ( volume < 0 || volume > 100 ) { + return INVALID_VOLUME_ERROR; + } + + return Mono.just( + Volume.fromInt(volume) + ); + } +} diff --git a/src/test/java/com/odeyalo/sonata/connect/controller/ChangePlayerVolumeEndpointTest.java b/src/test/java/com/odeyalo/sonata/connect/controller/ChangePlayerVolumeEndpointTest.java new file mode 100644 index 0000000..c142302 --- /dev/null +++ b/src/test/java/com/odeyalo/sonata/connect/controller/ChangePlayerVolumeEndpointTest.java @@ -0,0 +1,122 @@ +package com.odeyalo.sonata.connect.controller; + +import com.odeyalo.sonata.connect.dto.ConnectDeviceRequest; +import com.odeyalo.sonata.connect.dto.PlayerStateDto; +import com.odeyalo.sonata.connect.dto.ReasonCodeAwareExceptionMessage; +import org.jetbrains.annotations.NotNull; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.reactive.server.WebTestClient; +import reactor.core.publisher.Hooks; +import testing.faker.ConnectDeviceRequestFaker; +import testing.shared.SonataTestHttpOperations; +import testing.spring.autoconfigure.AutoConfigureSonataHttpClient; +import testing.spring.callback.ClearPlayerState; +import testing.spring.stubs.AutoConfigureSonataStubs; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + +@SpringBootTest +@TestInstance(TestInstance.Lifecycle.PER_CLASS) +@AutoConfigureWebTestClient +@AutoConfigureSonataStubs +@AutoConfigureSonataHttpClient +@ActiveProfiles("test") +@ClearPlayerState +class ChangePlayerVolumeEndpointTest { + + @Autowired + WebTestClient webTestClient; + + @Autowired + SonataTestHttpOperations sonataTestHttpOperations; + + final String VALID_ACCESS_TOKEN = "Bearer mikunakanoisthebestgirl"; + + @BeforeAll + void setup() { + Hooks.onOperatorDebug(); // DO NOT DELETE IT, VERY IMPORTANT LINE, WITHOUT IT FEIGN WITH WIREMOCK THROWS ILLEGAL STATE EXCEPTION, I DON'T FIND SOLUTION YET + } + + @ParameterizedTest + @ValueSource(ints = {0, 20, 40, 50, 60, 100}) + void shouldReturn204NoContentStatusAsResponseForValidVolume(final int volume) { + connectDevice(); + + final WebTestClient.ResponseSpec responseSpec = sendChangeVolumeRequest(volume); + + responseSpec.expectStatus().isNoContent(); + } + + @ParameterizedTest + @ValueSource(ints = {-1, -10, -20, -100, -1000}) + void shouldReturn404BadRequestStatusCodeIfVolumeIsNegative(final int volume) { + connectDevice(); + + final WebTestClient.ResponseSpec responseSpec = sendChangeVolumeRequest(volume); + + responseSpec.expectStatus().isBadRequest(); + + responseSpec.expectBody(ReasonCodeAwareExceptionMessage.class) + .value(message -> assertThat(message.getReasonCode()).isEqualTo("invalid_volume")); + } + + @ParameterizedTest + @ValueSource(ints = {101, 200, 300, 500}) + void shouldReturn404BadRequestStatusCodeIfVolumeIsGreaterThan100(final int volume) { + connectDevice(); + + final WebTestClient.ResponseSpec responseSpec = sendChangeVolumeRequest(volume); + + responseSpec.expectStatus().isBadRequest(); + + responseSpec.expectBody(ReasonCodeAwareExceptionMessage.class) + .value(message -> assertThat(message.getReasonCode()).isEqualTo("invalid_volume")); + } + + @Test + void shouldUpdatePlayerStateAfterCommand() { + connectDevice(); + + sendChangeVolumeRequest(30); + + final PlayerStateDto currentState = sonataTestHttpOperations.getCurrentState(VALID_ACCESS_TOKEN); + + assertThat(currentState.getVolume()).isEqualTo(30); + } + + @Test + void shouldReturn400BadRequestIfNoDeviceIsConnected() { + + final WebTestClient.ResponseSpec responseSpec = sendChangeVolumeRequest(30); + + responseSpec + .expectStatus().isBadRequest() + .expectBody(ReasonCodeAwareExceptionMessage.class) + .value(message -> assertThat(message.getReasonCode()).isEqualTo("no_active_device")); + } + + @NotNull + private WebTestClient.ResponseSpec sendChangeVolumeRequest(final int volume) { + return webTestClient.put() + .uri(b -> b.path("/player/volume") + .queryParam("volume_percent", volume) + .build()) + .header(AUTHORIZATION, VALID_ACCESS_TOKEN) + .exchange(); + } + + private void connectDevice() { + final ConnectDeviceRequest connectDeviceRequest = ConnectDeviceRequestFaker.create().get(); + + sonataTestHttpOperations.connectDevice(VALID_ACCESS_TOKEN, connectDeviceRequest); + } +} diff --git a/src/test/java/com/odeyalo/sonata/connect/service/player/ChangePlayerVolumeTest.java b/src/test/java/com/odeyalo/sonata/connect/service/player/ChangePlayerVolumeTest.java new file mode 100644 index 0000000..77e13ba --- /dev/null +++ b/src/test/java/com/odeyalo/sonata/connect/service/player/ChangePlayerVolumeTest.java @@ -0,0 +1,104 @@ +package com.odeyalo.sonata.connect.service.player; + +import com.odeyalo.sonata.connect.entity.DevicesEntity; +import com.odeyalo.sonata.connect.entity.PlayerStateEntity; +import com.odeyalo.sonata.connect.exception.NoActiveDeviceException; +import com.odeyalo.sonata.connect.model.CurrentPlayerState; +import com.odeyalo.sonata.connect.model.Volume; +import org.junit.jupiter.api.Test; +import reactor.test.StepVerifier; +import testing.asserts.DevicesAssert; +import testing.faker.DeviceEntityFaker; +import testing.faker.PlayerStateFaker; + +import static org.assertj.core.api.Assertions.assertThat; +import static testing.factory.DefaultPlayerOperationsTestableBuilder.testableBuilder; + +class ChangePlayerVolumeTest extends DefaultPlayerOperationsTest { + + @Test + void shouldReturnStateWithChangedVolume() { + final PlayerStateEntity playingPlayerState = PlayerStateFaker.create() + .user(existingUserEntity()) + .get(); + + final DefaultPlayerOperations testable = testableBuilder().withState(playingPlayerState).build(); + + testable.changeVolume(EXISTING_USER, Volume.from(40)) + .as(StepVerifier::create) + .assertNext(it -> assertThat(it.getVolume().asInt()).isEqualTo(40)) + .verifyComplete(); + } + + @Test + void shouldSaveUpdatedState() { + final PlayerStateEntity playingPlayerState = PlayerStateFaker.create() + .user(existingUserEntity()) + .get(); + + final DefaultPlayerOperations testable = testableBuilder().withState(playingPlayerState).build(); + + testable.changeVolume(EXISTING_USER, Volume.from(40)) + .as(StepVerifier::create) + .expectNextCount(1) + .verifyComplete(); + + testable.currentState(EXISTING_USER) + .as(StepVerifier::create) + .assertNext(it -> assertThat(it.getVolume().asInt()).isEqualTo(40)) + .verifyComplete(); + } + + @Test + void shouldReturnErrorIfThereIsNoDevices() { + final PlayerStateEntity playingPlayerState = PlayerStateFaker.create() + .user(existingUserEntity()) + .devicesEntity(DevicesEntity.empty()) + .get(); + + final DefaultPlayerOperations testable = testableBuilder() + .withState(playingPlayerState) + .build(); + + testable.changeVolume(EXISTING_USER, Volume.from(80)) + .as(StepVerifier::create) + .expectError(NoActiveDeviceException.class) + .verify(); + } + + @Test + void shouldChangeVolumeForSingleDevice() { + final PlayerStateEntity playingPlayerState = PlayerStateFaker.create() + .user(existingUserEntity()) + .device(DeviceEntityFaker.createActiveDevice().get()) + .get(); + + final DefaultPlayerOperations testable = testableBuilder().withState(playingPlayerState).build(); + + testable.changeVolume(EXISTING_USER, Volume.from(40)) + .map(CurrentPlayerState::getDevices) + .as(StepVerifier::create) + .assertNext(devices -> DevicesAssert.forDevices(devices).peekFirst().volume(40)) + .verifyComplete(); + } + + @Test + void shouldChangeVolumeOnlyForSingleActiveDeviceWhenMultipleDevicesAreConnected() { + final PlayerStateEntity playingPlayerState = PlayerStateFaker.create() + .user(existingUserEntity()) + .devicesEntity(DevicesEntity.just( + DeviceEntityFaker.createActiveDevice().setDeviceId("act1ve").get(), + DeviceEntityFaker.createInactiveDevice().get(), + DeviceEntityFaker.createInactiveDevice().get() + )) + .get(); + + final DefaultPlayerOperations testable = testableBuilder().withState(playingPlayerState).build(); + + testable.changeVolume(EXISTING_USER, Volume.from(60)) + .map(CurrentPlayerState::getDevices) + .as(StepVerifier::create) + .assertNext(devices -> DevicesAssert.forDevices(devices).peekById("act1ve").volume(60)) + .verifyComplete(); + } +} diff --git a/src/test/java/com/odeyalo/sonata/connect/service/player/EventPublisherPlayerOperationsDecoratorTest.java b/src/test/java/com/odeyalo/sonata/connect/service/player/EventPublisherPlayerOperationsDecoratorTest.java index 5662fd5..5130d8b 100644 --- a/src/test/java/com/odeyalo/sonata/connect/service/player/EventPublisherPlayerOperationsDecoratorTest.java +++ b/src/test/java/com/odeyalo/sonata/connect/service/player/EventPublisherPlayerOperationsDecoratorTest.java @@ -1,12 +1,10 @@ package com.odeyalo.sonata.connect.service.player; import com.odeyalo.sonata.connect.entity.DeviceEntity; +import com.odeyalo.sonata.connect.entity.DevicesEntity; import com.odeyalo.sonata.connect.entity.PlayerStateEntity; import com.odeyalo.sonata.connect.exception.NoActiveDeviceException; -import com.odeyalo.sonata.connect.model.CurrentPlayerState; -import com.odeyalo.sonata.connect.model.PlayableItem; -import com.odeyalo.sonata.connect.model.TrackItem; -import com.odeyalo.sonata.connect.model.User; +import com.odeyalo.sonata.connect.model.*; import com.odeyalo.sonata.connect.service.player.sync.DefaultPlayerSynchronizationManager; import com.odeyalo.sonata.connect.service.player.sync.InMemoryRoomHolder; import com.odeyalo.sonata.connect.service.player.sync.PlayerSynchronizationManager; @@ -257,6 +255,93 @@ void shouldInvokeDelegatePause() { } } + @Nested + class ChangeVolumeCommandTest { + + @Test + void shouldSendEventOnVolumeChange() { + PlayerStateEntity playerState = PlayerStateFaker + .forUser(USER) + .get(); + + EventCollectorPlayerSynchronizationManager synchronizationManagerMock = new EventCollectorPlayerSynchronizationManager(); + + EventPublisherPlayerOperationsDecorator testable = testableBuilder() + .withPlayerState(playerState) + .withSynchronizationManager(synchronizationManagerMock) + .build(); + + testable.changeVolume(USER, Volume.fromInt(20)).block(); + + assertThat(synchronizationManagerMock.getOccurredEvents()) + .hasSize(1) + .first().matches(it -> it.getCurrentPlayerState().getVolume().asInt() == 20); + } + + @Test + void shouldUseActiveDeviceId() { + DeviceEntity activeDevice = DeviceEntityFaker.createActiveDevice() + .setDeviceId("miku") + .get(); + // given + PlayerStateEntity playerState = PlayerStateFaker + .forUser(USER) + .device(activeDevice) + .get(); + + EventCollectorPlayerSynchronizationManager synchronizationManagerMock = new EventCollectorPlayerSynchronizationManager(); + + EventPublisherPlayerOperationsDecorator testable = testableBuilder() + .withPlayerState(playerState) + .withSynchronizationManager(synchronizationManagerMock) + .build(); + // when + testable.pause(USER) + .map(it -> synchronizationManagerMock.getOccurredEvents().get(0)) + .as(StepVerifier::create) + // then + .assertNext(it -> assertThat(it.getDeviceThatChanged()).isEqualTo("miku")) + .verifyComplete(); + } + + @Test + void shouldNotSendEventIfErrorOccurred() { + // given + PlayerStateEntity playerState = PlayerStateFaker + .forUser(USER) + .devicesEntity(DevicesEntity.empty()) + .get(); + + EventCollectorPlayerSynchronizationManager synchronizationManagerMock = new EventCollectorPlayerSynchronizationManager(); + + EventPublisherPlayerOperationsDecorator testable = testableBuilder() + .withPlayerState(playerState) + .withSynchronizationManager(synchronizationManagerMock) + .build(); + + // when, then + assertThatThrownBy(() -> testable.changeVolume(USER, Volume.fromInt(10)).block()).isInstanceOf(NoActiveDeviceException.class); + assertThat(synchronizationManagerMock.getOccurredEvents()).isEmpty(); + } + + @Test + void shouldInvokeDelegateChangeVolumeMethod() { + CurrentPlayerState playerState = CurrentPlayerStateFaker.create().withUser(USER).get(); + + BasicPlayerOperations delegateMock = mock(BasicPlayerOperations.class); + + when(delegateMock.changeVolume(USER, Volume.fromInt(10))).thenReturn(Mono.just(playerState)); + + EventPublisherPlayerOperationsDecorator testable = testableBuilder() + .withDelegate(delegateMock) + .build(); + + testable.changeVolume(USER, Volume.fromInt(10)).block(); + + verify(delegateMock, times(1)).changeVolume(eq(USER), eq(Volume.fromInt(10))); + } + } + static class TestableBuilder { private final DefaultPlayerOperationsTestableBuilder delegateBuilder = DefaultPlayerOperationsTestableBuilder.testableBuilder(); private BasicPlayerOperations delegate; diff --git a/src/test/java/testing/faker/CurrentPlayerStateFaker.java b/src/test/java/testing/faker/CurrentPlayerStateFaker.java index 699066b..dee94a6 100644 --- a/src/test/java/testing/faker/CurrentPlayerStateFaker.java +++ b/src/test/java/testing/faker/CurrentPlayerStateFaker.java @@ -19,7 +19,7 @@ public CurrentPlayerStateFaker() { .id(faker.random().nextLong()) .devices(Devices.fromCollection( Collections.singletonList(Device.of("123", "Odeyalo", DeviceType.COMPUTER, - DeviceSpec.Volume.from( + Volume.from( faker.random().nextInt(0, 100) ) , DeviceSpec.DeviceStatus.ACTIVE)) diff --git a/src/test/java/testing/faker/DeviceFaker.java b/src/test/java/testing/faker/DeviceFaker.java index 578a9f0..ea84792 100644 --- a/src/test/java/testing/faker/DeviceFaker.java +++ b/src/test/java/testing/faker/DeviceFaker.java @@ -4,6 +4,7 @@ import com.odeyalo.sonata.connect.model.Device; import com.odeyalo.sonata.connect.model.DeviceSpec; import com.odeyalo.sonata.connect.model.DeviceType; +import com.odeyalo.sonata.connect.model.Volume; import org.apache.commons.lang3.RandomStringUtils; public final class DeviceFaker { @@ -14,7 +15,7 @@ public DeviceFaker() { builder .deviceId(RandomStringUtils.randomAlphanumeric(16)) .deviceName(faker.funnyName().name()) - .volume(DeviceSpec.Volume.from(40)) + .volume(Volume.from(40)) .deviceType(faker.options().option(DeviceType.class)) .status(DeviceSpec.DeviceStatus.IDLE); } diff --git a/src/test/java/testing/spring/callback/ClearPlayerState.java b/src/test/java/testing/spring/callback/ClearPlayerState.java new file mode 100644 index 0000000..3a5ffe3 --- /dev/null +++ b/src/test/java/testing/spring/callback/ClearPlayerState.java @@ -0,0 +1,22 @@ +package testing.spring.callback; + +import org.springframework.test.context.TestExecutionListeners; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; + +/** + * An annotation that used for tests, that allows to clear the player state AFTER each test method. + * @see ClearPlayerStateListener + */ +@Retention(RUNTIME) +@Target(TYPE) +@TestExecutionListeners( + listeners = ClearPlayerStateListener.class, + mergeMode = TestExecutionListeners.MergeMode.MERGE_WITH_DEFAULTS +) +public @interface ClearPlayerState { +} diff --git a/src/test/java/testing/spring/callback/ClearPlayerStateListener.java b/src/test/java/testing/spring/callback/ClearPlayerStateListener.java new file mode 100644 index 0000000..8777004 --- /dev/null +++ b/src/test/java/testing/spring/callback/ClearPlayerStateListener.java @@ -0,0 +1,24 @@ +package testing.spring.callback; + +import com.odeyalo.sonata.connect.repository.PlayerStateRepository; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.BeansException; +import org.springframework.test.context.TestContext; +import org.springframework.test.context.TestExecutionListener; + +public final class ClearPlayerStateListener implements TestExecutionListener { + private final Logger logger = LoggerFactory.getLogger(ClearPlayerStateListener.class); + + @Override + public void afterTestMethod(@NotNull final TestContext testContext) { + try { + final PlayerStateRepository playerStateRepository = testContext.getApplicationContext().getBean(PlayerStateRepository.class); + + playerStateRepository.clear().block(); + } catch (final BeansException ex) { + logger.warn("No PlayerStateRepository bean found while running the test: {}", testContext.getTestMethod()); + } + } +} diff --git a/src/test/java/testing/spring/stubs/AutoConfigureSonataStubs.java b/src/test/java/testing/spring/stubs/AutoConfigureSonataStubs.java new file mode 100644 index 0000000..4448bba --- /dev/null +++ b/src/test/java/testing/spring/stubs/AutoConfigureSonataStubs.java @@ -0,0 +1,17 @@ +package testing.spring.stubs; + +import org.springframework.cloud.contract.stubrunner.spring.AutoConfigureStubRunner; + +import java.lang.annotation.Retention; +import java.lang.annotation.Target; + +import static java.lang.annotation.ElementType.TYPE; +import static java.lang.annotation.RetentionPolicy.RUNTIME; +import static org.springframework.cloud.contract.stubrunner.spring.StubRunnerProperties.StubsMode.REMOTE; + +@Retention(RUNTIME) +@Target(TYPE) +@AutoConfigureStubRunner(stubsMode = REMOTE, + repositoryRoot = "git://https://github.com/Project-Sonata/Sonata-Contracts.git", + ids = "com.odeyalo.sonata:authorization:+") +public @interface AutoConfigureSonataStubs {}