diff --git a/README.md b/README.md index 836e125..fbef6f9 100644 --- a/README.md +++ b/README.md @@ -5,15 +5,9 @@

- - CI - - - Version - - - License - + CI + Version + License Platform Swift Dependencies @@ -21,13 +15,16 @@ [Swift](https://swift.org) package for sending messages to [Mattermost](https://mattermost.com) via Incoming Webhooks with full support for Slack-compatible attachments and Mattermost-specific features. +> **Also check out [SlackKit](https://github.com/diegotl/SlackKit)** - A companion package for sending messages to Slack with full Block Kit support. + ## Features -- **Modern Swift API** - Built with Swift 6, async/await, and strict concurrency +- **Modern Result Builder API** - Declarative DSL for building messages with attachments - **Type-Safe** - Full Codable support with compile-time safety - **Attachments** - Slack-compatible attachment support for rich messages - **Mattermost-Specific** - Support for `props.card` and message priority -- **Flexible** - Send simple text messages or rich formatted messages +- **Conditional Logic** - Native if/else and for-in support in builders +- **Swift 6** - Built with Swift 6, async/await, and strict concurrency ## Requirements @@ -68,9 +65,9 @@ let client = try MattermostWebhookClient.create( try await client.send(Message(text: "Hello, Mattermost!")) ``` -## Usage +## Result Builder API -### Simple Text Message +### Simple Message ```swift let message = Message(text: "Deployment completed successfully!") @@ -81,61 +78,146 @@ try await client.send(message) ```swift let message = Message( - username: "DeployBot", - iconEmoji: ":rocket:", text: "Deployment complete!", - attachments: [ - Attachment( - color: "#36a64f", - title: "Build #123", - text: "Succeeded in 5m 32s", - fields: [ - AttachmentField(title: "Branch", value: "main", short: true), - AttachmentField(title: "Commit", value: "abc123", short: true), - AttachmentField(title: "Duration", value: "5m 32s", short: true), - AttachmentField(title: "Status", value: ":white_check_mark: Success", short: true) - ] - ) - ] -) + username: "DeployBot", + iconEmoji: ":rocket:" +) { + Attachment(color: "#36a64f", title: "Build #123", text: "Succeeded in 5m 32s") { + Field("Branch", value: "main") + Field("Commit", value: "abc123") + Field("Duration", value: "5m 32s") + Field("Status", value: ":white_check_mark: Success") + } +} try await client.send(message) ``` -**With custom username and icon:** +### Multiple Attachments ```swift let message = Message( + text: "Deployment Summary", username: "DeployBot", - iconEmoji: ":rocket:", - text: "Deployment complete!", - attachments: [ - Attachment( - color: "#36a64f", - title: "Build #123", - text: "Succeeded in 5m 32s" - ) - ] -) -try await client.send(message) + iconEmoji: ":rocket:" +) { + Attachment(color: "#36a64f", title: "Success") { + Field("Environment", value: "production") + Field("Duration", value: "5m 32s") + } + + Attachment(color: "#36a64f", title: "Build Info") { + Field("Branch", value: "main") + Field("Commit", value: "abc123") + Field("Tests", value: "156 passed") + } +} ``` -### Message with Card Props +### Conditional Attachments -Mattermost supports displaying custom content in the RHS sidebar via `props.card`: +```swift +let hasWarnings = true +let hasErrors = false + +let message = Message(username: "CIBot") { + Attachment(color: "#36a64f", title: "Build Summary") { + Field("Status", value: "Success") + } + + if hasWarnings { + Attachment(color: "#ffaa00", title: "Warnings") { + Field("Count", value: "3") + } + } + + if hasErrors { + Attachment(color: "#ff0000", title: "Errors") { + Field("Count", value: "1") + } + } +} +``` + +### Message with Actions + +```swift +let message = Message { + Attachment(text: "Deploy to production?") { + Field("Environment", value: "production") + Field("Version", value: "v2.4.1") + } + + Attachment.actions { + Button(text: "Approve", style: "primary", url: approveURL) + Button(text: "Reject", style: "danger", url: rejectURL) + Button(text: "Defer", style: "default", url: deferURL) + } +} +``` + +### Dynamic Fields with Loops ```swift +let testResults = [ + ("TestLogin", "passed"), + ("TestAPI", "passed"), + ("TestUI", "failed") +] + +let message = Message { + Attachment(title: "Test Results") { + for (name, result) in testResults { + Field(name, value: result, short: true) + } + } +} +``` + +### Message with Card Props + +```swift +// Simple card with static text let message = Message( text: "We won a new deal!", - props: Props(card: "Salesforce Opportunity Information:\\n\\n**Amount:** $300,020.00\\n**Close Date:** 2025-01-15\\n**Sales Rep:** John Doe") + props: Props(card: """ + Salesforce Opportunity Information: + + **Amount:** $300,020.00 + **Close Date:** 2025-01-15 + **Sales Rep:** John Doe + """) +) + +// Card with dynamic properties using builder +let message = Message( + text: "Deal updated!", + props: Props(card: "Deal Information") { + Property("amount", value: 300020) + Property("stage", value: "Proposal") + Property("is_closed", value: false) + } +) + +// Conditional properties +let includeDetails = true +let message = Message( + text: "Opportunity created", + props: Props(card: "Sales Info") { + Property("opportunity_id", value: "12345") + Property("account", value: "Acme Corp") + + if includeDetails { + Property("estimated_value", value: 50000) + Property("probability", value: 0.75) + } + } ) -try await client.send(message) ``` ### Message with Priority -Mattermost supports urgent and important message priorities: - ```swift +// Urgent priority with acknowledgment let message = Message( text: "Critical incident!", priority: Priority( @@ -144,86 +226,194 @@ let message = Message( persistentNotifications: true ) ) -try await client.send(message) -``` -**Important priority:** - -```swift +// Important priority let message = Message( text: "Important announcement", priority: Priority(priority: .important) ) -try await client.send(message) ``` -### Message with Actions - -Interactive buttons for user actions: +### Confirmation Dialogs for Actions ```swift -let message = Message( - text: "Approval required for production deployment", - attachments: [ - Attachment( - text: "Deploy to production?", - actions: [ - Action( - name: "approve", - text: "Approve", - style: "primary", - url: "https://example.com/approve" - ), - Action( - name: "reject", - text: "Reject", - style: "danger", - url: "https://example.com/reject" - ) - ] - ) - ] +// Simple confirmation +let confirm = Confirmation( + title: "Are you sure?", + text: "This will deploy to production", + confirmText: "Deploy", + denyText: "Cancel" ) -try await client.send(message) -``` -### Message with Rich Attachments +// Using result builder for custom buttons +let confirm = Confirmation( + title: "Confirm Deployment", + text: "This action cannot be undone" +) { + ConfirmButton(text: "Yes, Deploy", style: "primary") + DenyButton(text: "No, Cancel") +} + +let message = Message { + Attachment.actions { + Button(text: "Deploy", style: "primary", url: deployURL, confirm: confirm) + Button(text: "Cancel", style: "danger", url: cancelURL) + } +} +``` -Complete attachment with all available fields: +### Complex Message with All Features ```swift let message = Message( username: "CI/CD Bot", iconURL: "https://example.com/ci-icon.png", - text: "Build notification", - attachments: [ - Attachment( - fallback: "Build #123 succeeded", - color: "#36a64f", - pretext: "Build process completed", - authorName: "Jenkins", - authorLink: "https://jenkins.example.com", - authorIcon: "https://example.com/jenkins-icon.png", - title: "Build #123", - titleLink: "https://jenkins.example.com/job/123", - text: "All tests passed successfully", - fields: [ - AttachmentField(title: "Branch", value: "feature/new-api", short: true), - AttachmentField(title: "Commit", value: "a1b2c3d", short: true), - AttachmentField(title: "Duration", value: "5m 32s", short: true), - AttachmentField(title: "Tests", value: "156 passed", short: true) - ], - imageURL: "https://example.com/build-graph.png", - thumbURL: "https://example.com/thumb.png", - footer: "Jenkins CI", - footerIcon: "https://example.com/jenkins.png" - ) - ] + text: "Build notification" +) { + Attachment( + color: "#36a64f", + title: "Build #123", + pretext: "Build process completed", + authorName: "Jenkins", + authorLink: "https://jenkins.example.com", + authorIcon: "https://example.com/jenkins-icon.png", + titleLink: "https://jenkins.example.com/job/123", + imageURL: "https://example.com/build-graph.png", + thumbURL: "https://example.com/thumb.png", + footer: "Jenkins CI", + footerIcon: "https://example.com/jenkins.png" + ) { + Field("Branch", value: "feature/new-api", short: true) + Field("Commit", value: "a1b2c3d", short: true) + Field("Duration", value: "5m 32s", short: true) + Field("Tests", value: "156 passed", short: true) + } +} +``` + +### Confirmation Dialogs for Actions + +```swift +let confirm = Confirmation( + title: "Are you sure?", + text: "This will deploy to production", + confirmText: "Deploy", + denyText: "Cancel" ) -try await client.send(message) + +let message = Message { + Attachment.actions { + Button(text: "Deploy", style: "primary", url: deployURL, confirm: confirm) + Button(text: "Cancel", style: "danger", url: cancelURL) + } +} +``` + +## Builder API Reference + +### Message Builder + +```swift +Message( + text: "Message text", + username: "Bot Name", + iconEmoji: ":robot_face:" +) { + // Attachments via @AttachmentBuilder + Attachment(title: "Title") { ... } +} +``` + +### Attachment Builder + +```swift +Attachment( + color: "#36a64f", + title: "Title", + text: "Description" +) { + // Fields via @FieldBuilder + Field("Key", value: "Value") +} +``` + +### Actions Builder + +```swift +Attachment.actions { + // Actions via @ActionBuilder + Button(text: "Click", url: url) +} +``` + +### Props Builder + +```swift +Props(card: "Card content") { + // Properties via @PropsBuilder + Property("key", value: "value") + Property("number", value: 42) +} +``` + +### Confirmation Builder + +```swift +Confirmation( + title: "Confirm?", + text: "Are you sure?" +) { + // Components via @ConfirmationBuilder + ConfirmButton(text: "Yes", style: "primary") + DenyButton(text: "No") +} +``` + +### Convenience Functions + +```swift +// Field with short=true by default +Field("Branch", value: "main") + +// Button action +Button(text: "Approve", style: "primary", url: "https://example.com") + +// Properties (various types) +Property("name", value: "text") +Property("count", value: 42) +Property("enabled", value: true) +Property("metadata", value: ["key": .string("value")]) + +// Confirmation components +ConfirmButton(text: "Yes", style: "primary") +DenyButton(text: "No") + +// Actions-only attachment +Attachment.actions { + Button(text: "View", url: viewURL) + Button(text: "Dismiss", style: "danger", url: dismissURL) +} ``` -## Message Properties +## Error Handling + +```swift +do { + try await client.send(message) +} catch MattermostError.invalidURL(let url) { + print("Invalid URL: \(url)") +} catch MattermostError.invalidResponse(let code, let body) { + print("HTTP \(code): \(body ?? "No body")") +} catch MattermostError.encodingError(let error) { + print("Failed to encode message: \(error)") +} catch MattermostError.networkError(let error) { + print("Network error: \(error)") +} +``` + +## API Reference + +### Message ```swift public struct Message: Sendable, Codable { @@ -239,7 +429,7 @@ public struct Message: Sendable, Codable { } ``` -## Attachment Properties +### Attachment ```swift public struct Attachment: Sendable, Codable { @@ -261,22 +451,16 @@ public struct Attachment: Sendable, Codable { } ``` -## Props (Mattermost-Specific) - -Mattermost supports custom metadata via the `props` field: +### Props (Mattermost-Specific) ```swift public struct Props: Sendable, Codable { public var card: String? // RHS sidebar content - public var additionalProperties: [String: AnyCodable]? // Additional dynamic properties + public var additionalProperties: [String: AnyCodable]? } ``` -The `card` property displays formatted content in the Mattermost RHS (Right Hand Side) sidebar when clicking on the message. - -## Priority (Mattermost-Specific) - -Message priority for urgent and important notifications: +### Priority (Mattermost-Specific) ```swift public struct Priority: Sendable, Codable { @@ -291,22 +475,6 @@ public struct Priority: Sendable, Codable { } ``` -## Error Handling - -```swift -do { - try await client.send(message) -} catch MattermostError.invalidURL(let url) { - print("Invalid URL: \(url)") -} catch MattermostError.invalidResponse(let code, let body) { - print("HTTP \(code): \(body ?? "No body")") -} catch MattermostError.encodingError(let error) { - print("Failed to encode message: \(error)") -} catch MattermostError.networkError(let error) { - print("Network error: \(error)") -} -``` - ## Differences from Slack | Feature | SlackKit | MattermostKit | diff --git a/Sources/MattermostKit/Models/Attachment+Builder.swift b/Sources/MattermostKit/Models/Attachment+Builder.swift new file mode 100644 index 0000000..82a82b7 --- /dev/null +++ b/Sources/MattermostKit/Models/Attachment+Builder.swift @@ -0,0 +1,151 @@ +import Foundation + +// MARK: - FieldBuilder + +/// A result builder for constructing arrays of `AttachmentField` objects +@resultBuilder +public enum FieldBuilder { + /// Builds an empty field array + public static func buildBlock() -> [AttachmentField] { + [] + } + + /// Builds a field array from multiple field arrays + public static func buildBlock(_ components: [AttachmentField]...) -> [AttachmentField] { + components.flatMap { $0 } + } + + /// Builds a field array from a single field + public static func buildExpression(_ expression: AttachmentField) -> [AttachmentField] { + [expression] + } + + /// Builds a field array from an optional field + public static func buildExpression(_ expression: AttachmentField?) -> [AttachmentField] { + expression.map { [$0] } ?? [] + } + + /// Builds a field array from an if statement + public static func buildIf(_ content: [AttachmentField]?) -> [AttachmentField] { + content ?? [] + } + + /// Builds a field array from the first branch of an if-else statement + public static func buildEither(first component: [AttachmentField]) -> [AttachmentField] { + component + } + + /// Builds a field array from the second branch of an if-else statement + public static func buildEither(second component: [AttachmentField]) -> [AttachmentField] { + component + } + + /// Builds a field array from a for loop + public static func buildArray(_ components: [[AttachmentField]]) -> [AttachmentField] { + components.flatMap { $0 } + } + + /// Builds the final field array + public static func buildFinalBlock(_ component: [AttachmentField]) -> [AttachmentField] { + component + } +} + +// MARK: - ActionBuilder + +/// A result builder for constructing arrays of `Action` objects +@resultBuilder +public enum ActionBuilder { + /// Builds an empty action array + public static func buildBlock() -> [Action] { + [] + } + + /// Builds an action array from multiple action arrays + public static func buildBlock(_ components: [Action]...) -> [Action] { + components.flatMap { $0 } + } + + /// Builds an action array from a single action + public static func buildExpression(_ expression: Action) -> [Action] { + [expression] + } + + /// Builds an action array from an optional action + public static func buildExpression(_ expression: Action?) -> [Action] { + expression.map { [$0] } ?? [] + } + + /// Builds an action array from an if statement + public static func buildIf(_ content: [Action]?) -> [Action] { + content ?? [] + } + + /// Builds an action array from the first branch of an if-else statement + public static func buildEither(first component: [Action]) -> [Action] { + component + } + + /// Builds an action array from the second branch of an if-else statement + public static func buildEither(second component: [Action]) -> [Action] { + component + } + + /// Builds an action array from a for loop + public static func buildArray(_ components: [[Action]]) -> [Action] { + components.flatMap { $0 } + } + + /// Builds the final action array + public static func buildFinalBlock(_ component: [Action]) -> [Action] { + component + } +} + +// MARK: - Attachment Field Convenience + +extension AttachmentField { + /// Convenience initializer with short=true by default + public init(_ title: String, value: String, short: Bool = true) { + self.title = title + self.value = value + self.short = short + } +} + +// MARK: - Field Convenience Function + +/// Creates an attachment field with the specified parameters +/// - Parameters: +/// - title: The field title +/// - value: The field value (Markdown-formatted) +/// - short: Whether the field is short enough to display beside other fields (default: true) +/// - Returns: An attachment field +public func Field(_ title: String, value: String, short: Bool = true) -> AttachmentField { + AttachmentField(title: title, value: value, short: short) +} + +// MARK: - Action Convenience Function + +/// Creates a button action with a convenience API +/// - Parameters: +/// - text: The button text +/// - style: The button style ("primary", "danger", or "default") +/// - url: The URL to open when clicked +/// - confirm: Optional confirmation dialog +/// - Returns: A configured Action +public func Button( + text: String, + style: String? = nil, + url: String, + confirm: Confirmation? = nil +) -> Action { + Action( + name: text.lowercased().replacingOccurrences(of: " ", with: "_"), + text: text, + type: "button", + style: style, + url: url, + confirm: confirm + ) +} diff --git a/Sources/MattermostKit/Models/Confirmation+Builder.swift b/Sources/MattermostKit/Models/Confirmation+Builder.swift new file mode 100644 index 0000000..d7cba14 --- /dev/null +++ b/Sources/MattermostKit/Models/Confirmation+Builder.swift @@ -0,0 +1,106 @@ +import Foundation + +// MARK: - Confirmation Builder + +/// A result builder for constructing confirmation dialog components +@resultBuilder +public enum ConfirmationBuilder { + /// Builds an empty component array + public static func buildBlock() -> [ConfirmationComponent] { + [] + } + + /// Builds a component array from multiple components + public static func buildBlock(_ components: [ConfirmationComponent]...) -> [ConfirmationComponent] { + components.flatMap { $0 } + } + + /// Builds a component array from a single component + public static func buildExpression(_ expression: ConfirmationComponent) -> [ConfirmationComponent] { + [expression] + } + + /// Builds a component array from an optional component + public static func buildExpression(_ expression: ConfirmationComponent?) -> [ConfirmationComponent] { + expression.map { [$0] } ?? [] + } + + /// Builds a component array from an if statement + public static func buildIf(_ content: [ConfirmationComponent]?) -> [ConfirmationComponent] { + content ?? [] + } + + /// Builds a component array from the first branch of an if-else statement + public static func buildEither(first component: [ConfirmationComponent]) -> [ConfirmationComponent] { + component + } + + /// Builds a component array from the second branch of an if-else statement + public static func buildEither(second component: [ConfirmationComponent]) -> [ConfirmationComponent] { + component + } + + /// Builds a component array from a for loop + public static func buildArray(_ components: [[ConfirmationComponent]]) -> [ConfirmationComponent] { + components.flatMap { $0 } + } +} + +// MARK: - Confirmation Components + +/// Confirmation dialog components +public enum ConfirmationComponent { + case confirmButton(text: String, style: String? = nil) + case denyButton(text: String) +} + +// MARK: - Confirmation Convenience Initializer + +extension Confirmation { + /// Initializes a confirmation with a result builder for button configuration + /// - Parameters: + /// - title: Confirmation dialog title + /// - text: Confirmation dialog text + /// - builder: Result builder for confirmation components + public init( + title: String? = nil, + text: String, + @ConfirmationBuilder builder: () -> [ConfirmationComponent] + ) { + self.title = title + self.text = text + + var confirmText: String? + var denyText: String? + + for component in builder() { + switch component { + case .confirmButton(let text, _): + confirmText = text + case .denyButton(let text): + denyText = text + } + } + + self.confirmText = confirmText + self.denyText = denyText + } +} + +// MARK: - Confirmation Convenience Functions + +/// Creates a confirm button component +/// - Parameters: +/// - text: Button text +/// - style: Optional style hint +/// - Returns: A confirm button component +public func ConfirmButton(text: String, style: String? = nil) -> ConfirmationComponent { + .confirmButton(text: text, style: style) +} + +/// Creates a deny button component +/// - Parameter text: Button text +/// - Returns: A deny button component +public func DenyButton(text: String) -> ConfirmationComponent { + .denyButton(text: text) +} diff --git a/Sources/MattermostKit/Models/Message+Builder.swift b/Sources/MattermostKit/Models/Message+Builder.swift new file mode 100644 index 0000000..7a6db7e --- /dev/null +++ b/Sources/MattermostKit/Models/Message+Builder.swift @@ -0,0 +1,185 @@ +import Foundation + +// MARK: - AttachmentBuilder + +/// A result builder for constructing arrays of `Attachment` objects +@resultBuilder +public enum AttachmentBuilder { + /// Builds an empty attachment array + public static func buildBlock() -> [Attachment] { + [] + } + + /// Builds an attachment array from multiple attachment arrays + public static func buildBlock(_ components: [Attachment]...) -> [Attachment] { + components.flatMap { $0 } + } + + /// Builds an attachment array from a single attachment + public static func buildExpression(_ expression: Attachment) -> [Attachment] { + [expression] + } + + /// Builds an attachment array from an optional attachment + public static func buildExpression(_ expression: Attachment?) -> [Attachment] { + expression.map { [$0] } ?? [] + } + + /// Builds an attachment array from an array of attachments (pass-through) + public static func buildExpression(_ expression: [Attachment]) -> [Attachment] { + expression + } + + /// Builds an attachment array from an if statement + public static func buildIf(_ content: [Attachment]?) -> [Attachment] { + content ?? [] + } + + /// Builds an attachment array from the first branch of an if-else statement + public static func buildEither(first component: [Attachment]) -> [Attachment] { + component + } + + /// Builds an attachment array from the second branch of an if-else statement + public static func buildEither(second component: [Attachment]) -> [Attachment] { + component + } + + /// Builds an attachment array from a for loop + public static func buildArray(_ components: [[Attachment]]) -> [Attachment] { + components.flatMap { $0 } + } + + /// Builds the final attachment array + public static func buildFinalBlock(_ component: [Attachment]) -> [Attachment] { + component + } +} + +// MARK: - Message Convenience Initializer + +extension Message { + /// Initializes a message with a result builder for attachments + /// - Parameters: + /// - text: Markdown-formatted message text + /// - channel: Override the default channel + /// - username: Override the default username + /// - iconEmoji: Override with emoji (e.g., ":rocket:") + /// - iconURL: Override with image URL + /// - props: JSON metadata + /// - type: Post type (must begin with "custom_") + /// - priority: Message priority + /// - attachments: Result builder for attachments + public init( + text: String? = nil, + channel: String? = nil, + username: String? = nil, + iconEmoji: String? = nil, + iconURL: String? = nil, + props: Props? = nil, + type: String? = nil, + priority: Priority? = nil, + @AttachmentBuilder attachments: () -> [Attachment] + ) { + let builtAttachments = attachments() + self.text = text + self.channel = channel + self.username = username + self.iconEmoji = iconEmoji + self.iconURL = iconURL + self.attachments = builtAttachments.isEmpty ? nil : builtAttachments + self.props = props + self.type = type + self.priority = priority + } +} + +// MARK: - Attachment Convenience Initializer + +extension Attachment { + /// Initializes an attachment with a result builder for fields + /// - Parameters: + /// - color: Hex color code for left border + /// - title: Attachment title + /// - titleLink: Optional title link URL + /// - text: Attachment text (Markdown-formatted) + /// - fallback: Plain-text summary + /// - pretext: Text shown above the attachment + /// - authorName: Author name + /// - authorLink: Author link URL + /// - authorIcon: Author icon URL + /// - imageURL: Image URL + /// - thumbURL: Thumbnail URL + /// - footer: Footer text + /// - footerIcon: Footer icon URL + /// - builder: A result builder closure that provides the fields + public init( + color: String? = nil, + title: String? = nil, + titleLink: String? = nil, + text: String? = nil, + fallback: String? = nil, + pretext: String? = nil, + authorName: String? = nil, + authorLink: String? = nil, + authorIcon: String? = nil, + imageURL: String? = nil, + thumbURL: String? = nil, + footer: String? = nil, + footerIcon: String? = nil, + @FieldBuilder builder: () -> [AttachmentField] + ) { + let builtFields = builder() + self.fallback = fallback + self.color = color + self.pretext = pretext + self.authorName = authorName + self.authorLink = authorLink + self.authorIcon = authorIcon + self.title = title + self.titleLink = titleLink + self.text = text + self.fields = builtFields.isEmpty ? nil : builtFields + self.imageURL = imageURL + self.thumbURL = thumbURL + self.footer = footer + self.footerIcon = footerIcon + self.footerTimestamp = nil + self.actions = nil + } +} + +// MARK: - Actions Convenience + +extension Attachment { + /// Creates an attachment with actions built via result builder + public static func actions(@ActionBuilder builder: () -> [Action]) -> Attachment { + let actions = builder() + return Attachment(actions: actions.isEmpty ? nil : actions) + } + + /// Creates an attachment with both fields and actions + public init( + @FieldBuilder fieldsBuilder: () -> [AttachmentField], + @ActionBuilder actionsBuilder: () -> [Action] + ) { + let fields = fieldsBuilder() + let actions = actionsBuilder() + self.fields = fields.isEmpty ? nil : fields + self.actions = actions.isEmpty ? nil : actions + self.fallback = nil + self.color = nil + self.pretext = nil + self.authorName = nil + self.authorLink = nil + self.authorIcon = nil + self.title = nil + self.titleLink = nil + self.text = nil + self.imageURL = nil + self.thumbURL = nil + self.footer = nil + self.footerIcon = nil + self.footerTimestamp = nil + } +} diff --git a/Sources/MattermostKit/Models/Props+Builder.swift b/Sources/MattermostKit/Models/Props+Builder.swift new file mode 100644 index 0000000..0c4bb3d --- /dev/null +++ b/Sources/MattermostKit/Models/Props+Builder.swift @@ -0,0 +1,148 @@ +import Foundation + +// MARK: - PropsBuilder + +/// A result builder for constructing dynamic property dictionaries +@resultBuilder +public enum PropsBuilder { + /// Builds an empty property dictionary + public static func buildBlock() -> [String: AnyCodable] { + [:] + } + + /// Builds a property dictionary from multiple dictionaries + public static func buildBlock(_ components: [String: AnyCodable]...) -> [String: AnyCodable] { + components.merging() + } + + /// Builds a property dictionary from a single dictionary + public static func buildExpression(_ expression: [String: AnyCodable]) -> [String: AnyCodable] { + expression + } + + /// Builds a property dictionary from an optional dictionary + public static func buildExpression(_ expression: [String: AnyCodable]?) -> [String: AnyCodable] { + expression ?? [:] + } + + /// Builds a property dictionary from an if statement + public static func buildIf(_ content: [String: AnyCodable]?) -> [String: AnyCodable] { + content ?? [:] + } + + /// Builds a property dictionary from the first branch of an if-else statement + public static func buildEither(first component: [String: AnyCodable]) -> [String: AnyCodable] { + component + } + + /// Builds a property dictionary from the second branch of an if-else statement + public static func buildEither(second component: [String: AnyCodable]) -> [String: AnyCodable] { + component + } + + /// Builds a property dictionary from a for loop + public static func buildArray(_ components: [[String: AnyCodable]]) -> [String: AnyCodable] { + components.merging() + } + + /// Builds the final property dictionary + public static func buildFinalBlock(_ component: [String: AnyCodable]) -> [String: AnyCodable] { + component + } +} + +// MARK: - Props Convenience Initializer + +extension Props { + /// Initializes props with a result builder for additional properties + /// - Parameters: + /// - card: Card content displayed in RHS sidebar (Markdown-formatted) + /// - properties: Result builder for additional properties + public init( + card: String? = nil, + @PropsBuilder properties: () -> [String: AnyCodable] + ) { + self.card = card + let builtProperties = properties() + self.additionalProperties = builtProperties.isEmpty ? nil : builtProperties + } +} + +// MARK: - Custom Property Convenience Functions + +/// Creates a string property +/// - Parameters: +/// - key: The property key +/// - value: The string value +/// - Returns: A dictionary with the single property +public func Property(_ key: String, value: String) -> [String: AnyCodable] { + [key: .string(value)] +} + +/// Creates an integer property +/// - Parameters: +/// - key: The property key +/// - value: The integer value +/// - Returns: A dictionary with the single property +public func Property(_ key: String, value: Int) -> [String: AnyCodable] { + [key: .int(value)] +} + +/// Creates a double property +/// - Parameters: +/// - key: The property key +/// - value: The double value +/// - Returns: A dictionary with the single property +public func Property(_ key: String, value: Double) -> [String: AnyCodable] { + [key: .double(value)] +} + +/// Creates a boolean property +/// - Parameters: +/// - key: The property key +/// - value: The boolean value +/// - Returns: A dictionary with the single property +public func Property(_ key: String, value: Bool) -> [String: AnyCodable] { + [key: .bool(value)] +} + +/// Creates a dictionary property +/// - Parameters: +/// - key: The property key +/// - value: The dictionary value +/// - Returns: A dictionary with the single property +public func Property(_ key: String, value: [String: AnyCodable]) -> [String: AnyCodable] { + [key: .dictionary(value)] +} + +/// Creates an array property +/// - Parameters: +/// - key: The property key +/// - value: The array value +/// - Returns: A dictionary with the single property +public func Property(_ key: String, value: [AnyCodable]) -> [String: AnyCodable] { + [key: .array(value)] +} + +// MARK: - Dictionary Merging Helper + +fileprivate func mergeDictionaries(_ dictionaries: [[String: AnyCodable]]) -> [String: AnyCodable] { + var result: [String: AnyCodable] = [:] + for dict in dictionaries { + result.merge(dict) { _, new in new } + } + return result +} + +// MARK: - Array Merging Helper + +extension Array where Element == [String: AnyCodable] { + /// Merges multiple dictionaries into one + fileprivate func merging() -> [String: AnyCodable] { + var result: [String: AnyCodable] = [:] + for dict in self { + result.merge(dict) { _, new in new } + } + return result + } +} diff --git a/Tests/MattermostKitTests/Builders/AttachmentBuilderTests.swift b/Tests/MattermostKitTests/Builders/AttachmentBuilderTests.swift new file mode 100644 index 0000000..be76be5 --- /dev/null +++ b/Tests/MattermostKitTests/Builders/AttachmentBuilderTests.swift @@ -0,0 +1,266 @@ +import XCTest +@testable import MattermostKit + +/// Tests for the result builder functionality +final class AttachmentBuilderTests: XCTestCase { + // MARK: - Attachment Builder Tests + + func testEmptyAttachmentBuilder() { + let message = Message {} + XCTAssertNil(message.attachments) + } + + func testSingleAttachmentBuilder() { + let message = Message { + Attachment(title: "Test") + } + XCTAssertEqual(message.attachments?.count, 1) + XCTAssertEqual(message.attachments?.first?.title, "Test") + } + + func testMultipleAttachmentBuilder() { + let message = Message { + Attachment(title: "First") + Attachment(title: "Second") + Attachment(title: "Third") + } + XCTAssertEqual(message.attachments?.count, 3) + XCTAssertEqual(message.attachments?[0].title, "First") + XCTAssertEqual(message.attachments?[1].title, "Second") + XCTAssertEqual(message.attachments?[2].title, "Third") + } + + func testConditionalAttachmentBuilder() { + let include = true + let message = Message { + Attachment(title: "Always") + if include { + Attachment(title: "Sometimes") + } + } + XCTAssertEqual(message.attachments?.count, 2) + } + + func testConditionalAttachmentBuilderFalse() { + let include = false + let message = Message { + Attachment(title: "Always") + if include { + Attachment(title: "Sometimes") + } + } + XCTAssertEqual(message.attachments?.count, 1) + XCTAssertEqual(message.attachments?.first?.title, "Always") + } + + func testIfElseAttachmentBuilder() { + let isSuccess = true + let message = Message { + if isSuccess { + Attachment(title: "Success") + } else { + Attachment(title: "Failure") + } + } + XCTAssertEqual(message.attachments?.count, 1) + XCTAssertEqual(message.attachments?.first?.title, "Success") + } + + // MARK: - Field Builder Tests + + func testEmptyFieldBuilder() { + let message = Message { + Attachment(title: "Test") {} + } + XCTAssertNil(message.attachments?.first?.fields) + } + + func testFieldBuilder() { + let message = Message { + Attachment(title: "Test") { + Field("Branch", value: "main") + Field("Commit", value: "abc123") + } + } + XCTAssertEqual(message.attachments?.first?.fields?.count, 2) + XCTAssertEqual(message.attachments?.first?.fields?[0].title, "Branch") + XCTAssertEqual(message.attachments?.first?.fields?[0].value, "main") + } + + func testFieldBuilderWithShort() { + let message = Message { + Attachment(title: "Test") { + Field("Branch", value: "main", short: true) + Field("Description", value: "A long description", short: false) + } + } + XCTAssertEqual(message.attachments?.first?.fields?.count, 2) + XCTAssertEqual(message.attachments?.first?.fields?[0].short, true) + XCTAssertEqual(message.attachments?.first?.fields?[1].short, false) + } + + func testConditionalFieldBuilder() { + let showDetails = true + let message = Message { + Attachment(title: "Test") { + Field("Title", value: "Value") + if showDetails { + Field("Details", value: "More info") + } + } + } + XCTAssertEqual(message.attachments?.first?.fields?.count, 2) + } + + // MARK: - Action Builder Tests + + func testEmptyActionBuilder() { + let message = Message { + Attachment.actions {} + } + XCTAssertNil(message.attachments?.first?.actions) + } + + func testActionBuilder() { + let message = Message { + Attachment.actions { + Button(text: "Approve", style: "primary", url: "https://example.com/approve") + Button(text: "Reject", style: "danger", url: "https://example.com/reject") + } + } + XCTAssertEqual(message.attachments?.count, 1) + XCTAssertEqual(message.attachments?.first?.actions?.count, 2) + } + + // MARK: - Combined Tests + + func testMessageWithAllProperties() { + let message = Message( + text: "Deployment complete!", + username: "DeployBot", + iconEmoji: ":rocket:" + ) { + Attachment(color: "#36a64f", title: "Build #123") { + Field("Branch", value: "main") + Field("Commit", value: "abc123") + } + } + + XCTAssertEqual(message.username, "DeployBot") + XCTAssertEqual(message.iconEmoji, ":rocket:") + XCTAssertEqual(message.text, "Deployment complete!") + XCTAssertEqual(message.attachments?.count, 1) + XCTAssertEqual(message.attachments?.first?.color, "#36a64f") + XCTAssertEqual(message.attachments?.first?.title, "Build #123") + XCTAssertEqual(message.attachments?.first?.fields?.count, 2) + } + + func testComplexMessage() { + let hasWarnings = true + let hasErrors = false + + let message = Message(username: "CIBot", iconEmoji: ":robot_face:") { + Attachment(color: "#36a64f", title: "Build Summary") { + Field("Status", value: "Success") + Field("Duration", value: "5m 32s") + } + + if hasWarnings { + Attachment(color: "#ffaa00", title: "Warnings") { + Field("Count", value: "3") + } + } + + if hasErrors { + Attachment(color: "#ff0000", title: "Errors") { + Field("Count", value: "1") + } + } + } + + XCTAssertEqual(message.attachments?.count, 2) + XCTAssertEqual(message.attachments?[0].title, "Build Summary") + XCTAssertEqual(message.attachments?[1].title, "Warnings") + } + + // MARK: - Codable Tests + + func testResultBuilderEncoding() throws { + let message = Message(username: "TestBot") { + Attachment(color: "#36a64f", title: "Test") { + Field("Key", value: "Value") + } + } + + let encoder = JSONEncoder() + let jsonData = try encoder.encode(message) + let jsonString = String(data: jsonData, encoding: .utf8) + + XCTAssertNotNil(jsonString) + XCTAssertTrue(jsonString!.contains("TestBot")) + XCTAssertTrue(jsonString!.contains("Test")) + XCTAssertTrue(jsonString!.contains("Key")) + XCTAssertTrue(jsonString!.contains("Value")) + } + + func testResultBuilderDecoding() throws { + let jsonString = """ + { + "username": "TestBot", + "attachments": [ + { + "title": "Test", + "fields": [ + {"title": "Key", "value": "Value"} + ] + } + ] + } + """ + + let decoder = JSONDecoder() + let message = try decoder.decode(Message.self, from: jsonString.data(using: .utf8)!) + + XCTAssertEqual(message.username, "TestBot") + XCTAssertEqual(message.attachments?.count, 1) + XCTAssertEqual(message.attachments?.first?.title, "Test") + XCTAssertEqual(message.attachments?.first?.fields?.count, 1) + } + + // MARK: - AttachmentField Convenience Initializer Tests + + func testFieldConvenienceInitializer() { + let field1 = Field("Title", value: "Value") + XCTAssertEqual(field1.title, "Title") + XCTAssertEqual(field1.value, "Value") + XCTAssertEqual(field1.short, true) // Default is true + + let field2 = Field("Title", value: "Value", short: false) + XCTAssertEqual(field2.short, false) + } + + func testFieldStaticFactory() { + let field = Field("Title", value: "Value", short: true) + XCTAssertEqual(field.title, "Title") + XCTAssertEqual(field.value, "Value") + XCTAssertEqual(field.short, true) + } + + // MARK: - Action Convenience Tests + + func testButtonConvenience() { + let button = Button(text: "Approve", style: "primary", url: "https://example.com/approve") + XCTAssertEqual(button.text, "Approve") + XCTAssertEqual(button.style, "primary") + XCTAssertEqual(button.url, "https://example.com/approve") + XCTAssertEqual(button.type, "button") + XCTAssertEqual(button.name, "approve") // Lowercased, spaces replaced with underscores + } + + func testButtonConvenienceWithoutStyle() { + let button = Button(text: "Click Me", url: "https://example.com") + XCTAssertEqual(button.text, "Click Me") + XCTAssertNil(button.style) + XCTAssertEqual(button.name, "click_me") + } +} diff --git a/Tests/MattermostKitTests/IntegrationTests/MattermostKitIntegrationTests.swift b/Tests/MattermostKitTests/IntegrationTests/MattermostKitIntegrationTests.swift new file mode 100644 index 0000000..fdab684 --- /dev/null +++ b/Tests/MattermostKitTests/IntegrationTests/MattermostKitIntegrationTests.swift @@ -0,0 +1,879 @@ +import Foundation +import Testing +@testable import MattermostKit + +// MARK: - MattermostKit Integration Tests + +/* + # MattermostKit Integration Tests + + These are end-to-end tests that send actual messages to Mattermost via Incoming Webhooks. + + ## Prerequisites + + 1. **Create a Mattermost Webhook URL**: + - Go to your Mattermost workspace + - Navigate to: Integrations → Incoming Webhooks → Add Incoming Webhook + - Enter a title (e.g., "Integration Tests") + - Select a channel (a dedicated test channel is recommended) + - Copy the Webhook URL + + 2. **Set Environment Variables**: + + ```bash + export MATTERMOST_INTEGRATION_TESTS=1 + export MATTERMOST_TEST_WEBHOOK_URL="https://your-mattermost-server.com/hooks/YOUR_WEBHOOK_ID" + ``` + + Or run tests inline: + + ```bash + MATTERMOST_INTEGRATION_TESTS=1 MATTERMOST_TEST_WEBHOOK_URL="https://your-mattermost-server.com/hooks/YOUR_WEBHOOK_ID" 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 + MATTERMOST_INTEGRATION_TESTS=1 MATTERMOST_TEST_WEBHOOK_URL="your_url" swift test --filter "MattermostKit Integration Tests" + ``` + + ## What Gets Tested + + - Simple text messages with Markdown formatting + - Messages with custom username and icons + - Single and multiple attachments + - All attachment features (color, title, text, fields, images, footer) + - Action buttons with different styles + - Confirmation dialogs + - Builder API with conditionals and loops + - Message priority (important/urgent) + - Props and card content + - Special characters and emojis + + ## 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 Mattermost workspace + - A dedicated test channel is recommended + + ## 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( + "MattermostKit Integration Tests", + .serialized, + .enabled(if: { + // Only run integration tests when MATTERMOST_INTEGRATION_TESTS environment variable is set + ProcessInfo.processInfo.environment["MATTERMOST_INTEGRATION_TESTS"] != nil + }()) +) +struct MattermostKitIntegrationTests { + + // MARK: - Test Configuration + + private var webhookURL: URL { + guard let urlString = ProcessInfo.processInfo.environment["MATTERMOST_TEST_WEBHOOK_URL"], + let url = URL(string: urlString) else { + fatalError("MATTERMOST_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() -> MattermostWebhookClient { + MattermostWebhookClient(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") + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send message with markdown formatting") + func sendMessageWithMarkdown() async throws { + let client = createClient() + let message = Message(text: """ + 🧪 Integration Test: **Markdown formatting** + + This is a *bold text* and this is _italic text_. + This is `code` and this is a ~~strikethrough~~ text. + + * Bullet point 1 + * Bullet point 2 + * Bullet point 3 + + 1. Numbered item 1 + 2. Numbered item 2 + 3. Numbered item 3 + + A [link](https://mattermost.com) to Mattermost. + """) + try await client.send(message) + + 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: "MattermostKit Test Bot", + iconEmoji: ":robot_face:" + ) + try await client.send(message) + + 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: "MattermostKit", + iconURL: "https://httpbin.org/image/png" + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + // MARK: - Attachments + + @Test("Send message with single attachment") + func sendMessageWithSingleAttachment() async throws { + let client = createClient() + let message = Message( + text: "🧪 Integration Test: Single attachment", + attachments: [ + Attachment( + color: "good", + title: "Success", + text: "Operation completed successfully" + ) + ] + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send message with attachment with fields") + func sendMessageWithAttachmentFields() async throws { + let client = createClient() + let message = Message( + text: "🧪 Integration Test: Attachment with fields", + attachments: [ + Attachment( + color: "#439FE0", + 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) + ] + ) + ] + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send message with attachment with author") + func sendMessageWithAttachmentAuthor() async throws { + let client = createClient() + let message = Message( + text: "🧪 Integration Test: Attachment with author", + attachments: [ + Attachment( + color: "warning", + authorName: "John Doe", + authorLink: "https://mattermost.com", + authorIcon: "https://httpbin.org/image/png", + title: "Pull Request", + text: "Please review my changes", + fields: [ + AttachmentField(title: "Repository", value: "mattermost-server", short: true), + AttachmentField(title: "Branch", value: "feature/test", short: true) + ] + ) + ] + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send message with attachment with image") + func sendMessageWithAttachmentImage() async throws { + let client = createClient() + let message = Message( + text: "🧪 Integration Test: Attachment with image", + attachments: [ + Attachment( + title: "Screenshot", + text: "Here is the screenshot you requested", + imageURL: "https://httpbin.org/image/png" + ) + ] + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send message with attachment with thumbnail") + func sendMessageWithAttachmentThumbnail() async throws { + let client = createClient() + let message = Message( + text: "🧪 Integration Test: Attachment with thumbnail", + attachments: [ + Attachment( + title: "Document Preview", + text: " quarterly_report.pdf", + fields: [ + AttachmentField(title: "Size", value: "2.5 MB", short: true), + AttachmentField(title: "Type", value: "PDF", short: true) + ], + thumbURL: "https://httpbin.org/image/png" + ) + ] + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send message with attachment with footer") + func sendMessageWithAttachmentFooter() async throws { + let client = createClient() + let message = Message( + text: "🧪 Integration Test: Attachment with footer", + attachments: [ + Attachment( + color: "good", + title: "Deployment Complete", + text: "Production deployment finished successfully", + footer: "DeployBot v2.1", + footerIcon: "https://httpbin.org/image/png", + footerTimestamp: Int(Date().timeIntervalSince1970) + ) + ] + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send message with multiple attachments") + func sendMessageWithMultipleAttachments() async throws { + let client = createClient() + let message = Message( + text: "🧪 Integration Test: Multiple attachments", + 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" + ) + ] + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + // MARK: - Action Buttons + + @Test("Send message with action button") + func sendMessageWithActionButton() async throws { + let client = createClient() + let message = Message( + text: "🧪 Integration Test: Action button", + attachments: [ + Attachment( + text: "Click the button below to approve:", + actions: [ + Action( + name: "approve", + text: "Approve", + style: "primary", + url: "https://mattermost.com" + ) + ] + ) + ] + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send message with multiple action buttons") + func sendMessageWithMultipleActionButtons() async throws { + let client = createClient() + let message = Message( + text: "🧪 Integration Test: Multiple action buttons", + attachments: [ + Attachment( + text: "Choose an action:", + actions: [ + Action(name: "approve", text: "Approve", style: "primary", url: "https://mattermost.com"), + Action(name: "deny", text: "Deny", style: "danger", url: "https://mattermost.com"), + Action(name: "defer", text: "Defer", url: "https://mattermost.com") + ] + ) + ] + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send message with confirmation dialog") + func sendMessageWithConfirmationDialog() async throws { + let client = createClient() + let message = Message( + text: "🧪 Integration Test: Confirmation dialog", + attachments: [ + Attachment( + text: "Destructive action requires confirmation", + actions: [ + Action( + name: "delete", + text: "Delete", + style: "danger", + confirm: Confirmation( + title: "Are you sure?", + text: "This action cannot be undone.", + confirmText: "Delete", + denyText: "Cancel" + ) + ) + ] + ) + ] + ) + try await client.send(message) + + 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( + text: "🧪 Builder API - Basic", + attachments: { + Attachment( + color: "good", + title: "Builder API Test", + text: "This message was created using the result builder API" + ) { + AttachmentField("Clean", value: "Readable") + AttachmentField("Type-safe", value: "Expressive") + } + } + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send message using builder API - conditional attachments") + func sendMessageUsingBuilderConditional() async throws { + let client = createClient() + let showWarning = true + let showError = false + + let message = Message( + text: "🧪 Builder API - Conditional Attachments", + attachments: { + Attachment( + color: "good", + title: "Main Attachment", + text: "This always appears" + ) + + if showWarning { + Attachment( + color: "warning", + title: "Warning", + text: "This appears because showWarning is true" + ) + } + + if showError { + Attachment( + color: "danger", + title: "Error", + text: "This won't appear because showError is false" + ) + } + } + ) + try await client.send(message) + + 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( + text: "🧪 Builder API - For Loops", + attachments: { + for item in items { + Attachment( + title: item, + text: "This is \(item.lowercased())" + ) + } + } + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send message using builder API - complex message") + func sendMessageUsingBuilderComplex() async throws { + let client = createClient() + let features = [ + ("Result Builders", "Swift 5.4+"), + ("Type Safety", "Compile-time checks"), + ("Expressive", "Clean and readable") + ] + + let message = Message( + text: "🧪 Builder API - Complex Message", + username: "Builder Bot", + iconEmoji: ":construction_worker:", + attachments: { + Attachment( + color: "good", + title: "Builder API Features", + text: """ + This message demonstrates the *full power* of the builder API: + • Clean syntax + • Type-safe + • Expressive + """ + ) { + for (feature, description) in features { + AttachmentField(feature, value: description) + } + } + } + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send message using builder API - with actions") + func sendMessageUsingBuilderWithActions() async throws { + let client = createClient() + let message = Message( + text: "🧪 Builder API - With Actions", + username: "Action Bot", + attachments: { + Attachment.actions { + Action(name: "approve", text: "Approve", style: "primary", url: "https://mattermost.com") + Action(name: "deny", text: "Deny", style: "danger", url: "https://mattermost.com") + } + } + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send message using builder API - fields and actions") + func sendMessageUsingBuilderFieldsAndActions() async throws { + let client = createClient() + let message = Message( + text: "🧪 Builder API - Fields and Actions", + username: "Builder Bot", + attachments: { + Attachment( + fieldsBuilder: { + AttachmentField("Author", value: "John Doe") + AttachmentField("Repository", value: "mattermost-server") + AttachmentField("Branch", value: "feature/test") + }, + actionsBuilder: { + Action(name: "approve", text: "Approve", style: "primary", url: "https://mattermost.com") + Action(name: "changes", text: "Request Changes", style: "danger", url: "https://mattermost.com") + } + ) + } + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + // MARK: - Message Priority + + @Test("Send message with important priority") + func sendMessageWithImportantPriority() async throws { + let client = createClient() + let message = Message( + text: "🧪 Integration Test: Important priority message", + priority: Priority( + priority: .important, + requestedAck: true + ) + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send message with urgent priority") + func sendMessageWithUrgentPriority() async throws { + let client = createClient() + let message = Message( + text: "🧪 Integration Test: Urgent priority message", + priority: Priority( + priority: .urgent, + requestedAck: true + ) + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + // MARK: - Props and Card + + @Test("Send message with props card") + func sendMessageWithPropsCard() async throws { + let client = createClient() + let message = Message( + text: "🧪 Integration Test: Message with card content", + props: Props(card: "**Card Content**\n\nThis appears in the RHS sidebar when you click on the message.") + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send message with props and custom properties") + func sendMessageWithPropsCustomProperties() async throws { + let client = createClient() + let message = Message( + text: "🧪 Integration Test: Message with custom props", + props: Props( + card: "Custom card content", + properties: { + Property("app_id", value: "mattermostkit") + Property("version", value: "1.0.0") + Property("source", value: "integration_tests") + } + ) + ) + try await client.send(message) + + 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:", + username: "Emoji Bot :sparkles:", + iconEmoji: ":robot_face:", + attachments: [ + Attachment( + text: "Emojis in attachments work too! :star: :heart: :thumbsup:" + ) + ] + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send message with special characters") + func sendMessageWithSpecialCharacters() async throws { + let client = createClient() + let message = Message( + text: """ + 🧪 Integration Test: Special Characters + + Testing special characters: & < > \" ' ` ~ * _ { } [ ] ( ) + Unicode support: 你好 世界 🌍 Ñoño café + """, + attachments: [ + Attachment( + text: "More special characters: @mentions #channels ~groups", + fields: [ + AttachmentField("Symbols", value: "© ® ™ € £ ¥"), + AttachmentField("Math", value: "± × ÷ ≠ ≤ ≥") + ] + ) + ] + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send message with code blocks") + func sendMessageWithCodeBlocks() async throws { + let client = createClient() + let message = Message( + text: """ + 🧪 Integration Test: Code Blocks + + Inline code: `print("Hello, World!")` + + ``` + func greet(name: String) { + print("Hello, \\(name)!") + } + ``` + + ```bash + swift run + ``` + """ + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + // MARK: - Complex Messages + + @Test("Send complex deployment notification") + func sendComplexDeploymentNotification() async throws { + let client = createClient() + let message = Message( + text: "🚀 Deployment Alert", + username: "DeployBot", + iconEmoji: ":rocket:", + attachments: { + Attachment( + color: "good", + pretext: "Build #1234", + title: "Deployment Complete", + text: "Successfully deployed to production", + fields: [ + AttachmentField("Environment", value: "Production"), + AttachmentField("Status", value: "Success"), + AttachmentField("Duration", value: "5m 32s"), + AttachmentField("Deployed by", value: "John Doe") + ], + footer: "DeployBot v2.1", + footerIcon: "https://httpbin.org/image/png", + footerTimestamp: Int(Date().timeIntervalSince1970) + ) + } + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send error alert message") + func sendErrorAlertMessage() async throws { + let client = createClient() + let message = Message( + text: "Production Alert", + username: "AlertBot", + iconEmoji: ":warning:", + attachments: { + Attachment( + fieldsBuilder: { + AttachmentField("Service", value: "payment-api") + AttachmentField("Region", value: "us-east-1") + AttachmentField("Severity", value: ":rotating_light: Critical") + AttachmentField("Time", value: ISO8601DateFormatter().string(from: Date())) + }, + actionsBuilder: { + Action(name: "investigate", text: "Investigate", style: "danger", url: "https://mattermost.com") + Action(name: "acknowledge", text: "Acknowledge", url: "https://mattermost.com") + } + ) + } + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send feature announcement message") + func sendFeatureAnnouncementMessage() async throws { + let client = createClient() + let message = Message( + text: "New feature announcement", + username: "Product Updates", + iconEmoji: ":mega:", + attachments: { + Attachment( + fieldsBuilder: { + AttachmentField("Version", value: "2.0") + AttachmentField("Release Date", value: ISO8601DateFormatter().string(from: Date())) + }, + actionsBuilder: { + Action(name: "learn_more", text: "Learn More", style: "primary", url: "https://mattermost.com") + Action(name: "watch_demo", text: "Watch Demo", url: "https://mattermost.com") + } + ) + } + ) + try await client.send(message) + + 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: "", + attachments: [ + Attachment(text: "Text is empty but attachment is present") + ] + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + @Test("Send message with only text (no attachments)") + func sendMessageWithOnlyText() async throws { + let client = createClient() + let message = Message(text: "This is a simple message with only text, no attachments.") + try await client.send(message) + + await waitFor(.seconds(1)) + } + + // MARK: - Confirmation Builder + + @Test("Send message using confirmation builder") + func sendMessageUsingConfirmationBuilder() async throws { + let client = createClient() + let message = Message( + text: "🧪 Confirmation Builder Test", + attachments: [ + Attachment( + text: "This button uses the confirmation builder", + actions: [ + Action( + name: "delete", + text: "Delete", + style: "danger", + confirm: Confirmation( + title: "Confirm Deletion", + text: "Are you sure you want to delete this item?" + ) { + ConfirmButton(text: "Yes, Delete", style: "danger") + DenyButton(text: "Cancel") + } + ) + ] + ) + ] + ) + try await client.send(message) + + await waitFor(.seconds(1)) + } + + // MARK: - Final Summary Test + + @Test("Send integration test summary") + func sendIntegrationTestSummary() async throws { + let client = createClient() + let message = Message( + text: "Integration Tests Complete", + username: "MattermostKit Test Runner", + iconEmoji: ":test_tube:", + attachments: [ + Attachment( + color: "good", + title: "All MattermostKit features tested successfully!", + text: "The following were tested:", + fields: [ + AttachmentField("Features", value: "Simple text messages"), + AttachmentField("Features", value: "Markdown formatting"), + AttachmentField("Features", value: "Custom username and icons"), + AttachmentField("Features", value: "Single and multiple attachments"), + AttachmentField("Features", value: "Attachment fields"), + AttachmentField("Features", value: "Attachment author"), + AttachmentField("Features", value: "Images and thumbnails"), + AttachmentField("Features", value: "Footer and timestamps"), + AttachmentField("Features", value: "Action buttons"), + AttachmentField("Features", value: "Confirmation dialogs"), + AttachmentField("Features", value: "Builder API"), + AttachmentField("Features", value: "Message priority"), + AttachmentField("Features", value: "Props and card"), + AttachmentField("Features", value: "Emojis and Unicode"), + AttachmentField("Features", value: "Code blocks"), + AttachmentField("Features", value: "Complex messages") + ] + ), + Attachment( + color: "#439FE0", + text: "Powered by **Swift 6** • Built with ❤️", + footer: "MattermostKit Integration Tests" + ) + ] + ) + try await client.send(message) + } +}