diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/Block.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/Block.java index 7d3e0c68..2108a646 100644 --- a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/Block.java +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/Block.java @@ -34,6 +34,13 @@ @JsonSubTypes.Type(value = Table.class, name = Table.TYPE), @JsonSubTypes.Type(value = DataTable.class, name = DataTable.TYPE), @JsonSubTypes.Type(value = ContainerBlock.class, name = ContainerBlock.TYPE), + @JsonSubTypes.Type(value = VideoBlock.class, name = VideoBlock.TYPE), + @JsonSubTypes.Type(value = Carousel.class, name = Carousel.TYPE), + @JsonSubTypes.Type(value = Plan.class, name = Plan.TYPE), + @JsonSubTypes.Type( + value = DataVisualizationBlock.class, + name = DataVisualizationBlock.TYPE + ), } ) @JsonInclude(JsonInclude.Include.NON_EMPTY) diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/CarouselIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/CarouselIF.java new file mode 100644 index 00000000..44ff0acb --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/CarouselIF.java @@ -0,0 +1,37 @@ +package com.hubspot.slack.client.models.blocks; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.hubspot.immutables.style.HubSpotStyle; +import org.immutables.value.Value; +import org.immutables.value.Value.Check; +import org.immutables.value.Value.Immutable; + +/** + * Slack's carousel block — a horizontally-scrolling container for related cards. + * @see Carousel block + */ +@Immutable +@HubSpotStyle +@JsonNaming(SnakeCaseStrategy.class) +public interface CarouselIF extends Block { + String TYPE = "carousel"; + + @Override + @Value.Derived + default String getType() { + return TYPE; + } + + ImmutableList getElements(); + + @Check + default void check() { + Preconditions.checkState( + !getElements().isEmpty() && getElements().size() <= 10, + "carousel must contain between 1 and 10 cards" + ); + } +} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/ContainerChildBlock.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/ContainerChildBlock.java index 977009ec..64bf1f03 100644 --- a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/ContainerChildBlock.java +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/ContainerChildBlock.java @@ -21,6 +21,7 @@ @JsonSubTypes.Type(value = RichTextBlock.class, name = RichTextBlock.TYPE), @JsonSubTypes.Type(value = Section.class, name = Section.TYPE), @JsonSubTypes.Type(value = Table.class, name = Table.TYPE), + @JsonSubTypes.Type(value = VideoBlock.class, name = VideoBlock.TYPE), } ) public interface ContainerChildBlock {} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/DataVisualizationBlockIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/DataVisualizationBlockIF.java new file mode 100644 index 00000000..653f4057 --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/DataVisualizationBlockIF.java @@ -0,0 +1,40 @@ +package com.hubspot.slack.client.models.blocks; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.google.common.base.Preconditions; +import com.hubspot.immutables.style.HubSpotStyle; +import com.hubspot.slack.client.models.blocks.charts.Chart; +import org.immutables.value.Value; +import org.immutables.value.Value.Check; +import org.immutables.value.Value.Immutable; + +/** + * Slack's data visualization block renders a pie, bar, area, or line chart. + * Limit of 2 data visualization blocks per message. + * @see Data visualization block + */ +@Immutable +@HubSpotStyle +@JsonNaming(SnakeCaseStrategy.class) +public interface DataVisualizationBlockIF extends Block { + String TYPE = "data_visualization"; + + @Override + @Value.Derived + default String getType() { + return TYPE; + } + + String getTitle(); + + Chart getChart(); + + @Check + default void check() { + Preconditions.checkState( + getTitle().length() <= 50, + "data_visualization title cannot exceed 50 characters" + ); + } +} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/PlanIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/PlanIF.java new file mode 100644 index 00000000..9326ebc3 --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/PlanIF.java @@ -0,0 +1,48 @@ +package com.hubspot.slack.client.models.blocks; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.hubspot.immutables.style.HubSpotStyle; +import org.immutables.value.Value; +import org.immutables.value.Value.Check; +import org.immutables.value.Value.Immutable; + +/** + * Slack's plan block — a collection of related task cards. + * @see Plan block + */ +@Immutable +@HubSpotStyle +@JsonNaming(SnakeCaseStrategy.class) +public interface PlanIF extends Block { + String TYPE = "plan"; + + @Override + @Value.Derived + default String getType() { + return TYPE; + } + + String getTitle(); + + ImmutableList getTasks(); + + @Check + default void check() { + Preconditions.checkState( + !getTasks().isEmpty() && getTasks().size() <= 50, + "plan must contain between 1 and 50 tasks" + ); + long distinctTaskIds = getTasks() + .stream() + .map(PlanTask::getTaskId) + .distinct() + .count(); + Preconditions.checkState( + distinctTaskIds == getTasks().size(), + "each task_id in a plan must be unique" + ); + } +} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/PlanTaskIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/PlanTaskIF.java new file mode 100644 index 00000000..c36b02a9 --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/PlanTaskIF.java @@ -0,0 +1,40 @@ +package com.hubspot.slack.client.models.blocks; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.google.common.collect.ImmutableList; +import com.hubspot.immutables.style.HubSpotStyle; +import com.hubspot.slack.client.models.blocks.elements.UrlSource; +import com.hubspot.slack.client.models.blocks.elements.richtextelements.RichTextBlock; +import java.util.Optional; +import org.immutables.value.Value; +import org.immutables.value.Value.Immutable; + +/** + * A task within a {@link Plan}. Per Slack's plan block docs, a plan's task objects carry no + * {@code type} field, so this is modeled as a plain object rather than reusing the polymorphic + * {@code task_card} block ({@code TaskCardBlock}). That lets it both serialize without a {@code type} + * and deserialize inbound plan tasks that omit one (an incoming {@code type} is ignored). Fields + * mirror the task card. + * @see Plan block + */ +@Immutable +@HubSpotStyle +@JsonInclude(JsonInclude.Include.NON_EMPTY) +@JsonNaming(SnakeCaseStrategy.class) +public interface PlanTaskIF { + @Value.Parameter(order = 1) + String getTaskId(); + + @Value.Parameter(order = 2) + String getTitle(); + + Optional getDetails(); + + Optional getOutput(); + + ImmutableList getSources(); + + Optional getStatus(); +} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/VideoBlockIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/VideoBlockIF.java new file mode 100644 index 00000000..78a1a6af --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/VideoBlockIF.java @@ -0,0 +1,96 @@ +package com.hubspot.slack.client.models.blocks; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.google.common.base.Preconditions; +import com.hubspot.immutables.style.HubSpotStyle; +import com.hubspot.slack.client.models.blocks.objects.Text; +import com.hubspot.slack.client.models.blocks.objects.TextType; +import java.util.Optional; +import org.immutables.value.Value; +import org.immutables.value.Value.Check; +import org.immutables.value.Value.Immutable; + +/** + * Slack's video block embeds a video player in a message, modal, or home tab. + * Sending one requires the {@code links.embed:write} scope and that {@code video_url} matches one of + * the app's configured unfurl domains and points to an embeddable HTTPS URL. + * @see Video block + */ +@Immutable +@HubSpotStyle +@JsonNaming(SnakeCaseStrategy.class) +public interface VideoBlockIF extends Block, ContainerChildBlock { + String TYPE = "video"; + String HTTPS_PREFIX = "https://"; + + @Override + @Value.Derived + default String getType() { + return TYPE; + } + + String getAltText(); + + Text getTitle(); + + String getThumbnailUrl(); + + String getVideoUrl(); + + Optional getTitleUrl(); + + Optional getDescription(); + + Optional getProviderName(); + + Optional getProviderIconUrl(); + + Optional getAuthorName(); + + @Check + default void check() { + Preconditions.checkState( + getTitle().getType() == TextType.PLAIN_TEXT, + "video title must be a plain_text text object" + ); + Preconditions.checkState( + getTitle().getText().length() < 200, + "video title must be less than 200 characters" + ); + getDescription() + .ifPresent(description -> { + Preconditions.checkState( + description.getType() == TextType.PLAIN_TEXT, + "video description must be a plain_text text object" + ); + Preconditions.checkState( + description.getText().length() < 200, + "video description must be less than 200 characters" + ); + }); + getAuthorName() + .ifPresent(authorName -> + Preconditions.checkState( + authorName.length() < 50, + "video author_name must be less than 50 characters" + ) + ); + Preconditions.checkState( + isHttpsUrl(getVideoUrl()), + "video video_url must be an HTTPS URL" + ); + getTitleUrl() + .ifPresent(titleUrl -> + Preconditions.checkState( + isHttpsUrl(titleUrl), + "video title_url must be an HTTPS URL" + ) + ); + } + + /** Case-insensitive {@code https://} prefix check (URL schemes are case-insensitive). */ + static boolean isHttpsUrl(String url) { + return url.regionMatches(true, 0, HTTPS_PREFIX, 0, HTTPS_PREFIX.length()); + } +} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/AreaChartIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/AreaChartIF.java new file mode 100644 index 00000000..4af87f6c --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/AreaChartIF.java @@ -0,0 +1,35 @@ +package com.hubspot.slack.client.models.blocks.charts; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.google.common.collect.ImmutableList; +import com.hubspot.immutables.style.HubSpotStyle; +import org.immutables.value.Value; +import org.immutables.value.Value.Check; +import org.immutables.value.Value.Immutable; + +/** + * An {@code area} chart for the data visualization block. + * @see Data visualization block — area + */ +@Immutable +@HubSpotStyle +@JsonNaming(SnakeCaseStrategy.class) +public interface AreaChartIF extends Chart { + String TYPE = "area"; + + @Override + @Value.Derived + default String getType() { + return TYPE; + } + + ImmutableList getSeries(); + + ChartAxisConfig getAxisConfig(); + + @Check + default void check() { + Charts.checkSeries(getSeries()); + } +} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/BarChartIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/BarChartIF.java new file mode 100644 index 00000000..fdcb53a8 --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/BarChartIF.java @@ -0,0 +1,36 @@ +package com.hubspot.slack.client.models.blocks.charts; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.hubspot.immutables.style.HubSpotStyle; +import org.immutables.value.Value; +import org.immutables.value.Value.Check; +import org.immutables.value.Value.Immutable; + +/** + * A {@code bar} chart for the data visualization block. + * @see Data visualization block — bar + */ +@Immutable +@HubSpotStyle +@JsonNaming(SnakeCaseStrategy.class) +public interface BarChartIF extends Chart { + String TYPE = "bar"; + + @Override + @Value.Derived + default String getType() { + return TYPE; + } + + ImmutableList getSeries(); + + ChartAxisConfig getAxisConfig(); + + @Check + default void check() { + Charts.checkSeries(getSeries()); + } +} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/Chart.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/Chart.java new file mode 100644 index 00000000..e77affd9 --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/Chart.java @@ -0,0 +1,28 @@ +package com.hubspot.slack.client.models.blocks.charts; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; + +/** + * The polymorphic {@code chart} payload of a data visualization block. The concrete type is + * discriminated by {@code type}: {@code pie}, {@code bar}, {@code area}, or {@code line}. + * @see Data visualization block + */ +@JsonTypeInfo( + use = JsonTypeInfo.Id.NAME, + property = "type", + defaultImpl = UnknownChart.class +) +@JsonSubTypes( + { + @JsonSubTypes.Type(value = PieChart.class, name = PieChart.TYPE), + @JsonSubTypes.Type(value = BarChart.class, name = BarChart.TYPE), + @JsonSubTypes.Type(value = AreaChart.class, name = AreaChart.TYPE), + @JsonSubTypes.Type(value = LineChart.class, name = LineChart.TYPE), + } +) +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public interface Chart { + String getType(); +} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/ChartAxisConfigIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/ChartAxisConfigIF.java new file mode 100644 index 00000000..9d8819d7 --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/ChartAxisConfigIF.java @@ -0,0 +1,59 @@ +package com.hubspot.slack.client.models.blocks.charts; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.hubspot.immutables.style.HubSpotStyle; +import java.util.Optional; +import org.immutables.value.Value.Check; +import org.immutables.value.Value.Immutable; + +/** + * Axis configuration for a bar/area/line chart. + * @see Data visualization block — axis config + */ +@Immutable +@HubSpotStyle +@JsonInclude(JsonInclude.Include.NON_EMPTY) +@JsonNaming(SnakeCaseStrategy.class) +public interface ChartAxisConfigIF { + ImmutableList getCategories(); + + @JsonProperty("x_label") + Optional getXLabel(); + + @JsonProperty("y_label") + Optional getYLabel(); + + @Check + default void check() { + Preconditions.checkState( + !getCategories().isEmpty(), + "chart axis_config categories must not be empty" + ); + getCategories() + .forEach(category -> + Preconditions.checkState( + category.length() <= 20, + "chart axis_config category label cannot exceed 20 characters" + ) + ); + getXLabel() + .ifPresent(label -> + Preconditions.checkState( + label.length() <= 50, + "chart axis_config x_label cannot exceed 50 characters" + ) + ); + getYLabel() + .ifPresent(label -> + Preconditions.checkState( + label.length() <= 50, + "chart axis_config y_label cannot exceed 50 characters" + ) + ); + } +} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/ChartDataPointIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/ChartDataPointIF.java new file mode 100644 index 00000000..760a43a8 --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/ChartDataPointIF.java @@ -0,0 +1,31 @@ +package com.hubspot.slack.client.models.blocks.charts; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.google.common.base.Preconditions; +import com.hubspot.immutables.style.HubSpotStyle; +import org.immutables.value.Value.Check; +import org.immutables.value.Value.Immutable; + +/** + * A single data point in a chart data series (used by bar/area/line charts). + * @see Data visualization block — data point + */ +@Immutable +@HubSpotStyle +@JsonInclude(JsonInclude.Include.NON_EMPTY) +@JsonNaming(SnakeCaseStrategy.class) +public interface ChartDataPointIF { + String getLabel(); + + double getValue(); + + @Check + default void check() { + Preconditions.checkState( + getLabel().length() <= 20, + "chart data point label cannot exceed 20 characters" + ); + } +} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/ChartDataSeriesIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/ChartDataSeriesIF.java new file mode 100644 index 00000000..a9353a09 --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/ChartDataSeriesIF.java @@ -0,0 +1,36 @@ +package com.hubspot.slack.client.models.blocks.charts; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.hubspot.immutables.style.HubSpotStyle; +import org.immutables.value.Value.Check; +import org.immutables.value.Value.Immutable; + +/** + * A named series of data points in a bar/area/line chart. + * @see Data visualization block — data series + */ +@Immutable +@HubSpotStyle +@JsonInclude(JsonInclude.Include.NON_EMPTY) +@JsonNaming(SnakeCaseStrategy.class) +public interface ChartDataSeriesIF { + String getName(); + + ImmutableList getData(); + + @Check + default void check() { + Preconditions.checkState( + getName().length() <= 20, + "chart data series name cannot exceed 20 characters" + ); + Preconditions.checkState( + !getData().isEmpty() && getData().size() <= 20, + "chart data series must contain between 1 and 20 data points" + ); + } +} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/ChartSegmentIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/ChartSegmentIF.java new file mode 100644 index 00000000..ccecfe57 --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/ChartSegmentIF.java @@ -0,0 +1,32 @@ +package com.hubspot.slack.client.models.blocks.charts; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.google.common.base.Preconditions; +import com.hubspot.immutables.style.HubSpotStyle; +import org.immutables.value.Value.Check; +import org.immutables.value.Value.Immutable; + +/** + * A single segment of a pie chart (used by the data visualization block). + * @see Data visualization block — segment + */ +@Immutable +@HubSpotStyle +@JsonInclude(JsonInclude.Include.NON_EMPTY) +@JsonNaming(SnakeCaseStrategy.class) +public interface ChartSegmentIF { + String getLabel(); + + double getValue(); + + @Check + default void check() { + Preconditions.checkState( + getLabel().length() <= 20, + "pie chart segment label cannot exceed 20 characters" + ); + Preconditions.checkState(getValue() > 0, "pie chart segment value must be > 0"); + } +} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/Charts.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/Charts.java new file mode 100644 index 00000000..dd0a3d02 --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/Charts.java @@ -0,0 +1,22 @@ +package com.hubspot.slack.client.models.blocks.charts; + +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; + +/** Shared validation for the bar/area/line chart variants. */ +final class Charts { + + private Charts() {} + + static void checkSeries(ImmutableList series) { + Preconditions.checkState( + !series.isEmpty() && series.size() <= 12, + "chart must contain between 1 and 12 data series" + ); + long distinctNames = series.stream().map(ChartDataSeries::getName).distinct().count(); + Preconditions.checkState( + distinctNames == series.size(), + "chart data series names must be unique" + ); + } +} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/LineChartIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/LineChartIF.java new file mode 100644 index 00000000..da3d37da --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/LineChartIF.java @@ -0,0 +1,35 @@ +package com.hubspot.slack.client.models.blocks.charts; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.google.common.collect.ImmutableList; +import com.hubspot.immutables.style.HubSpotStyle; +import org.immutables.value.Value; +import org.immutables.value.Value.Check; +import org.immutables.value.Value.Immutable; + +/** + * A {@code line} chart for the data visualization block. + * @see Data visualization block — line + */ +@Immutable +@HubSpotStyle +@JsonNaming(SnakeCaseStrategy.class) +public interface LineChartIF extends Chart { + String TYPE = "line"; + + @Override + @Value.Derived + default String getType() { + return TYPE; + } + + ImmutableList getSeries(); + + ChartAxisConfig getAxisConfig(); + + @Check + default void check() { + Charts.checkSeries(getSeries()); + } +} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/PieChartIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/PieChartIF.java new file mode 100644 index 00000000..99b281f7 --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/PieChartIF.java @@ -0,0 +1,37 @@ +package com.hubspot.slack.client.models.blocks.charts; + +import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.google.common.base.Preconditions; +import com.google.common.collect.ImmutableList; +import com.hubspot.immutables.style.HubSpotStyle; +import org.immutables.value.Value; +import org.immutables.value.Value.Check; +import org.immutables.value.Value.Immutable; + +/** + * A {@code pie} chart for the data visualization block. + * @see Data visualization block — pie + */ +@Immutable +@HubSpotStyle +@JsonNaming(SnakeCaseStrategy.class) +public interface PieChartIF extends Chart { + String TYPE = "pie"; + + @Override + @Value.Derived + default String getType() { + return TYPE; + } + + ImmutableList getSegments(); + + @Check + default void check() { + Preconditions.checkState( + !getSegments().isEmpty() && getSegments().size() <= 12, + "pie chart must contain between 1 and 12 segments" + ); + } +} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/UnknownChart.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/UnknownChart.java new file mode 100644 index 00000000..f43ece96 --- /dev/null +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/charts/UnknownChart.java @@ -0,0 +1,17 @@ +package com.hubspot.slack.client.models.blocks.charts; + +/** + * Read-only deserialization fallback for a {@link Chart} whose {@code type} we don't model + * (mirrors the other {@code Unknown*} fallbacks). Must not be re-serialized and sent to Slack. + */ +public class UnknownChart implements Chart { + + public static final String TYPE = "unknown"; + + protected UnknownChart() {} + + @Override + public String getType() { + return TYPE; + } +} diff --git a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/WorkflowButtonIF.java b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/WorkflowButtonIF.java index 2d1692f8..912e46f5 100644 --- a/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/WorkflowButtonIF.java +++ b/slack-base/src/main/java/com/hubspot/slack/client/models/blocks/elements/WorkflowButtonIF.java @@ -2,11 +2,14 @@ import com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; import com.fasterxml.jackson.databind.annotation.JsonNaming; +import com.google.common.base.Preconditions; import com.hubspot.immutables.style.HubSpotStyle; import com.hubspot.slack.client.models.blocks.objects.Text; +import com.hubspot.slack.client.models.blocks.objects.TextType; import com.hubspot.slack.client.models.blocks.objects.WorkflowObject; import java.util.Optional; import org.immutables.value.Value; +import org.immutables.value.Value.Check; import org.immutables.value.Value.Immutable; /** @@ -38,4 +41,27 @@ default String getType() { Optional getStyle(); Optional getAccessibilityLabel(); + + @Check + default void check() { + Preconditions.checkState( + getText().getType() == TextType.PLAIN_TEXT, + "workflow_button text must be a plain_text text object" + ); + Preconditions.checkState( + getText().getText().length() <= 75, + "workflow_button text cannot exceed 75 characters" + ); + Preconditions.checkState( + getActionId().length() <= 255, + "workflow_button action_id cannot exceed 255 characters" + ); + getAccessibilityLabel() + .ifPresent(label -> + Preconditions.checkState( + label.length() <= 75, + "workflow_button accessibility_label cannot exceed 75 characters" + ) + ); + } } diff --git a/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/CarouselTest.java b/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/CarouselTest.java new file mode 100644 index 00000000..18b1f9a4 --- /dev/null +++ b/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/CarouselTest.java @@ -0,0 +1,50 @@ +package com.hubspot.slack.client.models.blocks; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hubspot.slack.client.jackson.ObjectMapperUtils; +import com.hubspot.slack.client.models.blocks.objects.Text; +import com.hubspot.slack.client.models.blocks.objects.TextType; +import java.io.IOException; +import org.junit.Test; + +public class CarouselTest { + + private static Card card(String title) { + return Card.builder().setTitle(Text.of(TextType.PLAIN_TEXT, title)).build(); + } + + @Test + public void itSerializesAndDeserializes() throws IOException { + Carousel original = Carousel + .builder() + .addElements(card("First")) + .addElements(card("Second")) + .build(); + ObjectMapper mapper = ObjectMapperUtils.mapper(); + String serialized = mapper.writeValueAsString(original); + + assertThat(mapper.readValue(serialized, Block.class)).isInstanceOf(Carousel.class); + assertThat(mapper.readValue(serialized, Carousel.class)).isEqualTo(original); + } + + @Test + public void itRejectsEmptyCarousel() { + assertThatThrownBy(() -> Carousel.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("between 1 and 10"); + } + + @Test + public void itRejectsMoreThanTenCards() { + Carousel.Builder builder = Carousel.builder(); + for (int i = 0; i < 11; i++) { + builder.addElements(card("Card " + i)); + } + assertThatThrownBy(builder::build) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("between 1 and 10"); + } +} diff --git a/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/DataVisualizationBlockTest.java b/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/DataVisualizationBlockTest.java new file mode 100644 index 00000000..65519685 --- /dev/null +++ b/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/DataVisualizationBlockTest.java @@ -0,0 +1,209 @@ +package com.hubspot.slack.client.models.blocks; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hubspot.slack.client.jackson.ObjectMapperUtils; +import com.hubspot.slack.client.models.blocks.charts.AreaChart; +import com.hubspot.slack.client.models.blocks.charts.BarChart; +import com.hubspot.slack.client.models.blocks.charts.ChartAxisConfig; +import com.hubspot.slack.client.models.blocks.charts.ChartDataPoint; +import com.hubspot.slack.client.models.blocks.charts.ChartDataSeries; +import com.hubspot.slack.client.models.blocks.charts.ChartSegment; +import com.hubspot.slack.client.models.blocks.charts.LineChart; +import com.hubspot.slack.client.models.blocks.charts.PieChart; +import java.io.IOException; +import org.junit.Test; + +public class DataVisualizationBlockTest { + + private static final ObjectMapper MAPPER = ObjectMapperUtils.mapper(); + + @Test + public void itSerializesAndDeserializesPieChart() throws IOException { + DataVisualizationBlock original = DataVisualizationBlock + .builder() + .setTitle("Market share") + .setChart( + PieChart + .builder() + .addSegments(ChartSegment.builder().setLabel("A").setValue(60.0).build()) + .addSegments(ChartSegment.builder().setLabel("B").setValue(40.0).build()) + .build() + ) + .build(); + + String json = MAPPER.writeValueAsString(original); + assertThat(MAPPER.readValue(json, Block.class)) + .isInstanceOf(DataVisualizationBlock.class); + + DataVisualizationBlock roundTripped = MAPPER.readValue( + json, + DataVisualizationBlock.class + ); + assertThat(roundTripped).isEqualTo(original); + assertThat(roundTripped.getChart()).isInstanceOf(PieChart.class); + } + + @Test + public void itSerializesAndDeserializesBarChart() throws IOException { + DataVisualizationBlock original = DataVisualizationBlock + .builder() + .setTitle("Monthly revenue") + .setChart( + BarChart + .builder() + .addSeries( + ChartDataSeries + .builder() + .setName("2026") + .addData(ChartDataPoint.builder().setLabel("Jan").setValue(5.0).build()) + .addData(ChartDataPoint.builder().setLabel("Feb").setValue(8.0).build()) + .build() + ) + .setAxisConfig( + ChartAxisConfig + .builder() + .addCategories("Jan") + .addCategories("Feb") + .setXLabel("Month") + .setYLabel("Revenue") + .build() + ) + .build() + ) + .build(); + + String json = MAPPER.writeValueAsString(original); + DataVisualizationBlock roundTripped = MAPPER.readValue( + json, + DataVisualizationBlock.class + ); + assertThat(roundTripped).isEqualTo(original); + assertThat(roundTripped.getChart()).isInstanceOf(BarChart.class); + } + + @Test + public void itRejectsTitleOver50Chars() { + String longTitle = new String(new char[51]).replace('\0', 'a'); + assertThatThrownBy(() -> + DataVisualizationBlock + .builder() + .setTitle(longTitle) + .setChart( + PieChart + .builder() + .addSegments(ChartSegment.builder().setLabel("A").setValue(1.0).build()) + .build() + ) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("title cannot exceed 50"); + } + + @Test + public void itRejectsEmptyPieChart() { + assertThatThrownBy(() -> PieChart.builder().build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("between 1 and 12"); + } + + @Test + public void itSerializesAndDeserializesLineChart() throws IOException { + DataVisualizationBlock original = DataVisualizationBlock + .builder() + .setTitle("Latency") + .setChart( + LineChart + .builder() + .addSeries( + ChartDataSeries + .builder() + .setName("p50") + .addData(ChartDataPoint.builder().setLabel("Jan").setValue(1.0).build()) + .build() + ) + .setAxisConfig(ChartAxisConfig.builder().addCategories("Jan").build()) + .build() + ) + .build(); + String json = MAPPER.writeValueAsString(original); + assertThat(json).contains("\"type\":\"line\""); + DataVisualizationBlock roundTripped = MAPPER.readValue( + json, + DataVisualizationBlock.class + ); + assertThat(roundTripped).isEqualTo(original); + assertThat(roundTripped.getChart()).isInstanceOf(LineChart.class); + } + + @Test + public void itRejectsDuplicateSeriesNames() { + assertThatThrownBy(() -> + BarChart + .builder() + .addSeries( + ChartDataSeries + .builder() + .setName("dup") + .addData(ChartDataPoint.builder().setLabel("Jan").setValue(1.0).build()) + .build() + ) + .addSeries( + ChartDataSeries + .builder() + .setName("dup") + .addData(ChartDataPoint.builder().setLabel("Feb").setValue(2.0).build()) + .build() + ) + .setAxisConfig(ChartAxisConfig.builder().addCategories("Jan").build()) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("names must be unique"); + } + + @Test + public void itRejectsCategoryLabelOver20Chars() { + String longCategory = new String(new char[21]).replace('\0', 'x'); + assertThatThrownBy(() -> ChartAxisConfig.builder().addCategories(longCategory).build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("category label"); + } + + @Test + public void itRejectsNonPositivePieSegmentValue() { + assertThatThrownBy(() -> ChartSegment.builder().setLabel("Zero").setValue(0.0).build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("value must be > 0"); + } + + @Test + public void itUsesAreaChartTypeDiscriminator() throws IOException { + DataVisualizationBlock original = DataVisualizationBlock + .builder() + .setTitle("Area") + .setChart( + AreaChart + .builder() + .addSeries( + ChartDataSeries + .builder() + .setName("s") + .addData(ChartDataPoint.builder().setLabel("Jan").setValue(1.0).build()) + .build() + ) + .setAxisConfig(ChartAxisConfig.builder().addCategories("Jan").build()) + .build() + ) + .build(); + String json = MAPPER.writeValueAsString(original); + assertThat(json).contains("\"type\":\"area\""); + assertThat(MAPPER.readValue(json, DataVisualizationBlock.class).getChart()) + .isInstanceOf(AreaChart.class); + } +} diff --git a/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/PlanTest.java b/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/PlanTest.java new file mode 100644 index 00000000..c6c029d9 --- /dev/null +++ b/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/PlanTest.java @@ -0,0 +1,82 @@ +package com.hubspot.slack.client.models.blocks; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hubspot.slack.client.jackson.ObjectMapperUtils; +import java.io.IOException; +import org.junit.Test; + +public class PlanTest { + + private static final ObjectMapper MAPPER = ObjectMapperUtils.mapper(); + + private static PlanTask task(String taskId, String title) { + return PlanTask.builder().setTaskId(taskId).setTitle(title).build(); + } + + @Test + public void itSerializesAndDeserializes() throws IOException { + Plan original = Plan + .builder() + .setTitle("My plan") + .addTasks(task("t1", "First task")) + .addTasks(task("t2", "Second task")) + .build(); + String serialized = MAPPER.writeValueAsString(original); + + assertThat(MAPPER.readValue(serialized, Block.class)).isInstanceOf(Plan.class); + assertThat(MAPPER.readValue(serialized, Plan.class)).isEqualTo(original); + } + + @Test + public void itDoesNotSerializeTaskTypeField() throws IOException { + Plan plan = Plan.builder().setTitle("My plan").addTasks(task("t1", "A")).build(); + // Per Slack's plan docs, task objects carry no "type" field. + assertThat(MAPPER.writeValueAsString(plan)).doesNotContain("task_card"); + } + + @Test + public void itDeserializesTasksWithoutTypeField() throws IOException { + String json = + "{\"type\":\"plan\",\"title\":\"My plan\",\"tasks\":[" + + "{\"task_id\":\"t1\",\"title\":\"First task\",\"status\":\"in_progress\"}]}"; + Plan plan = MAPPER.readValue(json, Plan.class); + assertThat(plan.getTasks()).hasSize(1); + assertThat(plan.getTasks().get(0).getTaskId()).isEqualTo("t1"); + assertThat(plan.getTasks().get(0).getStatus()) + .contains(TaskCardBlockStatus.IN_PROGRESS); + } + + @Test + public void itToleratesInboundTaskTypeField() throws IOException { + // A task that includes "type":"task_card" must still parse (the field is ignored). + String json = + "{\"type\":\"plan\",\"title\":\"My plan\",\"tasks\":[" + + "{\"type\":\"task_card\",\"task_id\":\"t1\",\"title\":\"First task\"}]}"; + Plan plan = MAPPER.readValue(json, Plan.class); + assertThat(plan.getTasks().get(0).getTaskId()).isEqualTo("t1"); + } + + @Test + public void itRejectsEmptyPlan() { + assertThatThrownBy(() -> Plan.builder().setTitle("My plan").build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("between 1 and 50"); + } + + @Test + public void itRejectsDuplicateTaskIds() { + assertThatThrownBy(() -> + Plan + .builder() + .setTitle("My plan") + .addTasks(task("dup", "First")) + .addTasks(task("dup", "Second")) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("task_id"); + } +} diff --git a/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/VideoBlockTest.java b/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/VideoBlockTest.java new file mode 100644 index 00000000..dd76df46 --- /dev/null +++ b/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/VideoBlockTest.java @@ -0,0 +1,135 @@ +package com.hubspot.slack.client.models.blocks; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hubspot.slack.client.jackson.ObjectMapperUtils; +import com.hubspot.slack.client.models.JsonLoader; +import com.hubspot.slack.client.models.blocks.objects.Text; +import com.hubspot.slack.client.models.blocks.objects.TextType; +import java.io.IOException; +import org.junit.Test; + +public class VideoBlockTest { + + @Test + public void itDeserializesFromJson() throws IOException { + String rawJson = JsonLoader.loadJsonFromFile("video_block.json"); + Block block = ObjectMapperUtils.mapper().readValue(rawJson, Block.class); + assertThat(block).isInstanceOf(VideoBlock.class); + + VideoBlock video = ObjectMapperUtils.mapper().readValue(rawJson, VideoBlock.class); + assertThat(video.getAltText()).isEqualTo("How to use Slack."); + assertThat(video.getTitle().getText()).isEqualTo("How to use Slack."); + assertThat(video.getThumbnailUrl()).isEqualTo("https://example.com/thumb.png"); + assertThat(video.getVideoUrl()).isEqualTo("https://www.youtube.com/embed/abc123"); + assertThat(video.getTitleUrl()).contains("https://www.youtube.com/watch?v=abc123"); + assertThat(video.getProviderName()).contains("YouTube"); + assertThat(video.getAuthorName()).contains("Slack"); + } + + @Test + public void itSerializesAndDeserializes() throws IOException { + VideoBlock original = VideoBlock + .builder() + .setAltText("A short demo") + .setTitle(Text.of(TextType.PLAIN_TEXT, "Demo")) + .setThumbnailUrl("https://example.com/thumb.png") + .setVideoUrl("https://www.youtube.com/embed/abc123") + .build(); + ObjectMapper mapper = ObjectMapperUtils.mapper(); + String serialized = mapper.writeValueAsString(original); + VideoBlock deserialized = mapper.readValue(serialized, VideoBlock.class); + assertThat(deserialized).isEqualTo(original); + } + + @Test + public void itAcceptsUppercaseHttpsScheme() { + VideoBlock video = VideoBlock + .builder() + .setAltText("a") + .setTitle(Text.of(TextType.PLAIN_TEXT, "t")) + .setThumbnailUrl("https://example.com/t.png") + .setVideoUrl("HTTPS://example.com/v") + .build(); + assertThat(video.getVideoUrl()).isEqualTo("HTTPS://example.com/v"); + } + + @Test + public void itRejectsNonHttpsVideoUrl() { + assertThatThrownBy(() -> + VideoBlock + .builder() + .setAltText("a") + .setTitle(Text.of(TextType.PLAIN_TEXT, "t")) + .setThumbnailUrl("https://example.com/t.png") + .setVideoUrl("http://insecure.example.com/v") + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("HTTPS"); + } + + @Test + public void itRejectsNonPlainTextTitle() { + assertThatThrownBy(() -> + VideoBlock + .builder() + .setAltText("a") + .setTitle(Text.of(TextType.MARKDOWN, "t")) + .setThumbnailUrl("https://example.com/t.png") + .setVideoUrl("https://example.com/v") + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("plain_text"); + } + + @Test + public void itRejectsAuthorNameOver50Chars() { + String longName = new String(new char[50]).replace('\0', 'a'); + assertThatThrownBy(() -> + VideoBlock + .builder() + .setAltText("a") + .setTitle(Text.of(TextType.PLAIN_TEXT, "t")) + .setThumbnailUrl("https://example.com/t.png") + .setVideoUrl("https://example.com/v") + .setAuthorName(longName) + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("author_name"); + } + + // Slack docs: title/description "must be less than 200 characters" — so 199 is allowed, 200 is not. + @Test + public void itAcceptsTitleOf199Chars() { + String title199 = new String(new char[199]).replace('\0', 'a'); + VideoBlock video = VideoBlock + .builder() + .setAltText("a") + .setTitle(Text.of(TextType.PLAIN_TEXT, title199)) + .setThumbnailUrl("https://example.com/t.png") + .setVideoUrl("https://example.com/v") + .build(); + assertThat(video.getTitle().getText()).hasSize(199); + } + + @Test + public void itRejectsTitleOf200Chars() { + String title200 = new String(new char[200]).replace('\0', 'a'); + assertThatThrownBy(() -> + VideoBlock + .builder() + .setAltText("a") + .setTitle(Text.of(TextType.PLAIN_TEXT, title200)) + .setThumbnailUrl("https://example.com/t.png") + .setVideoUrl("https://example.com/v") + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("title must be less than 200"); + } +} diff --git a/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/elements/WorkflowButtonTest.java b/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/elements/WorkflowButtonTest.java new file mode 100644 index 00000000..8cac49c9 --- /dev/null +++ b/slack-base/src/test/java/com/hubspot/slack/client/models/blocks/elements/WorkflowButtonTest.java @@ -0,0 +1,83 @@ +package com.hubspot.slack.client.models.blocks.elements; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.hubspot.slack.client.jackson.ObjectMapperUtils; +import com.hubspot.slack.client.models.blocks.objects.Text; +import com.hubspot.slack.client.models.blocks.objects.TextType; +import com.hubspot.slack.client.models.blocks.objects.TriggerObject; +import com.hubspot.slack.client.models.blocks.objects.WorkflowObject; +import java.io.IOException; +import org.junit.Test; + +public class WorkflowButtonTest { + + private static final WorkflowObject WORKFLOW = WorkflowObject.of( + TriggerObject.of("https://slack.com/shortcuts/Ft0000000000/abc123") + ); + + private static WorkflowButton.Builder validButton() { + return WorkflowButton + .builder() + .setText(Text.of(TextType.PLAIN_TEXT, "Run")) + .setWorkflow(WORKFLOW) + .setActionId("wb_1"); + } + + private static String repeat(int length) { + return new String(new char[length]).replace('\0', 'a'); + } + + @Test + public void itSerializesAndDeserializes() throws IOException { + WorkflowButton original = validButton() + .setStyle("primary") + .setAccessibilityLabel("Run the workflow") + .build(); + ObjectMapper mapper = ObjectMapperUtils.mapper(); + String serialized = mapper.writeValueAsString(original); + + assertThat(mapper.readValue(serialized, BlockElement.class)) + .isInstanceOf(WorkflowButton.class); + assertThat(mapper.readValue(serialized, WorkflowButton.class)).isEqualTo(original); + } + + @Test + public void itRejectsNonPlainTextText() { + assertThatThrownBy(() -> + WorkflowButton + .builder() + .setText(Text.of(TextType.MARKDOWN, "Run")) + .setWorkflow(WORKFLOW) + .setActionId("wb_1") + .build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("plain_text"); + } + + @Test + public void itRejectsTextOver75Chars() { + assertThatThrownBy(() -> + validButton().setText(Text.of(TextType.PLAIN_TEXT, repeat(76))).build() + ) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("text cannot exceed 75"); + } + + @Test + public void itRejectsActionIdOver255Chars() { + assertThatThrownBy(() -> validButton().setActionId(repeat(256)).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("action_id cannot exceed 255"); + } + + @Test + public void itRejectsAccessibilityLabelOver75Chars() { + assertThatThrownBy(() -> validButton().setAccessibilityLabel(repeat(76)).build()) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("accessibility_label cannot exceed 75"); + } +} diff --git a/slack-base/src/test/resources/video_block.json b/slack-base/src/test/resources/video_block.json new file mode 100644 index 00000000..c05456f7 --- /dev/null +++ b/slack-base/src/test/resources/video_block.json @@ -0,0 +1,12 @@ +{ + "type": "video", + "alt_text": "How to use Slack.", + "title": { "type": "plain_text", "text": "How to use Slack." }, + "thumbnail_url": "https://example.com/thumb.png", + "video_url": "https://www.youtube.com/embed/abc123", + "title_url": "https://www.youtube.com/watch?v=abc123", + "description": { "type": "plain_text", "text": "Watch this short demo." }, + "provider_name": "YouTube", + "provider_icon_url": "https://example.com/yt-icon.png", + "author_name": "Slack" +}