diff --git a/README.md b/README.md index 39631f4..f762d19 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,26 @@ let client = try SlackWebhookClient.create( try await client.send(Message(text: "Hello, Slack!")) ``` +## Convenience Builder API + +SlackKit includes a builder API for cleaner, more readable message construction: + +```swift +// Clean, declarative syntax +let message = Message { + Header("Deployment Complete!") + Section("Build *#123* was deployed to *production*") + Divider() + SectionBlock( + fields: [ + .markdown("*Environment:*\nProduction"), + .markdown("*Version:*\nv2.4.1") + ] + ) +} +try await client.send(message) +``` + ## Usage ### Simple Text Message @@ -79,52 +99,45 @@ try await client.send(message) ### Message with Blocks ```swift -let message = Message( - username: "DeployBot", - iconEmoji: ":rocket:", - blocks: [ - HeaderBlock(text: "Deployment Complete!"), - - SectionBlock( - text: .markdown("Build *#123* was deployed to *production*") - ), +let message = Message { + Header("Deployment Complete!") + Section(markdown: "Build *#123* was deployed to *production*") + Divider() + + Section { + Field.markdown("*Environment:*\nProduction") + Field.markdown("*Version:*\nv2.4.1") + Field.markdown("*Duration:*\n5m 32s") + Field.markdown("*Status:*\n:white_check_mark: Success") + } +} +try await client.send(message) +``` - DividerBlock(), +**With custom username and icon:** - SectionBlock( - fields: [ - .markdown("*Environment:*\nProduction"), - .markdown("*Version:*\nv2.4.1"), - .markdown("*Duration:*\n5m 32s"), - .markdown("*Status:*\n:white_check_mark: Success") - ] - ) - ] -) +```swift +let message = Message( + username: "DeployBot", + iconEmoji: ":rocket:" +) { + Header("Deployment Complete!") + Section("Build *#123* was deployed to *production*") + Divider() +} try await client.send(message) ``` ### Message with Actions ```swift -let message = Message( - text: "Approval required for production deployment", - blocks: [ - SectionBlock(text: .plainText("Deploy to production?")), - ActionsBlock(elements: [ - ButtonElement( - text: .plainText("Approve"), - style: .primary, - value: "approve" - ), - ButtonElement( - text: .plainText("Reject"), - style: .danger, - value: "reject" - ) - ]) - ] -) +let message = Message(text: "Approval required for production deployment") { + Section("Deploy to production?") + Actions { + ButtonElement(text: .plainText("Approve"), style: .primary, value: "approve") + ButtonElement(text: .plainText("Reject"), style: .danger, value: "reject") + } +} try await client.send(message) ``` @@ -165,13 +178,18 @@ try await client.send(message) Text sections with optional fields: ```swift -SectionBlock( - text: .markdown("Some *formatted* text"), - fields: [ - .markdown("*Field 1*\nValue 1"), - .markdown("*Field 2*\nValue 2") - ] -) +Section("Some *formatted* text") +// Or with markdown +Section(markdown: "Some *formatted* text") +``` + +With fields using the result builder: + +```swift +Section { + Field.markdown("*Field 1*\nValue 1") + Field.plainText("Field 2") +} ``` ### Header Block @@ -179,7 +197,7 @@ SectionBlock( Large header text: ```swift -HeaderBlock(text: "Important Announcement") +Header("Important Announcement") ``` ### Divider Block @@ -187,7 +205,7 @@ HeaderBlock(text: "Important Announcement") Horizontal line divider: ```swift -DividerBlock() +Divider() ``` ### Image Block @@ -195,11 +213,7 @@ DividerBlock() Display an image: ```swift -ImageBlock( - imageURL: URL(string: "https://example.com/image.png")!, - altText: "An example image", - title: .plainText("Image Title") -) +Image(url: "https://example.com/image.png", altText: "An example image") ``` ### Actions Block @@ -207,28 +221,40 @@ ImageBlock( Interactive buttons: ```swift -ActionsBlock(elements: [ - ButtonElement( - text: .plainText("Click Me"), - actionID: "button_1", - value: "button_value", - style: .primary - ) -]) +Actions { + ButtonElement(text: .plainText("Click Me"), actionID: "button_1", value: "button_value", style: .primary) +} +``` + +The builder also supports conditionals and loops: + +```swift +Actions { + ButtonElement(text: .plainText("Approve"), actionID: "approve", value: "yes") + + if needsReview { + ButtonElement(text: .plainText("Request Review"), actionID: "review", value: "review") + } + + for option in options { + ButtonElement(text: .plainText(option), actionID: "opt_\(option)", value: option) + } +} ``` ### Context Block -Contextual information with images and text: +Contextual information with text and images: ```swift -ContextBlock(elements: [ - TextContextElement(text: "Created by @john"), - ImageContextElement( - imageURL: "https://example.com/avatar.png", - altText: "Avatar" - ) -]) +// Simple text context +Context("Created by @john", "2 minutes ago") + +// Or with elements using the builder +Context { + TextContextElement(text: "Created by @john") + ImageContextElement(imageURL: "https://example.com/avatar.png", altText: "Avatar") +} ``` ### Input Block (Modals) @@ -236,14 +262,9 @@ ContextBlock(elements: [ Input blocks for collecting user input in modals: ```swift -InputBlock( - label: .plainText("Task description"), - element: PlainTextInputElement( - placeholder: "Enter task details...", - multiline: true - ), - hint: .plainText("Be specific about the requirements"), - optional: false +Input( + label: "Task description", + element: PlainTextInputElement(placeholder: "Enter task details...", multiline: true) ) ``` @@ -263,26 +284,19 @@ ButtonElement( ### Select Menu ```swift -StaticSelectElement( - placeholder: .plainText("Choose an option"), - options: [ - Option(text: .plainText("Option 1"), value: "opt1"), - Option(text: .plainText("Option 2"), value: "opt2") - ] -) +StaticSelectElement(placeholder: .plainText("Choose an option")) { + Option(text: .plainText("Option 1"), value: "opt1") + Option(text: .plainText("Option 2"), value: "opt2") +} ``` ### Multi-Select Menu ```swift -MultiStaticSelectElement( - placeholder: .plainText("Select options"), - options: [ - Option(text: .plainText("Option 1"), value: "opt1"), - Option(text: .plainText("Option 2"), value: "opt2") - ], - maxSelectedItems: 3 -) +MultiStaticSelectElement(placeholder: .plainText("Select options"), maxSelectedItems: 3) { + Option(text: .plainText("Option 1"), value: "opt1") + Option(text: .plainText("Option 2"), value: "opt2") +} ``` ### Date Picker diff --git a/Sources/SlackKit/Client/SlackWebhookClient.swift b/Sources/SlackKit/Client/SlackWebhookClient.swift index f167869..f2a3c06 100644 --- a/Sources/SlackKit/Client/SlackWebhookClient.swift +++ b/Sources/SlackKit/Client/SlackWebhookClient.swift @@ -86,19 +86,18 @@ public final actor SlackWebhookClient { // Send the request let response = try await networkClient.post(url: webhookURL, body: body) + // Check for rate limiting (must check before isSuccess) + if response.statusCode == 429 { + let retryAfter = extractRetryAfter(from: response) ?? 60 + throw SlackError.rateLimitExceeded(retryAfter: retryAfter) + } + // Check for HTTP errors guard response.isSuccess else { let bodyString = String(data: response.data, encoding: .utf8) throw SlackError.invalidResponse(statusCode: response.statusCode, body: bodyString) } - // Check for rate limiting - if response.statusCode == 429 { - if let retryAfter = extractRetryAfter(from: response) { - throw SlackError.rateLimitExceeded(retryAfter: retryAfter) - } - } - // Decode the response // Slack webhooks return "ok" as plain text on success if let bodyString = String(data: response.data, encoding: .utf8), diff --git a/Sources/SlackKit/Client/URLSessionNetworkClient.swift b/Sources/SlackKit/Client/URLSessionNetworkClient.swift index b962854..ad6f95b 100644 --- a/Sources/SlackKit/Client/URLSessionNetworkClient.swift +++ b/Sources/SlackKit/Client/URLSessionNetworkClient.swift @@ -8,18 +8,11 @@ import Foundation /// A URLSession-based implementation of NetworkClient public actor URLSessionNetworkClient: NetworkClient { private let session: URLSession - private let decoder: JSONDecoder /// Initializes a new URLSession network client - /// - Parameters: - /// - session: The URLSession to use for requests (defaults to shared) - /// - decoder: The JSONDecoder to use for decoding responses (defaults to standard) - public init( - session: URLSession = .shared, - decoder: JSONDecoder = JSONDecoder() - ) { + /// - Parameter session: The URLSession to use for requests (defaults to shared) + public init(session: URLSession = .shared) { self.session = session - self.decoder = decoder } public func post(url: URL, body: Data) async throws -> HTTPResponse { diff --git a/Sources/SlackKit/Models/Blocks/ActionsBlock+Builder.swift b/Sources/SlackKit/Models/Blocks/ActionsBlock+Builder.swift new file mode 100644 index 0000000..597ca0b --- /dev/null +++ b/Sources/SlackKit/Models/Blocks/ActionsBlock+Builder.swift @@ -0,0 +1,73 @@ +import Foundation + +// MARK: - ActionsBuilder + +/// A result builder for constructing ActionsBlock elements +@resultBuilder +public enum ActionsBuilder { + /// Builds an empty element array + public static func buildBlock() -> [any BlockElement] { + [] + } + + /// Builds an element array from multiple elements + public static func buildBlock(_ components: [any BlockElement]...) -> [any BlockElement] { + components.flatMap { $0 } + } + + /// Builds an element array from a single element expression + public static func buildExpression(_ expression: any BlockElement) -> [any BlockElement] { + [expression] + } + + /// Builds an element array from an optional element expression + public static func buildExpression(_ expression: (any BlockElement)?) -> [any BlockElement] { + expression.map { [$0] } ?? [] + } + + /// Builds an element array from an array of elements (pass-through) + public static func buildExpression(_ expression: [any BlockElement]) -> [any BlockElement] { + expression + } + + /// Builds an element array from an if block + public static func buildIf(_ content: [any BlockElement]?) -> [any BlockElement] { + content ?? [] + } + + /// Builds an element array from an if-else block (first branch) + public static func buildEither(first component: [any BlockElement]) -> [any BlockElement] { + component + } + + /// Builds an element array from an if-else block (second branch) + public static func buildEither(second component: [any BlockElement]) -> [any BlockElement] { + component + } + + /// Builds an element array from a for loop + public static func buildArray(_ components: [[any BlockElement]]) -> [any BlockElement] { + components.flatMap { $0 } + } + + /// Builds the final element array + public static func buildFinalBlock(_ component: [any BlockElement]) -> [any BlockElement] { + component + } +} + +// MARK: - ActionsBlock Convenience Initializer + +extension ActionsBlock { + /// Initializes a new actions block using a result builder + /// - Parameters: + /// - blockID: An optional identifier for the block + /// - builder: A result builder closure that provides the elements + public init( + blockID: String? = nil, + @ActionsBuilder builder: () -> [any BlockElement] + ) { + self.elements = builder() + self.blockID = blockID + } +} diff --git a/Sources/SlackKit/Models/Blocks/ContextBlock+Builder.swift b/Sources/SlackKit/Models/Blocks/ContextBlock+Builder.swift new file mode 100644 index 0000000..1643be5 --- /dev/null +++ b/Sources/SlackKit/Models/Blocks/ContextBlock+Builder.swift @@ -0,0 +1,73 @@ +import Foundation + +// MARK: - ContextBuilder + +/// A result builder for constructing ContextBlock elements +@resultBuilder +public enum ContextBuilder { + /// Builds an empty element array + public static func buildBlock() -> [any ContextElement] { + [] + } + + /// Builds an element array from multiple elements + public static func buildBlock(_ components: [any ContextElement]...) -> [any ContextElement] { + components.flatMap { $0 } + } + + /// Builds an element array from a single element expression + public static func buildExpression(_ expression: any ContextElement) -> [any ContextElement] { + [expression] + } + + /// Builds an element array from an optional element expression + public static func buildExpression(_ expression: (any ContextElement)?) -> [any ContextElement] { + expression.map { [$0] } ?? [] + } + + /// Builds an element array from an array of elements (pass-through) + public static func buildExpression(_ expression: [any ContextElement]) -> [any ContextElement] { + expression + } + + /// Builds an element array from an if block + public static func buildIf(_ content: [any ContextElement]?) -> [any ContextElement] { + content ?? [] + } + + /// Builds an element array from an if-else block (first branch) + public static func buildEither(first component: [any ContextElement]) -> [any ContextElement] { + component + } + + /// Builds an element array from an if-else block (second branch) + public static func buildEither(second component: [any ContextElement]) -> [any ContextElement] { + component + } + + /// Builds an element array from a for loop + public static func buildArray(_ components: [[any ContextElement]]) -> [any ContextElement] { + components.flatMap { $0 } + } + + /// Builds the final element array + public static func buildFinalBlock(_ component: [any ContextElement]) -> [any ContextElement] { + component + } +} + +// MARK: - ContextBlock Convenience Initializer + +extension ContextBlock { + /// Initializes a new context block using a result builder + /// - Parameters: + /// - blockID: An optional identifier for the block + /// - builder: A result builder closure that provides the elements + public init( + blockID: String? = nil, + @ContextBuilder builder: () -> [any ContextElement] + ) { + self.elements = builder() + self.blockID = blockID + } +} diff --git a/Sources/SlackKit/Models/Blocks/ContextBlock.swift b/Sources/SlackKit/Models/Blocks/ContextBlock.swift index a216f48..272d118 100644 --- a/Sources/SlackKit/Models/Blocks/ContextBlock.swift +++ b/Sources/SlackKit/Models/Blocks/ContextBlock.swift @@ -71,11 +71,19 @@ public protocol ContextElement: BlockElement {} /// A text element for context blocks public struct TextContextElement: ContextElement { - public let type: String = "plain_text" + public let type: String // Can be "plain_text" or "mrkdwn" public var text: String - public init(text: String) { + public init(text: String, type: String = "plain_text") { self.text = text + self.type = type + } + + /// Creates a markdown text context element + /// - Parameter markdown: The markdown text + /// - Returns: A TextContextElement with markdown type + public static func markdown(_ markdown: String) -> TextContextElement { + TextContextElement(text: markdown, type: "mrkdwn") } enum CodingKeys: String, CodingKey { diff --git a/Sources/SlackKit/Models/Blocks/InputBlock.swift b/Sources/SlackKit/Models/Blocks/InputBlock.swift index 5f6c4bb..e0e1da9 100644 --- a/Sources/SlackKit/Models/Blocks/InputBlock.swift +++ b/Sources/SlackKit/Models/Blocks/InputBlock.swift @@ -54,7 +54,7 @@ public struct InputBlock: Block { case blockID = "block_id" case label case element - case dispatchAction = "dispatch_action_config" + case dispatchAction = "dispatch_action" case hint case optional } @@ -73,21 +73,23 @@ public struct InputBlock: Block { // Decode element polymorphically if let elementContainer = try? container.nestedContainer(keyedBy: CodingKeys.self, forKey: .element) { let elementType = try elementContainer.decode(String.self, forKey: .type) + let elementDecoder = try container.superDecoder(forKey: .element) + switch elementType { case "plain_text_input": - element = try PlainTextInputElement(from: decoder) + element = try PlainTextInputElement(from: elementDecoder) case "static_select": - element = try StaticSelectElement(from: decoder) + element = try StaticSelectElement(from: elementDecoder) case "datepicker": - element = try DatePickerElement(from: decoder) + element = try DatePickerElement(from: elementDecoder) case "multi_static_select": - element = try MultiStaticSelectElement(from: decoder) + element = try MultiStaticSelectElement(from: elementDecoder) case "multi_users_select": - element = try MultiUsersSelectElement(from: decoder) + element = try MultiUsersSelectElement(from: elementDecoder) case "multi_conversations_select": - element = try MultiConversationsSelectElement(from: decoder) + element = try MultiConversationsSelectElement(from: elementDecoder) case "multi_channels_select": - element = try MultiChannelsSelectElement(from: decoder) + element = try MultiChannelsSelectElement(from: elementDecoder) default: throw DecodingError.dataCorruptedError( forKey: .element, diff --git a/Sources/SlackKit/Models/Blocks/SectionBlock+Builder.swift b/Sources/SlackKit/Models/Blocks/SectionBlock+Builder.swift new file mode 100644 index 0000000..dd11cc9 --- /dev/null +++ b/Sources/SlackKit/Models/Blocks/SectionBlock+Builder.swift @@ -0,0 +1,79 @@ +import Foundation + +// MARK: - FieldsBuilder + +/// A result builder for constructing SectionBlock fields +@resultBuilder +public enum FieldsBuilder { + /// Builds an empty field array + public static func buildBlock() -> [TextObject] { + [] + } + + /// Builds a field array from multiple fields + public static func buildBlock(_ components: [TextObject]...) -> [TextObject] { + components.flatMap { $0 } + } + + /// Builds a field array from a single field expression + public static func buildExpression(_ expression: TextObject) -> [TextObject] { + [expression] + } + + /// Builds a field array from an optional field expression + public static func buildExpression(_ expression: TextObject?) -> [TextObject] { + expression.map { [$0] } ?? [] + } + + /// Builds a field array from an array of fields (pass-through) + public static func buildExpression(_ expression: [TextObject]) -> [TextObject] { + expression + } + + /// Builds a field array from an if block + public static func buildIf(_ content: [TextObject]?) -> [TextObject] { + content ?? [] + } + + /// Builds a field array from an if-else block (first branch) + public static func buildEither(first component: [TextObject]) -> [TextObject] { + component + } + + /// Builds a field array from an if-else block (second branch) + public static func buildEither(second component: [TextObject]) -> [TextObject] { + component + } + + /// Builds a field array from a for loop + public static func buildArray(_ components: [[TextObject]]) -> [TextObject] { + components.flatMap { $0 } + } + + /// Builds the final field array + public static func buildFinalBlock(_ component: [TextObject]) -> [TextObject] { + component + } +} + +// MARK: - SectionBlock Convenience Initializer + +extension SectionBlock { + /// Initializes a new section block with fields using a result builder + /// - Parameters: + /// - text: Optional text for the block + /// - accessory: An optional accessory element + /// - blockID: An optional identifier for the block + /// - builder: A result builder closure that provides the fields + public init( + text: TextObject? = nil, + accessory: (any BlockElement)? = nil, + blockID: String? = nil, + @FieldsBuilder builder: () -> [TextObject] + ) { + self.text = text + self.fields = builder() + self.accessory = accessory + self.blockID = blockID + } +} diff --git a/Sources/SlackKit/Models/Elements/DatePickerElement.swift b/Sources/SlackKit/Models/Elements/DatePickerElement.swift index c6c0e17..7df435e 100644 --- a/Sources/SlackKit/Models/Elements/DatePickerElement.swift +++ b/Sources/SlackKit/Models/Elements/DatePickerElement.swift @@ -7,19 +7,19 @@ public struct DatePickerElement: BlockElement { public let type: String = "datepicker" public var actionID: String? public var placeholder: TextObject - public var initialDate: Int? + public var initialDate: String? public var confirm: ConfirmationDialog? /// Initializes a new date picker element /// - Parameters: /// - actionID: An identifier for the action /// - placeholder: The placeholder text - /// - initialDate: The initial date as a Unix timestamp + /// - initialDate: The initial date in YYYY-MM-DD format /// - confirm: An optional confirmation dialog public init( actionID: String? = nil, placeholder: TextObject, - initialDate: Int? = nil, + initialDate: String? = nil, confirm: ConfirmationDialog? = nil ) { self.actionID = actionID @@ -28,6 +28,18 @@ public struct DatePickerElement: BlockElement { self.confirm = confirm } + /// Creates a date string in YYYY-MM-DD format from a Date + /// - Parameter date: The date to format + /// - Returns: A string in YYYY-MM-DD format + public static func formatDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM-dd" + formatter.locale = Locale(identifier: "en_US_POSIX") + formatter.calendar = Calendar(identifier: .iso8601) + formatter.timeZone = TimeZone(secondsFromGMT: 0) + return formatter.string(from: date) + } + enum CodingKeys: String, CodingKey { case type case actionID = "action_id" diff --git a/Sources/SlackKit/Models/Elements/OptionGroup+Builder.swift b/Sources/SlackKit/Models/Elements/OptionGroup+Builder.swift new file mode 100644 index 0000000..cb3916a --- /dev/null +++ b/Sources/SlackKit/Models/Elements/OptionGroup+Builder.swift @@ -0,0 +1,17 @@ +import Foundation + +// MARK: - OptionGroup Convenience Initializer + +extension OptionGroup { + /// Initializes a new option group using a result builder for options + /// - Parameters: + /// - label: A label for the group + /// - builder: A result builder closure that provides the options + public init( + label: TextObject, + @OptionsBuilder builder: () -> [Option] + ) { + self.label = label + self.options = builder() + } +} diff --git a/Sources/SlackKit/Models/Elements/OverflowElement+Builder.swift b/Sources/SlackKit/Models/Elements/OverflowElement+Builder.swift new file mode 100644 index 0000000..d4f423c --- /dev/null +++ b/Sources/SlackKit/Models/Elements/OverflowElement+Builder.swift @@ -0,0 +1,17 @@ +import Foundation + +// MARK: - OverflowElement Convenience Initializer + +extension OverflowElement { + /// Initializes a new overflow element using a result builder for options + /// - Parameters: + /// - actionID: An optional identifier for the action + /// - builder: A result builder closure that provides the options + public init( + actionID: String? = nil, + @OptionsBuilder builder: () -> [Option] + ) { + self.actionID = actionID + self.options = builder() + } +} diff --git a/Sources/SlackKit/Models/Elements/SelectElement+Builder.swift b/Sources/SlackKit/Models/Elements/SelectElement+Builder.swift new file mode 100644 index 0000000..b13f272 --- /dev/null +++ b/Sources/SlackKit/Models/Elements/SelectElement+Builder.swift @@ -0,0 +1,112 @@ +import Foundation + +// MARK: - OptionsBuilder + +/// A result builder for constructing select menu options +@resultBuilder +public enum OptionsBuilder { + /// Builds an empty option array + public static func buildBlock() -> [Option] { + [] + } + + /// Builds an option array from multiple options + public static func buildBlock(_ components: [Option]...) -> [Option] { + components.flatMap { $0 } + } + + /// Builds an option array from a single option expression + public static func buildExpression(_ expression: Option) -> [Option] { + [expression] + } + + /// Builds an option array from an optional option expression + public static func buildExpression(_ expression: Option?) -> [Option] { + expression.map { [$0] } ?? [] + } + + /// Builds an option array from an array of options (pass-through) + public static func buildExpression(_ expression: [Option]) -> [Option] { + expression + } + + /// Builds an option array from an if block + public static func buildIf(_ content: [Option]?) -> [Option] { + content ?? [] + } + + /// Builds an option array from an if-else block (first branch) + public static func buildEither(first component: [Option]) -> [Option] { + component + } + + /// Builds an option array from an if-else block (second branch) + public static func buildEither(second component: [Option]) -> [Option] { + component + } + + /// Builds an option array from a for loop + public static func buildArray(_ components: [[Option]]) -> [Option] { + components.flatMap { $0 } + } + + /// Builds the final option array + public static func buildFinalBlock(_ component: [Option]) -> [Option] { + component + } +} + +// MARK: - StaticSelectElement Convenience Initializer + +extension StaticSelectElement { + /// Initializes a new static select element using a result builder for options + /// - Parameters: + /// - placeholder: The placeholder text + /// - actionID: An optional identifier for the action + /// - initialOption: The initially selected option + /// - confirm: An optional confirmation dialog + /// - builder: A result builder closure that provides the options + public init( + placeholder: TextObject, + actionID: String? = nil, + initialOption: Option? = nil, + confirm: ConfirmationDialog? = nil, + @OptionsBuilder builder: () -> [Option] + ) { + self.placeholder = placeholder + self.actionID = actionID + self.options = builder() + self.optionGroups = nil + self.initialOption = initialOption + self.confirm = confirm + } +} + +// MARK: - MultiStaticSelectElement Convenience Initializer + +extension MultiStaticSelectElement { + /// Initializes a new multi-select element using a result builder for options + /// - Parameters: + /// - placeholder: The placeholder text + /// - actionID: An optional identifier for the action + /// - maxSelectedItems: Maximum number of items that can be selected + /// - initialOptions: The initially selected options + /// - confirm: An optional confirmation dialog + /// - builder: A result builder closure that provides the options + public init( + placeholder: TextObject, + actionID: String? = nil, + maxSelectedItems: Int? = nil, + initialOptions: [Option]? = nil, + confirm: ConfirmationDialog? = nil, + @OptionsBuilder builder: () -> [Option] + ) { + self.placeholder = placeholder + self.actionID = actionID + self.options = builder() + self.optionGroups = nil + self.maxSelectedItems = maxSelectedItems + self.initialOptions = initialOptions + self.confirm = confirm + } +} diff --git a/Sources/SlackKit/Models/Message+Builder.swift b/Sources/SlackKit/Models/Message+Builder.swift new file mode 100644 index 0000000..3fa9d12 --- /dev/null +++ b/Sources/SlackKit/Models/Message+Builder.swift @@ -0,0 +1,286 @@ +import Foundation + +// MARK: - MessageBuilder + +/// A result builder for constructing Slack messages with blocks +@resultBuilder +public enum MessageBuilder { + /// Builds an empty block array + public static func buildBlock() -> [any Block] { + [] + } + + /// Builds a block array from multiple blocks + public static func buildBlock(_ components: [any Block]...) -> [any Block] { + components.flatMap { $0 } + } + + /// Builds a block array from a single block expression + public static func buildExpression(_ expression: any Block) -> [any Block] { + [expression] + } + + /// Builds a block array from an optional block expression + public static func buildExpression(_ expression: (any Block)?) -> [any Block] { + expression.map { [$0] } ?? [] + } + + /// Builds a block array from an array of blocks (pass-through) + public static func buildExpression(_ expression: [any Block]) -> [any Block] { + expression + } + + /// Builds a block array from an if block + public static func buildIf(_ content: [any Block]?) -> [any Block] { + content ?? [] + } + + /// Builds a block array from an if-else block (first branch) + public static func buildEither(first component: [any Block]) -> [any Block] { + component + } + + /// Builds a block array from an if-else block (second branch) + public static func buildEither(second component: [any Block]) -> [any Block] { + component + } + + /// Builds a block array from a for loop + public static func buildArray(_ components: [[any Block]]) -> [any Block] { + components.flatMap { $0 } + } + + /// Builds the final block array + public static func buildFinalBlock(_ component: [any Block]) -> [any Block] { + component + } +} + +// MARK: - Message Convenience Initializer + +extension Message { + /// Initializes a new message using a result builder with all Message options + /// - Parameters: + /// - text: A plain-text summary of the message + /// - blocks: An array of layout blocks (built from result builder) + /// - attachments: Legacy attachments + /// - username: Override the bot's username + /// - iconEmoji: Override the bot's icon with an emoji (e.g., ":rocket:") + /// - iconURL: Override the bot's icon with an image URL + /// - channel: Send to a specific channel + /// - threadTimestamp: Parent message timestamp for threading + /// - unfurlLinks: Enable automatic unfurling of links + /// - unfurlMedia: Enable automatic unfurling of media + /// - replyBroadcast: Reply broadcasts (for threaded messages) + /// - mrkdwn: Whether to format message text using mrkdwn formatting + /// - content: A result builder closure containing blocks + /// - Returns: A new message with all specified options + public init( + text: String? = nil, + attachments: [Attachment]? = nil, + username: String? = nil, + iconEmoji: String? = nil, + iconURL: String? = nil, + channel: String? = nil, + threadTimestamp: String? = nil, + unfurlLinks: Bool? = nil, + unfurlMedia: Bool? = nil, + replyBroadcast: Bool? = nil, + mrkdwn: Bool? = nil, + @MessageBuilder content: () -> [any Block] + ) { + let builtBlocks = content() + self.init( + text: text, + blocks: builtBlocks.isEmpty ? nil : builtBlocks, + attachments: attachments, + username: username, + iconEmoji: iconEmoji, + iconURL: iconURL, + channel: channel, + threadTimestamp: threadTimestamp, + unfurlLinks: unfurlLinks, + unfurlMedia: unfurlMedia, + replyBroadcast: replyBroadcast, + mrkdwn: mrkdwn + ) + } +} + +// MARK: - Block Convenience Functions + +/// Helper enum for creating section block fields +public enum Field { + /// Creates a markdown field + /// - Parameter string: The markdown string + /// - Returns: A TextObject with markdown formatting + public static func markdown(_ string: String) -> TextObject { + .markdown(string) + } + + /// Creates a plain text field + /// - Parameter string: The plain text string + /// - Returns: A TextObject with plain text formatting + public static func plainText(_ string: String) -> TextObject { + .plainText(string) + } +} + +/// Creates a section block with plain text +/// - Parameters: +/// - text: The text string for the section +/// - blockID: An optional identifier for the block +/// - Returns: A section block with the specified text +public func Section(_ text: String, blockID: String? = nil) -> SectionBlock { + SectionBlock(text: .plainText(text), blockID: blockID) +} + +/// Creates a section block with markdown text +/// - Parameters: +/// - markdown: The markdown text string +/// - blockID: An optional identifier for the block +/// - Returns: A section block with markdown text +public func Section(markdown: String, blockID: String? = nil) -> SectionBlock { + SectionBlock(text: .markdown(markdown), blockID: blockID) +} + +/// Creates a section block with fields using a result builder +/// - Parameters: +/// - text: Optional text for the block +/// - accessory: An optional accessory element +/// - blockID: An optional identifier for the block +/// - builder: A result builder closure that provides the fields +/// - Returns: A section block with fields +public func Section( + text: String? = nil, + accessory: (any BlockElement)? = nil, + blockID: String? = nil, + @FieldsBuilder builder: () -> [TextObject] +) -> SectionBlock { + SectionBlock( + text: text.map { .plainText($0) }, + accessory: accessory, + blockID: blockID, + builder: builder + ) +} + +/// Creates a section block with markdown text and fields using a result builder +/// - Parameters: +/// - markdown: Optional markdown text for the block +/// - accessory: An optional accessory element +/// - blockID: An optional identifier for the block +/// - builder: A result builder closure that provides the fields +/// - Returns: A section block with fields +public func Section( + markdown: String? = nil, + accessory: (any BlockElement)? = nil, + blockID: String? = nil, + @FieldsBuilder builder: () -> [TextObject] +) -> SectionBlock { + SectionBlock( + text: markdown.map { .markdown($0) }, + accessory: accessory, + blockID: blockID, + builder: builder + ) +} + +/// Creates a divider block +/// - Parameter blockID: An optional identifier for the block +/// - Returns: A divider block +public func Divider(blockID: String? = nil) -> DividerBlock { + DividerBlock(blockID: blockID) +} + +/// Creates a header block with plain text +/// - Parameters: +/// - text: The text string for the header (max 150 characters) +/// - blockID: An optional identifier for the block +/// - Returns: A header block +public func Header(_ text: String, blockID: String? = nil) -> HeaderBlock { + HeaderBlock(text: .plainText(text), blockID: blockID) +} + +/// Creates an image block +/// - Parameters: +/// - url: The URL of the image +/// - altText: Alt text for the image +/// - blockID: An optional identifier for the block +/// - Returns: An image block +public func Image(url: String, altText: String, blockID: String? = nil) -> ImageBlock { + guard let imageURL = URL(string: url) else { + fatalError("Invalid URL string: \(url)") + } + return ImageBlock(imageURL: imageURL, altText: altText, blockID: blockID) +} + +/// Creates a context block with text strings +/// - Parameters: +/// - texts: The text strings to display (converted to TextContextElement) +/// - blockID: An optional identifier for the block +/// - Returns: A context block +public func Context(_ texts: String..., blockID: String? = nil) -> ContextBlock { + ContextBlock(elements: texts.map { TextContextElement(text: $0) }, blockID: blockID) +} + +/// Creates a context block with text elements +/// - Parameters: +/// - elements: The text objects to display (converted to TextContextElement) +/// - blockID: An optional identifier for the block +/// - Returns: A context block +public func Context(elements: [any ContextElement], blockID: String? = nil) -> ContextBlock { + ContextBlock(elements: elements, blockID: blockID) +} + +/// Creates a context block using a result builder +/// - Parameters: +/// - blockID: An optional identifier for the block +/// - builder: A result builder closure that provides the elements +/// - Returns: A context block +public func Context( + blockID: String? = nil, + @ContextBuilder builder: () -> [any ContextElement] +) -> ContextBlock { + ContextBlock(elements: builder(), blockID: blockID) +} + +/// Creates an actions block with elements +/// - Parameters: +/// - elements: The interactive elements +/// - blockID: An optional identifier for the block +/// - Returns: An actions block +public func Actions(_ elements: any BlockElement..., blockID: String? = nil) -> ActionsBlock { + ActionsBlock(elements: elements, blockID: blockID) +} + +/// Creates an actions block using a result builder +/// - Parameters: +/// - blockID: An optional identifier for the block +/// - builder: A result builder closure that provides the elements +/// - Returns: An actions block +public func Actions( + blockID: String? = nil, + @ActionsBuilder builder: () -> [any BlockElement] +) -> ActionsBlock { + ActionsBlock(elements: builder(), blockID: blockID) +} + +/// Creates an input block +/// - Parameters: +/// - label: The label for the input (as plain text) +/// - element: The input element +/// - blockID: An optional identifier for the block +/// - Returns: An input block +public func Input(label: String, element: any BlockElement, blockID: String? = nil) -> InputBlock { + InputBlock(label: .plainText(label), element: element, blockID: blockID) +} + +/// Creates a call block +/// - Parameters: +/// - callID: The call ID +/// - blockID: An optional identifier for the block +/// - Returns: A call block +public func Call(callID: String, blockID: String? = nil) -> CallBlock { + CallBlock(callID: callID, blockID: blockID) +} diff --git a/Tests/SlackKitTests/ClientTests/SlackWebhookClientTests.swift b/Tests/SlackKitTests/ClientTests/SlackWebhookClientTests.swift index 2c36486..0dc2827 100644 --- a/Tests/SlackKitTests/ClientTests/SlackWebhookClientTests.swift +++ b/Tests/SlackKitTests/ClientTests/SlackWebhookClientTests.swift @@ -115,6 +115,32 @@ struct SlackWebhookClientTests { } } + @Test("Handle rate limit response") + func handleRateLimitResponse() async throws { + // Arrange + let webhookURL = URL(string: "https://hooks.slack.com/services/T/B/C")! + let mockClient = MockNetworkClient() + let slackClient = SlackWebhookClient(webhookURL: webhookURL, networkClient: mockClient) + + // Mock a 429 response without body hints + let responseData = #"{"error": "rate_limited"}"#.data(using: .utf8)! + await mockClient.addResponse(statusCode: 429, data: responseData) + + // Act & Assert + let message = Message(text: "Test") + do { + _ = try await slackClient.send(message) + #expect(false, "Expected rate limit error") + } catch let error as SlackError { + switch error { + case .rateLimitExceeded(let retryAfter): + #expect(retryAfter == 60) + default: + #expect(false, "Unexpected error: \(error)") + } + } + } + @Test("Send message with attachments") func sendMessageWithAttachments() async throws { // Arrange diff --git a/Tests/SlackKitTests/IntegrationTests/SlackKitIntegrationTests.swift b/Tests/SlackKitTests/IntegrationTests/SlackKitIntegrationTests.swift new file mode 100644 index 0000000..5daaff1 --- /dev/null +++ b/Tests/SlackKitTests/IntegrationTests/SlackKitIntegrationTests.swift @@ -0,0 +1,1333 @@ +import Foundation +import Testing +@testable import SlackKit + +// MARK: - SlackKit Integration Tests + +/* + # SlackKit Integration Tests + + These are end-to-end tests that send actual messages to Slack via Incoming Webhooks. + + ## Prerequisites + + 1. **Create a Slack Webhook URL**: + - Go to your Slack workspace + - Navigate to: https://api.slack.com/messaging/webhooks + - Click "Get Started" or "Create your Slack app" + - Enable Incoming Webhooks + - Install the app to your workspace + - Copy the Webhook URL + + 2. **Set Environment Variables**: + + ```bash + export SLACK_INTEGRATION_TESTS=1 + export SLACK_TEST_WEBHOOK_URL="https://hooks.slack.com/services/YOUR/WEBHOOK/URL" + ``` + + Or run tests inline: + + ```bash + SLACK_INTEGRATION_TESTS=1 SLACK_TEST_WEBHOOK_URL="https://hooks.slack.com/services/YOUR/WEBHOOK/URL" swift test + ``` + + ## Running Tests + + Run all tests (integration tests will be skipped unless env vars are set): + ```bash + swift test + ``` + + Run only integration tests: + ```bash + SLACK_INTEGRATION_TESTS=1 SLACK_TEST_WEBHOOK_URL="your_url" swift test --filter "SlackKit Integration Tests" + ``` + + ## What Gets Tested + + - Simple text messages + - Messages with custom username and icons + - All block types (Header, Section, Divider, Image, Context, Actions, Input) + - Interactive elements (Buttons, Select menus, Date pickers, Overflow menus) + - Multi-select elements (Users, Conversations, Channels) + - Builder API with conditionals and loops + - Legacy attachments + - Special characters and formatting + - Complex multi-block messages + + ## Important Notes + + - Tests are **serialized** (run one at a time) to avoid rate limits + - Each test includes a 1-second delay between requests + - Tests send actual messages to your Slack workspace + - A dedicated test channel is recommended + - Some tests verify that InputBlocks throw errors (they only work in modals, not webhooks) + + ## Safety + + - Integration tests are **disabled by default** + - Tests only run when both environment variables are set + - All messages include emoji indicators (🧪) for easy identification + */ + +@Suite( + "SlackKit Integration Tests", + .serialized, + .enabled(if: { + // Only run integration tests when SLACK_INTEGRATION_TESTS environment variable is set + ProcessInfo.processInfo.environment["SLACK_INTEGRATION_TESTS"] != nil + }()) +) +struct SlackKitIntegrationTests { + + // MARK: - Test Configuration + + private var webhookURL: URL { + guard let urlString = ProcessInfo.processInfo.environment["SLACK_TEST_WEBHOOK_URL"], + let url = URL(string: urlString) else { + fatalError("SLACK_TEST_WEBHOOK_URL environment variable must be set to a valid URL") + } + return url + } + + private let defaultTimeout: Duration = .seconds(30) + + // MARK: - Helper Methods + + private func createClient() -> SlackWebhookClient { + SlackWebhookClient(webhookURL: webhookURL) + } + + private func waitFor(_ duration: Duration) async { + // Add random jitter (0-500ms) to help avoid rate limits + let jitter = Duration.seconds(Double.random(in: 0...0.5)) + try? await Task.sleep(for: duration + jitter) + } + + // MARK: - Simple Text Messages + + @Test("Send simple text message") + func sendSimpleTextMessage() async throws { + let client = createClient() + let message = Message(text: "🧪 Integration Test: Simple text message") + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message with username and icon") + func sendMessageWithUsernameAndIcon() async throws { + let client = createClient() + let message = Message( + text: "🧪 Integration Test: Message with custom username and icon", + username: "SlackKit Test Bot", + iconEmoji: ":robot_face:" + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message with custom icon URL") + func sendMessageWithIconURL() async throws { + let client = createClient() + let message = Message( + text: "🧪 Integration Test: Message with icon URL", + username: "SlackKit", + iconURL: "https://httpbin.org/image/png" + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - Header Block + + @Test("Send message with multiple headers") + func sendMessageWithMultipleHeaders() async throws { + let client = createClient() + let message = Message { + Header("First Header") + Divider() + Header("Second Header") + } + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - Section Block + + @Test("Send message with SectionBlock and fields") + func sendMessageWithSectionBlockFields() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Section Block - Fields"), + SectionBlock( + fields: [ + .markdown("*Field 1:*\nValue 1"), + .markdown("*Field 2:*\nValue 2"), + .markdown("*Field 3:*\nValue 3"), + .markdown("*Field 4:*\nValue 4") + ] + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message with SectionBlock with text and fields") + func sendMessageWithSectionBlockTextAndFields() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Section Block - Text + Fields"), + SectionBlock( + text: .plainText("This section has both text and fields below:"), + fields: [ + .plainText("First field"), + .plainText("Second field") + ] + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - Image Block + + @Test("Send message with ImageBlock") + func sendMessageWithImageBlock() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Image Block"), + ImageBlock( + imageURL: URL(string: "https://httpbin.org/image/png")!, + altText: "Swift Logo", + title: .plainText("Swift Programming Language") + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message with multiple images") + func sendMessageWithMultipleImages() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Multiple Images"), + ImageBlock( + imageURL: URL(string: "https://httpbin.org/image/png")!, + altText: "Test Image 1", + title: .plainText("Image 1") + ), + ImageBlock( + imageURL: URL(string: "https://httpbin.org/image/jpeg")!, + altText: "Test Image 2", + title: .plainText("Image 2") + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - Context Block + + @Test("Send message with ContextBlock") + func sendMessageWithContextBlock() async throws { + let client = createClient() + let message = Message { + Header("🧪 Context Block") + Section("Main content of the message") + Context( + elements: [ + TextContextElement(text: "Created by SlackKit • "), + ImageContextElement( + imageURL: "https://httpbin.org/image/png", + altText: "Swift" + ), + TextContextElement(text: " • Integration Test") + ] + ) + } + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - Builder API Tests + + @Test("Send message using builder API - basic") + func sendMessageUsingBuilderBasic() async throws { + let client = createClient() + let message = Message { + Header("🧪 Builder API - Basic") + Section("This message was created using the result builder API") + Divider() + Section(markdown: "Clean and *readable* syntax") + } + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message using builder API - conditional blocks") + func sendMessageUsingBuilderConditional() async throws { + let client = createClient() + let includeImage = true + let showExtraInfo = false + + let message = Message { + Header("🧪 Builder API - Conditional Blocks") + + if includeImage { + Image( + url: "https://httpbin.org/image/png", + altText: "Conditional Image" + ) + } + + Section("This block appears conditionally") + + if showExtraInfo { + Section("This won't appear because showExtraInfo is false") + } else { + Section("This appears because showExtraInfo is false") + } + } + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message using builder API - for loops") + func sendMessageUsingBuilderLoops() async throws { + let client = createClient() + let items = ["Item 1", "Item 2", "Item 3"] + + let message = Message { + Header("🧪 Builder API - For Loops") + + for item in items { + Section(item) + Divider() + } + } + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message using builder API - complex message") + func sendMessageUsingBuilderComplex() async throws { + let client = createClient() + let message = Message( + text: "Builder API Complex Message", + username: "Builder Bot", + iconEmoji: ":construction_worker:" + ) { + Header("🧪 Builder API - Complex Message") + + Section(markdown: """ + This message demonstrates the *full power* of the builder API: + • Clean syntax + • Type-safe + • Expressive + """) + + Divider() + + let features = [ + ("Result Builders", "Swift 5.4+"), + ("Type Safety", "Compile-time checks"), + ("Expressive", "Clean and readable") + ] + + for (feature, description) in features { + Section(markdown: "*\(feature)*\n\(description)") + if feature != features.last?.0 { + Divider() + } + } + + Context( + "Built with ", + "Swift 6", + " • ", + "Result Builders" + ) + + Actions( + ButtonElement( + text: .plainText("Learn More"), + actionID: "learn_more", + value: "clicked", + style: .primary + ), + ButtonElement( + text: .plainText("Documentation"), + url: "https://slack.dev" + ) + ) + } + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message using builder API - all convenience functions") + func sendMessageUsingBuilderAllConvenience() async throws { + let client = createClient() + let message = Message { + Header("🧪 All Convenience Functions") + Section("Section with plain text") + Divider() + Section(markdown: "Section with *markdown*") + Context("Context 1", " • ", "Context 2") + } + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - Multi-Select Elements + + @Test("Send message with multi-static select") + func sendMessageWithMultiStaticSelect() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Multi-Static Select"), + SectionBlock( + text: .plainText("Choose multiple options:"), + accessory: MultiStaticSelectElement( + placeholder: .plainText("Select options"), + options: [ + Option(text: .plainText("Option 1"), value: "opt1"), + Option(text: .plainText("Option 2"), value: "opt2"), + Option(text: .plainText("Option 3"), value: "opt3") + ], + maxSelectedItems: 2 + ) + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message with multi-external select") + func sendMessageWithMultiExternalSelect() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Multi-External Select"), + SectionBlock( + text: .plainText("Select from external data source:"), + accessory: MultiExternalSelectElement( + placeholder: .plainText("Search items"), + minQueryLength: 3, + maxSelectedItems: 5 + ) + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message with multi-users select") + func sendMessageWithMultiUsersSelect() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Multi-Users Select"), + SectionBlock( + text: .plainText("Select users:"), + accessory: MultiUsersSelectElement( + placeholder: .plainText("Choose users"), + initialUsers: ["U123456"], + maxSelectedItems: 3 + ) + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message with multi-conversations select") + func sendMessageWithMultiConversationsSelect() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Multi-Conversations Select"), + SectionBlock( + text: .plainText("Select conversations:"), + accessory: MultiConversationsSelectElement( + placeholder: .plainText("Choose conversations"), + filter: ConversationFilter( + include: [.public, .private], + excludeBotUsers: true + ), + maxSelectedItems: 10 + ) + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message with multi-channels select") + func sendMessageWithMultiChannelsSelect() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Multi-Channels Select"), + SectionBlock( + text: .plainText("Select channels:"), + accessory: MultiChannelsSelectElement( + placeholder: .plainText("Choose channels"), + maxSelectedItems: 5 + ) + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - DatePicker Element + + @Test("Send message with DatePicker") + func sendMessageWithDatePicker() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Date Picker"), + SectionBlock( + text: .plainText("Select a date:"), + accessory: DatePickerElement( + placeholder: .plainText("Pick a date"), + initialDate: DatePickerElement.formatDate(Date()) + ) + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message with DatePicker and confirmation") + func sendMessageWithDatePickerConfirmation() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Date Picker with Confirmation"), + SectionBlock( + text: .plainText("Select a deadline:"), + accessory: DatePickerElement( + actionID: "deadline_picker", + placeholder: .plainText("Choose deadline"), + confirm: ConfirmationDialog( + title: .plainText("Confirm Date"), + text: .plainText("Are you sure you want to set this deadline?"), + confirm: .plainText("Set Deadline"), + deny: .plainText("Cancel"), + style: .primary + ) + ) + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - Image Element + + @Test("Send message with ImageElement accessory") + func sendMessageWithImageElementAccessory() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Image Element Accessory"), + SectionBlock( + text: .plainText("Section with image element:"), + accessory: ImageElement( + imageURL: "https://httpbin.org/image/png", + altText: "Swift Logo" + ) + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - Input Block (Modal-only) + + @Test("Send message with InputBlock and PlainTextInputElement") + func sendMessageWithInputBlock() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Input Block (Note: Only works in modals)"), + InputBlock( + label: .plainText("Task Name"), + element: PlainTextInputElement( + actionID: "task_name", + placeholder: "Enter task name", + maxLength: 100 + ), + hint: .plainText("Enter a descriptive name for the task"), + optional: false + ), + InputBlock( + label: .plainText("Description"), + element: PlainTextInputElement( + actionID: "task_description", + placeholder: "Enter detailed description", + multiline: true + ), + optional: true + ) + ] + ) + await #expect(throws: SlackError.self) { + try await client.send(message) + } + } + + @Test("Send message with InputBlock and SelectElement") + func sendMessageWithInputBlockSelect() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Input Block with Select"), + InputBlock( + label: .plainText("Priority"), + element: StaticSelectElement( + placeholder: .plainText("Select priority"), + options: [ + Option(text: .plainText("Low"), value: "low"), + Option(text: .plainText("Medium"), value: "medium"), + Option(text: .plainText("High"), value: "high") + ], + initialOption: Option(text: .plainText("Medium"), value: "medium") + ), + optional: false + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message using builder Input function") + func sendMessageUsingBuilderInput() async throws { + let client = createClient() + let message = Message { + Header("🧪 Builder Input Function") + + Input( + label: "Feedback", + element: PlainTextInputElement( + actionID: "feedback", + placeholder: "Enter your feedback", + multiline: true + ) + ) + } + await #expect(throws: SlackError.self) { + try await client.send(message) + } + } + + // MARK: - Conversation Filter Types + + @Test("Send message with all conversation filter types") + func sendMessageWithAllConversationFilters() async throws { + let client = createClient() + let filterTypes: [(ConversationFilterType, String)] = [ + (.public, "Public Channels"), + (.private, "Private Channels"), + (.im, "Direct Messages"), + (.mpim, "Group DMs") + ] + + for (filterType, description) in filterTypes { + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Conversation Filter - \(description)"), + SectionBlock( + text: .plainText("Select from \(description.lowercased()):"), + accessory: MultiConversationsSelectElement( + placeholder: .plainText("Choose conversations"), + filter: ConversationFilter(include: [filterType]) + ) + ) + ] + ) + let response = try await client.send(message) + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + } + + // MARK: - Complex Multi-Block Messages + + @Test("Send complex deployment notification message") + func sendComplexDeploymentNotification() async throws { + let client = createClient() + let message = Message( + text: "Production Alert", + blocks: [ + HeaderBlock(text: "Production Alert"), + SectionBlock( + text: .markdown("Critical Error in payment processing service") + ), + DividerBlock(), + SectionBlock( + fields: [ + .markdown("*Service:*\npayment-api"), + .markdown("*Region:*\nus-east-1") + ] + ), + ActionsBlock(elements: [ + ButtonElement( + text: .plainText("Investigate"), + actionID: "investigate_btn", + url: nil, + value: "investigate", + style: .danger + ) + ]) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send error alert message") + func sendErrorAlertMessage() async throws { + let client = createClient() + let message = Message( + text: "Production Alert", + blocks: [ + HeaderBlock(text: "⚠️ Production Alert"), + SectionBlock( + text: .markdown("Critical Error in payment processing service") + ), + DividerBlock(), + SectionBlock( + fields: [ + .markdown("*Service:*\npayment-api"), + .markdown("*Region:*\nus-east-1"), + .markdown("*Severity:*\n:rotating_light: Critical") + ] + ), + ActionsBlock(elements: [ + ButtonElement( + text: .plainText("Investigate"), + actionID: "investigate_btn", + url: nil, + value: "investigate", + style: .danger + ) + ]) + ], + username: "AlertBot", + iconEmoji: ":warning:" + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send feature announcement message") + func sendFeatureAnnouncementMessage() async throws { + let client = createClient() + let message = Message( + text: "New feature announcement", + blocks: [ + HeaderBlock(text: "🎉 New Feature Release"), + ImageBlock( + imageURL: URL(string: "https://httpbin.org/image/png")!, + altText: "Feature Preview", + title: .plainText("New Dashboard") + ), + SectionBlock( + text: .markdown("We're excited to announce our *new dashboard* with:\n• Real-time analytics\n• Customizable widgets\n• Dark mode support :new_moon_with_face:") + ), + DividerBlock(), + ContextBlock(elements: [ + TextContextElement(text: "Version 2.0 • Released: "), + TextContextElement(text: ISO8601DateFormatter().string(from: Date())) + ]), + ActionsBlock(elements: [ + ButtonElement( + text: .plainText("Learn More"), + actionID: "learn_more_btn", + url: nil, + value: "learn_more", + style: .primary + ), + ButtonElement( + text: .plainText("Watch Demo"), + actionID: nil, + url: "https://slack.com", + value: nil, + style: nil + ) + ]) + ], + username: "Product Updates", + iconEmoji: ":mega:" + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - Legacy Attachments + + @Test("Send message with legacy attachment") + func sendMessageWithLegacyAttachment() async throws { + let client = createClient() + let message = Message( + text: "This message uses legacy attachments", + attachments: [ + Attachment( + color: "good", + title: "Build Report", + text: "Build #1234 completed successfully", + fields: [ + AttachmentField(title: "Status", value: "Success", short: true), + AttachmentField(title: "Duration", value: "5m 32s", short: true), + AttachmentField(title: "Branch", value: "main", short: true), + AttachmentField(title: "Commit", value: "abc123", short: true) + ], + footer: "Build System", + footerTimestamp: Int(Date().timeIntervalSince1970) + ) + ], + username: "Legacy Bot", + iconEmoji: ":paperclip:" + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message with multiple attachments") + func sendMessageWithMultipleAttachments() async throws { + let client = createClient() + let message = Message( + text: "Multiple attachments test", + attachments: [ + Attachment( + color: "good", + title: "Success", + text: "Operation completed" + ), + Attachment( + color: "warning", + title: "Warning", + text: "Minor issues detected" + ), + Attachment( + color: "#439FE0", + title: "Info", + text: "Additional information" + ) + ], + username: "Multi-Attachment Bot" + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - Special Characters and Formatting + + @Test("Send message with emojis") + func sendMessageWithEmojis() async throws { + let client = createClient() + let message = Message( + text: "🎉👋 Hello! Testing emoji support: :rocket: :fire: :100: :tada:", + blocks: [ + SectionBlock( + text: .plainText("Emojis in blocks work too! :star: :heart: :thumbsup:") + ) + ], + username: "Emoji Bot :sparkles:", + iconEmoji: ":robot_face:" + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message with markdown formatting") + func sendMessageWithMarkdownFormatting() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Markdown Formatting Test"), + SectionBlock( + text: .markdown(""" +This is a *bold text* and this is _italic text_. +This is `code` and this is a ```code block```. +This is a ~strikethrough~ text. + +> Blockquote example + +* Bullet point 1 +* Bullet point 2 +* Bullet point 3 + +1. Numbered item 1 +2. Numbered item 2 +3. Numbered item 3 + +A and an . +""") + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message with special characters") + func sendMessageWithSpecialCharacters() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Special Characters Test"), + SectionBlock( + text: .markdown("Testing special characters: & < > \" ' ` ~ * _ { } [ ] ( )") + ), + SectionBlock( + text: .plainText("Unicode support: 你好 世界 🌍 Ñoño café") + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - Long Messages + + @Test("Send long message with many blocks") + func sendLongMessage() async throws { + let client = createClient() + var blocks: [any Block] = [ + HeaderBlock(text: "🧪 Long Message Test") + ] + + // Add multiple sections + for i in 1...10 { + blocks.append(SectionBlock( + text: .plainText("Section \(i): This is section number \(i) with some content to demonstrate scrolling through long messages.") + )) + if i < 10 { + blocks.append(DividerBlock()) + } + } + + let message = Message(blocks: blocks) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - All Block Types Together + + @Test("Send message with all block types") + func sendMessageWithAllBlockTypes() async throws { + let client = createClient() + let message = Message( + text: "Complete block type test", + blocks: [ + HeaderBlock(text: "🧪 Complete Block Type Test"), + SectionBlock(text: .markdown("This message demonstrates *all block types* supported by SlackKit")), + DividerBlock(), + SectionBlock( + fields: [ + .markdown("*Field A*"), + .markdown("*Field B*") + ] + ), + ImageBlock( + imageURL: URL(string: "https://httpbin.org/image/png")!, + altText: "Swift Logo", + title: .plainText("Swift") + ), + ContextBlock(elements: [ + TextContextElement(text: "Context info"), + ImageContextElement( + imageURL: "https://httpbin.org/image/png", + altText: "Swift" + ) + ]), + ActionsBlock(elements: [ + ButtonElement(text: .plainText("Button 1"), actionID: "button_1", url: nil, value: "b1", style: .primary), + ButtonElement(text: .plainText("Open Link"), actionID: nil, url: "https://slack.com", value: nil, style: nil) + ]), + DividerBlock(), + HeaderBlock(text: "End of Test") + ], + username: "SlackKit Test Suite", + iconEmoji: ":test_tube:" + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - Block IDs + + @Test("Send message with block IDs") + func sendMessageWithBlockIDs() async throws { + let client = createClient() + let message = Message( + blocks: [ + SectionBlock( + text: .plainText("Section with ID"), + blockID: "section_001" + ), + DividerBlock(blockID: "divider_001"), + HeaderBlock(text: "Header with ID", blockID: "header_001"), + ImageBlock( + imageURL: URL(string: "https://httpbin.org/image/png")!, + altText: "Swift", + blockID: "image_001" + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - Text Object Variations + + @Test("Send message with emoji enabled") + func sendMessageWithEmojiEnabled() async throws { + let client = createClient() + let message = Message( + blocks: [ + SectionBlock( + text: .plainText("Emoji should render :)", emoji: true) + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message with verbatim markdown") + func sendMessageWithVerbatimMarkdown() async throws { + let client = createClient() + let message = Message( + blocks: [ + SectionBlock( + text: .markdown("*This should NOT be bold*", verbatim: true) + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - Select Menu Element + + @Test("Send message with select menu") + func sendMessageWithSelectMenu() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Select Menu Element"), + SectionBlock( + text: .plainText("Choose an option:"), + accessory: StaticSelectElement( + placeholder: .plainText("Select an option"), + options: [ + Option(text: .plainText("Option 1"), value: "opt1"), + Option(text: .plainText("Option 2"), value: "opt2"), + Option(text: .plainText("Option 3"), value: "opt3") + ] + ) + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message with option groups") + func sendMessageWithOptionGroups() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Option Groups"), + SectionBlock( + text: .plainText("Select from grouped options:"), + accessory: StaticSelectElement( + placeholder: .plainText("Choose a fruit"), + optionGroups: [ + OptionGroup( + label: .plainText("Citrus"), + options: [ + Option(text: .plainText("Orange"), value: "orange"), + Option(text: .plainText("Lemon"), value: "lemon") + ] + ), + OptionGroup( + label: .plainText("Berries"), + options: [ + Option(text: .plainText("Strawberry"), value: "strawberry"), + Option(text: .plainText("Blueberry"), value: "blueberry") + ] + ) + ] + ) + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - Button with Confirmation Dialog + + @Test("Send message with confirmation dialog") + func sendMessageWithConfirmationDialog() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Confirmation Dialog"), + SectionBlock(text: .plainText("Destructive action requires confirmation")), + ActionsBlock(elements: [ + ButtonElement( + text: .plainText("Delete"), + actionID: "delete_btn", + url: nil, + value: "delete", + style: .danger, + confirm: ConfirmationDialog( + title: .plainText("Are you sure?"), + text: .plainText("This action cannot be undone."), + confirm: .plainText("Delete"), + deny: .plainText("Cancel"), + style: .danger + ) + ) + ]) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - Overflow Menu + + @Test("Send message with overflow menu") + func sendMessageWithOverflowMenu() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "🧪 Overflow Menu"), + SectionBlock( + text: .plainText("Click the menu to see more options:"), + accessory: OverflowElement(options: [ + Option(text: .plainText("Option 1"), value: "opt1"), + Option(text: .plainText("Option 2"), value: "opt2"), + Option(text: .plainText("Option 3"), value: "opt3"), + Option(text: .plainText("Option 4"), value: "opt4"), + Option(text: .plainText("Option 5"), value: "opt5") + ]) + ) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - Empty/Edge Cases + + @Test("Send message with empty text") + func sendMessageWithEmptyText() async throws { + let client = createClient() + let message = Message( + text: "", + blocks: [ + SectionBlock(text: .plainText("Text is empty but blocks are present")) + ] + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message with only text (no blocks)") + func sendMessageWithOnlyText() async throws { + let client = createClient() + let message = Message(text: "This is a simple message with only text, no blocks.") + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + @Test("Send message with link unfurling disabled") + func sendMessageWithLinkUnfurlingDisabled() async throws { + let client = createClient() + let message = Message( + text: "Link unfurling test: https://slack.com", + unfurlLinks: false, + unfurlMedia: false + ) + let response = try await client.send(message) + + #expect(response.ok == true) + await waitFor(.seconds(1)) + } + + // MARK: - Final Summary Test + + @Test("Send integration test summary") + func sendIntegrationTestSummary() async throws { + let client = createClient() + let message = Message( + blocks: [ + HeaderBlock(text: "✅ Integration Tests Complete"), + SectionBlock( + text: .markdown("All SlackKit features have been tested successfully!\n\nThe following were tested:") + ), + SectionBlock( + fields: [ + .plainText("Simple text messages"), + .plainText("Header blocks"), + .plainText("Section blocks"), + .plainText("Divider blocks"), + .plainText("Image blocks") + ] + ), + SectionBlock( + fields: [ + .plainText("Actions blocks"), + .plainText("Context blocks"), + .plainText("Legacy attachments"), + .plainText("Markdown formatting"), + .plainText("Special characters") + ] + ), + SectionBlock( + fields: [ + .plainText("Select menus"), + .plainText("Multi-select menus"), + .plainText("Overflow menus"), + .plainText("Confirmation dialogs"), + .plainText("Emojis and Unicode") + ] + ), + SectionBlock( + fields: [ + .plainText("Builder API"), + .plainText("Result builders"), + .plainText("Input blocks"), + .plainText("Date pickers"), + .plainText("Conversation filters") + ] + ), + DividerBlock(), + ContextBlock(elements: [ + TextContextElement(text: "Powered by "), + TextContextElement(text: "Swift 6 "), + TextContextElement(text: "• "), + TextContextElement(text: "Built with ❤️") + ]) + ], + username: "SlackKit Test Runner", + iconEmoji: ":test_tube:" + ) + let response = try await client.send(message) + + #expect(response.ok == true) + } +} diff --git a/Tests/SlackKitTests/ModelTests/BlockTests.swift b/Tests/SlackKitTests/ModelTests/BlockTests.swift index e9e7fcf..1ad5170 100644 --- a/Tests/SlackKitTests/ModelTests/BlockTests.swift +++ b/Tests/SlackKitTests/ModelTests/BlockTests.swift @@ -31,12 +31,10 @@ struct BlockTests { @Test("Encode SectionBlock with fields") func encodeSectionBlockWithFields() throws { // Arrange - let block = SectionBlock( - fields: [ - .markdown("*Field 1*\nValue 1"), - .markdown("*Field 2*\nValue 2") - ] - ) + let block = SectionBlock { + Field.markdown("*Field 1*\nValue 1") + Field.markdown("*Field 2*\nValue 2") + } // Act let encoder = JSONEncoder() @@ -99,13 +97,13 @@ struct BlockTests { @Test("Encode ActionsBlock with button") func encodeActionsBlockWithButton() throws { // Arrange - let block = ActionsBlock(elements: [ + let block = Actions { ButtonElement( text: .plainText("Click me"), actionID: "button1", value: "button_value" ) - ]) + } // Act let encoder = JSONEncoder() @@ -121,10 +119,10 @@ struct BlockTests { @Test("Encode ContextBlock") func encodeContextBlock() throws { // Arrange - let block = ContextBlock(elements: [ - TextContextElement(text: "Context text"), + let block = Context { + TextContextElement(text: "Context text") ImageContextElement(imageURL: "https://example.com/icon.png", altText: "Icon") - ]) + } // Act let encoder = JSONEncoder() @@ -202,6 +200,18 @@ struct BlockTests { #expect(json.contains("\"value\":\"opt1\"")) } + @Test("Format DatePicker initial date") + func formatDatePickerInitialDate() throws { + // Arrange + let epoch = Date(timeIntervalSince1970: 0) + + // Act + let formatted = DatePickerElement.formatDate(epoch) + + // Assert + #expect(formatted == "1970-01-01") + } + @Test("Encode Attachment with fields") func encodeAttachmentWithFields() throws { // Arrange @@ -291,12 +301,11 @@ struct BlockTests { // Arrange let element = MultiStaticSelectElement( placeholder: .plainText("Select options"), - options: [ - Option(text: .plainText("Option 1"), value: "opt1"), - Option(text: .plainText("Option 2"), value: "opt2") - ], maxSelectedItems: 3 - ) + ) { + Option(text: .plainText("Option 1"), value: "opt1") + Option(text: .plainText("Option 2"), value: "opt2") + } // Act let encoder = JSONEncoder() diff --git a/Tests/SlackKitTests/ModelTests/MessageTests.swift b/Tests/SlackKitTests/ModelTests/MessageTests.swift index 897b29b..3204c14 100644 --- a/Tests/SlackKitTests/ModelTests/MessageTests.swift +++ b/Tests/SlackKitTests/ModelTests/MessageTests.swift @@ -214,4 +214,139 @@ struct MessageTests { #expect(json.contains("\"blocks\"")) #expect(json.contains("\"type\":\"section\"")) } + + // MARK: - Builder API Tests + + @Test("Build message with result builder - simple case") + func buildMessageWithResultBuilderSimple() throws { + // Arrange + let message = Message { + Section("Hello, World!") + Divider() + } + + // Act + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let data = try encoder.encode(message) + let json = String(data: data, encoding: .utf8)! + + // Assert + #expect(json.contains("\"type\":\"section\"")) + #expect(json.contains("\"type\":\"divider\"")) + #expect(json.contains("\"blocks\"")) + #expect(json.contains("Hello, World!")) + } + + @Test("Build message with result builder and text") + func buildMessageWithResultBuilderAndText() throws { + // Arrange + let message = Message(text: "Fallback") { + Header("Important Header") + Section(markdown: "This is *markdown* text") + Divider() + } + + // Act + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let data = try encoder.encode(message) + let json = String(data: data, encoding: .utf8)! + + // Assert + #expect(json.contains("\"text\":\"Fallback\"")) + #expect(json.contains("\"type\":\"header\"")) + #expect(json.contains("\"type\":\"section\"")) + #expect(json.contains("\"type\":\"divider\"")) + } + + @Test("Build message with conditional blocks") + func buildMessageWithConditionalBlocks() throws { + // Arrange + let showDivider = true + let message = Message { + Section("First section") + if showDivider { + Divider() + } + } + + // Act + let encoder = JSONEncoder() + let data = try encoder.encode(message) + let json = String(data: data, encoding: .utf8)! + + // Assert + #expect(json.contains("\"type\":\"section\"")) + #expect(json.contains("\"type\":\"divider\"")) + } + + @Test("Build message with all convenience functions") + func buildMessageWithAllConvenienceFunctions() throws { + // Arrange + let message = Message { + Header("Welcome!") + Section("This is a section") + Divider() + Image(url: "https://example.com/image.png", altText: "Example image") + Context("Some context text", "More context") + } + + // Act + let encoder = JSONEncoder() + let data = try encoder.encode(message) + let json = String(data: data, encoding: .utf8)! + + // Assert + #expect(json.contains("\"type\":\"header\"")) + #expect(json.contains("\"type\":\"section\"")) + #expect(json.contains("\"type\":\"divider\"")) + #expect(json.contains("\"type\":\"image\"")) + #expect(json.contains("\"type\":\"context\"")) + } + + @Test("Build empty message with result builder") + func buildEmptyMessageWithResultBuilder() throws { + // Arrange + let message = Message { + // Empty builder + } + + // Act + let encoder = JSONEncoder() + let data = try encoder.encode(message) + let json = String(data: data, encoding: .utf8)! + + // Assert + // Empty blocks array should be encoded as empty or null + #expect(!json.contains("\"type\"") || !json.contains("\"blocks\"")) + } + + @Test("Build message with result builder and all options") + func buildMessageWithResultBuilderAndAllOptions() throws { + // Arrange + let message = Message( + text: "Fallback", + username: "TestBot", + iconEmoji: ":robot_face:", + channel: "#test" + ) { + Header("Test Message") + Section("This is a test") + } + + // Act + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let data = try encoder.encode(message) + let json = String(data: data, encoding: .utf8)! + + // Assert + #expect(json.contains("\"text\":\"Fallback\"")) + #expect(json.contains("\"username\":\"TestBot\"")) + #expect(json.contains("\"icon_emoji\":\":robot_face:\"")) + #expect(json.contains("\"channel\":\"#test\"")) + #expect(json.contains("\"type\":\"header\"")) + #expect(json.contains("\"type\":\"section\"")) + } }