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"
+}