diff --git a/.gemini/settings.json b/.gemini/settings.json new file mode 100644 index 0000000..1f150c5 --- /dev/null +++ b/.gemini/settings.json @@ -0,0 +1,7 @@ +{ + "codeReview": { + "enabled": true, + "autoReview": true, + "reviewStyle": "concise" + } +} diff --git a/lib/lettermint.rb b/lib/lettermint.rb index aa58267..a1f3ddf 100644 --- a/lib/lettermint.rb +++ b/lib/lettermint.rb @@ -7,6 +7,21 @@ require_relative 'lettermint/http_client' require_relative 'lettermint/email_message' require_relative 'lettermint/webhook' + +# Resources (Team API) +require_relative 'lettermint/resources/base' +require_relative 'lettermint/resources/team' +require_relative 'lettermint/resources/domains' +require_relative 'lettermint/resources/projects' +require_relative 'lettermint/resources/routes' +require_relative 'lettermint/resources/webhooks' +require_relative 'lettermint/resources/messages' +require_relative 'lettermint/resources/suppressions' +require_relative 'lettermint/resources/stats' + +# API Clients +require_relative 'lettermint/sending_api' +require_relative 'lettermint/team_api' require_relative 'lettermint/client' module Lettermint diff --git a/lib/lettermint/client.rb b/lib/lettermint/client.rb index 6326d40..4caa1bf 100644 --- a/lib/lettermint/client.rb +++ b/lib/lettermint/client.rb @@ -1,72 +1,7 @@ # frozen_string_literal: true module Lettermint - class Client - attr_reader :configuration - - def initialize(api_token:, base_url: nil, timeout: nil) - validate_api_token!(api_token) - - @configuration = Configuration.new - @configuration.base_url = base_url || Lettermint.configuration.base_url - @configuration.timeout = timeout || Lettermint.configuration.timeout - - yield @configuration if block_given? - - @http_client = HttpClient.new( - api_token: api_token, - base_url: @configuration.base_url, - timeout: @configuration.timeout - ) - end - - def email - EmailMessage.new(http_client: @http_client) - end - - # Makes a GET request to an arbitrary API endpoint. - # - # @param path [String] The API endpoint path (e.g., '/domains') - # @param params [Hash, nil] Query parameters to include in the request - # @param headers [Hash, nil] Additional HTTP headers - # @return [Hash] The parsed JSON response body - def get(path, params: nil, headers: nil) - @http_client.get(path: path, params: params, headers: headers) - end - - # Makes a POST request to an arbitrary API endpoint. - # - # @param path [String] The API endpoint path - # @param data [Hash, nil] The request body (will be JSON-encoded) - # @param headers [Hash, nil] Additional HTTP headers - # @return [Hash] The parsed JSON response body - def post(path, data: nil, headers: nil) - @http_client.post(path: path, data: data, headers: headers) - end - - # Makes a PUT request to an arbitrary API endpoint. - # - # @param path [String] The API endpoint path - # @param data [Hash, nil] The request body (will be JSON-encoded) - # @param headers [Hash, nil] Additional HTTP headers - # @return [Hash] The parsed JSON response body - def put(path, data: nil, headers: nil) - @http_client.put(path: path, data: data, headers: headers) - end - - # Makes a DELETE request to an arbitrary API endpoint. - # - # @param path [String] The API endpoint path - # @param headers [Hash, nil] Additional HTTP headers - # @return [Hash] The parsed JSON response body - def delete(path, headers: nil) - @http_client.delete(path: path, headers: headers) - end - - private - - def validate_api_token!(token) - raise ArgumentError, 'API token cannot be empty' if token.nil? || token.to_s.strip.empty? - end - end + # Backward compatibility: Client is an alias for SendingAPI. + # Use Lettermint::SendingAPI explicitly for clarity. + Client = SendingAPI end diff --git a/lib/lettermint/http_client.rb b/lib/lettermint/http_client.rb index b148a2e..b973323 100644 --- a/lib/lettermint/http_client.rb +++ b/lib/lettermint/http_client.rb @@ -4,7 +4,7 @@ module Lettermint class HttpClient - def initialize(api_token:, base_url:, timeout:) + def initialize(api_token:, base_url:, timeout:, auth_scheme: :project) normalized_url = "#{base_url.chomp('/')}/" @connection = Faraday.new(url: normalized_url) do |f| f.request :json @@ -14,7 +14,7 @@ def initialize(api_token:, base_url:, timeout:) f.headers = { 'Content-Type' => 'application/json', 'Accept' => 'application/json', - 'x-lettermint-token' => api_token, + **auth_headers(api_token, auth_scheme), 'User-Agent' => "Lettermint/#{Lettermint::VERSION} (Ruby; ruby #{RUBY_VERSION})" } end @@ -57,6 +57,14 @@ def delete(path:, headers: nil) private + def auth_headers(token, scheme) + case scheme.to_sym + when :project then { 'x-lettermint-token' => token } + when :team then { 'Authorization' => "Bearer #{token}" } + else raise ArgumentError, "Unknown auth_scheme: #{scheme}" + end + end + def with_error_handling response = yield handle_response(response) diff --git a/lib/lettermint/resources/base.rb b/lib/lettermint/resources/base.rb new file mode 100644 index 0000000..403b1aa --- /dev/null +++ b/lib/lettermint/resources/base.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module Lettermint + module Resources + # Base class for Team API resources providing shared functionality. + class Base + def initialize(http_client:) + @http_client = http_client + end + + private + + # Builds query parameters for list endpoints. + # @param page_size [Integer, nil] Number of items per page + # @param page_cursor [String, nil] Cursor for pagination + # @param sort [String, nil] Sort field (prefix with - for descending) + # @param include [String, nil] Related resources to include + # @param filters [Hash] Filter parameters (converted to filter[key]=value) + # @return [Hash, nil] Query parameters hash or nil if empty + def build_params(page_size: nil, page_cursor: nil, sort: nil, include: nil, **filters) + params = { + 'page[size]' => page_size, + 'page[cursor]' => page_cursor, + 'sort' => sort, + 'include' => include + }.compact + + filters.each { |k, v| params["filter[#{k}]"] = v unless v.nil? } + params.empty? ? nil : params + end + end + end +end diff --git a/lib/lettermint/resources/domains.rb b/lib/lettermint/resources/domains.rb new file mode 100644 index 0000000..b27b2d4 --- /dev/null +++ b/lib/lettermint/resources/domains.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +module Lettermint + module Resources + # Domains resource for managing sending domains and DNS verification. + class Domains < Base + # List all domains. + # @param page_size [Integer, nil] Number of items per page (default: 30) + # @param page_cursor [String, nil] Cursor for pagination + # @param sort [String, nil] Sort field: domain, created_at, status_changed_at (prefix - for desc) + # @param status [String, nil] Filter by status (verified, partially_verified, etc.) + # @param domain [String, nil] Filter by domain (partial match) + # @return [Hash] Paginated list of domains + def list(page_size: nil, page_cursor: nil, sort: nil, status: nil, domain: nil) + params = build_params(page_size:, page_cursor:, sort:, status:, domain:) + @http_client.get(path: '/domains', params: params) + end + + # Create a new domain. + # @param domain [String] Domain name (max 255 chars) + # @return [Hash] Created domain data + def create(domain:) + @http_client.post(path: '/domains', data: { domain: domain }) + end + + # Get domain details. + # @param id [String] Domain ID + # @param include [String, nil] Related data to include (dnsRecords, dnsRecordsCount, dnsRecordsExists) + # @return [Hash] Domain data with optional includes + def find(id, include: nil) + params = build_params(include: include) + @http_client.get(path: "/domains/#{id}", params: params) + end + + # Delete a domain. + # @param id [String] Domain ID + # @return [Hash] Confirmation message + def delete(id) + @http_client.delete(path: "/domains/#{id}") + end + + # Verify all DNS records for a domain. + # @param id [String] Domain ID + # @return [Hash] Verification result + def verify_dns(id) + @http_client.post(path: "/domains/#{id}/dns-records/verify") + end + + # Verify a specific DNS record. + # @param domain_id [String] Domain ID + # @param record_id [String] DNS record ID + # @return [Hash] Verification result + def verify_dns_record(domain_id, record_id) + @http_client.post(path: "/domains/#{domain_id}/dns-records/#{record_id}/verify") + end + + # Update projects associated with a domain. + # @param id [String] Domain ID + # @param project_ids [Array] Array of project UUIDs + # @return [Hash] Updated domain data + def update_projects(id, project_ids:) + @http_client.put(path: "/domains/#{id}/projects", data: { project_ids: project_ids }) + end + end + end +end diff --git a/lib/lettermint/resources/messages.rb b/lib/lettermint/resources/messages.rb new file mode 100644 index 0000000..7751e9a --- /dev/null +++ b/lib/lettermint/resources/messages.rb @@ -0,0 +1,81 @@ +# frozen_string_literal: true + +module Lettermint + module Resources + # Messages resource for viewing sent and received messages. + class Messages < Base + # List messages. + # @param page_size [Integer, nil] Number of items per page (default: 30) + # @param page_cursor [String, nil] Cursor for pagination + # @param sort [String, nil] Sort field: type, status, from_email, subject, created_at, status_changed_at + # @param type [String, nil] Filter: inbound, outbound + # @param status [String, nil] Filter by status + # @param route_id [String, nil] Filter by route ID + # @param domain_id [String, nil] Filter by domain ID + # @param tag [String, nil] Filter by tag + # @param from_email [String, nil] Filter by sender email + # @param subject [String, nil] Filter by subject + # @param from_date [String, nil] Filter from date (Y-m-d) + # @param to_date [String, nil] Filter to date (Y-m-d) + # @return [Hash] Paginated list of messages + # rubocop:disable Metrics/ParameterLists + def list(page_size: nil, page_cursor: nil, sort: nil, type: nil, status: nil, + route_id: nil, domain_id: nil, tag: nil, from_email: nil, subject: nil, + from_date: nil, to_date: nil) + params = build_params( + page_size: page_size, + page_cursor: page_cursor, + sort: sort, + type: type, + status: status, + route_id: route_id, + domain_id: domain_id, + tag: tag, + from_email: from_email, + subject: subject, + from_date: from_date, + to_date: to_date + ) + @http_client.get(path: '/messages', params: params) + end + # rubocop:enable Metrics/ParameterLists + + # Get message details. + # @param id [String] Message ID + # @return [Hash] Message data + def find(id) + @http_client.get(path: "/messages/#{id}") + end + + # Get message events (delivery history). + # @param id [String] Message ID + # @param sort [String, nil] Sort field: timestamp, event + # @return [Hash] List of message events + def events(id, sort: nil) + params = sort ? { 'sort' => sort } : nil + @http_client.get(path: "/messages/#{id}/events", params: params) + end + + # Get raw message source (RFC822 format). + # @param id [String] Message ID + # @return [String] Raw message source (message/rfc822) + def source(id) + @http_client.get(path: "/messages/#{id}/source", headers: { 'Accept' => 'message/rfc822' }) + end + + # Get message HTML body. + # @param id [String] Message ID + # @return [String] HTML content (text/html) + def html(id) + @http_client.get(path: "/messages/#{id}/html", headers: { 'Accept' => 'text/html' }) + end + + # Get message plain text body. + # @param id [String] Message ID + # @return [String] Plain text content (text/plain) + def text(id) + @http_client.get(path: "/messages/#{id}/text", headers: { 'Accept' => 'text/plain' }) + end + end + end +end diff --git a/lib/lettermint/resources/projects.rb b/lib/lettermint/resources/projects.rb new file mode 100644 index 0000000..a2c13e9 --- /dev/null +++ b/lib/lettermint/resources/projects.rb @@ -0,0 +1,103 @@ +# frozen_string_literal: true + +module Lettermint + module Resources + # Projects resource for managing projects, members, and accessing routes. + class Projects < Base + # List all projects. + # @param page_size [Integer, nil] Number of items per page (default: 30) + # @param page_cursor [String, nil] Cursor for pagination + # @param sort [String, nil] Sort field: name, created_at (prefix - for desc) + # @param search [String, nil] Search filter + # @return [Hash] Paginated list of projects + def list(page_size: nil, page_cursor: nil, sort: nil, search: nil) + params = build_params(page_size: page_size, page_cursor: page_cursor, sort: sort, search: search) + @http_client.get(path: '/projects', params: params) + end + + # Create a new project. + # @param name [String] Project name (max 255 chars) + # @param smtp_enabled [Boolean, nil] Enable SMTP (default: false) + # @param initial_routes [String, nil] Initial routes: both, transactional, broadcast (default: both) + # @return [Hash] Created project data including api_token + def create(name:, smtp_enabled: nil, initial_routes: nil) + data = { name: name } + data[:smtp_enabled] = smtp_enabled unless smtp_enabled.nil? + data[:initial_routes] = initial_routes if initial_routes + @http_client.post(path: '/projects', data: data) + end + + # Get project details. + # + # Note: Returns a Hash, not a resource object. To access routes for a project, + # use `projects.routes(project_id)` rather than chaining on the find result. + # + # @param id [String] Project ID + # @param include [String, nil] Related data: routes, domains, teamMembers, messageStats (+ Count/Exists variants) + # @return [Hash] Project data with optional includes + def find(id, include: nil) + params = include ? { 'include' => include } : nil + @http_client.get(path: "/projects/#{id}", params: params) + end + + # Update a project. + # @param id [String] Project ID + # @param name [String, nil] New project name + # @param smtp_enabled [Boolean, nil] Enable/disable SMTP + # @param default_route_id [String, nil] Default route UUID + # @return [Hash] Updated project data + def update(id, name: nil, smtp_enabled: nil, default_route_id: nil) + data = {} + data[:name] = name if name + data[:smtp_enabled] = smtp_enabled unless smtp_enabled.nil? + data[:default_route_id] = default_route_id if default_route_id + @http_client.put(path: "/projects/#{id}", data: data) + end + + # Delete a project. + # @param id [String] Project ID + # @return [Hash] Confirmation message + def delete(id) + @http_client.delete(path: "/projects/#{id}") + end + + # Rotate the project API token. + # @param id [String] Project ID + # @return [Hash] Contains new_token + def rotate_token(id) + @http_client.post(path: "/projects/#{id}/rotate-token") + end + + # Update project members (replace all). + # @param id [String] Project ID + # @param team_member_ids [Array] Array of team member IDs + # @return [Hash] Confirmation + def update_members(id, team_member_ids:) + @http_client.put(path: "/projects/#{id}/members", data: { team_member_ids: team_member_ids }) + end + + # Add a member to the project. + # @param project_id [String] Project ID + # @param member_id [String] Team member ID + # @return [Hash] Confirmation + def add_member(project_id, member_id) + @http_client.post(path: "/projects/#{project_id}/members/#{member_id}") + end + + # Remove a member from the project. + # @param project_id [String] Project ID + # @param member_id [String] Team member ID + # @return [Hash] Confirmation + def remove_member(project_id, member_id) + @http_client.delete(path: "/projects/#{project_id}/members/#{member_id}") + end + + # Get a routes accessor scoped to this project. + # @param project_id [String] Project ID + # @return [Routes] Routes resource scoped to the project + def routes(project_id) + Routes.new(http_client: @http_client, project_id: project_id) + end + end + end +end diff --git a/lib/lettermint/resources/routes.rb b/lib/lettermint/resources/routes.rb new file mode 100644 index 0000000..7c99b39 --- /dev/null +++ b/lib/lettermint/resources/routes.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +module Lettermint + module Resources + # Routes resource for managing project routes (transactional, broadcast, inbound). + # Can be instantiated with a project_id for scoped operations or without for direct route access. + class Routes < Base + # @param http_client [HttpClient] HTTP client instance + # @param project_id [String, nil] Optional project ID for scoped operations + def initialize(http_client:, project_id: nil) + super(http_client: http_client) + @project_id = project_id + end + + # List routes for a project. + # Requires project_id to be set (via constructor or projects.routes(id)). + # @param page_size [Integer, nil] Number of items per page (default: 30) + # @param page_cursor [String, nil] Cursor for pagination + # @param sort [String, nil] Sort field: name, slug, created_at (prefix - for desc) + # @param route_type [String, nil] Filter: transactional, broadcast, inbound + # @param is_default [Boolean, nil] Filter by default route status + # @param search [String, nil] Search filter + # @return [Hash] Paginated list of routes + # rubocop:disable Metrics/ParameterLists + def list(page_size: nil, page_cursor: nil, sort: nil, route_type: nil, + is_default: nil, search: nil) + raise ArgumentError, 'project_id required for listing routes' unless @project_id + + params = build_params( + page_size: page_size, + page_cursor: page_cursor, + sort: sort, + route_type: route_type, + is_default: is_default, + search: search + ) + @http_client.get(path: "/projects/#{@project_id}/routes", params: params) + end + # rubocop:enable Metrics/ParameterLists + + # Create a new route in a project. + # Requires project_id to be set. + # @param name [String] Route name (max 255 chars) + # @param route_type [String] Type: transactional, broadcast, inbound + # @param slug [String, nil] Optional slug (max 255 chars) + # @return [Hash] Created route data + def create(name:, route_type:, slug: nil) + raise ArgumentError, 'project_id required for creating routes' unless @project_id + + data = { name: name, route_type: route_type } + data[:slug] = slug if slug + @http_client.post(path: "/projects/#{@project_id}/routes", data: data) + end + + # Get route details. + # @param id [String] Route ID + # @param include [String, nil] Related data: project, statistics + # @return [Hash] Route data + def find(id, include: nil) + params = include ? { 'include' => include } : nil + @http_client.get(path: "/routes/#{id}", params: params) + end + + # Update a route. + # @param id [String] Route ID + # @param name [String, nil] New route name + # @param settings [Hash, nil] Route settings (track_opens, track_clicks, disable_hosted_unsubscribe) + # @param inbound_settings [Hash, nil] Inbound settings (inbound_domain, spam_threshold, etc.) + # @return [Hash] Updated route data + def update(id, name: nil, settings: nil, inbound_settings: nil) + data = {} + data[:name] = name if name + data[:settings] = settings if settings + data[:inbound_settings] = inbound_settings if inbound_settings + @http_client.put(path: "/routes/#{id}", data: data) + end + + # Delete a route. + # @param id [String] Route ID + # @return [Hash] Confirmation message + def delete(id) + @http_client.delete(path: "/routes/#{id}") + end + + # Verify inbound domain for a route. + # @param id [String] Route ID + # @return [Hash] Verification result + def verify_inbound_domain(id) + @http_client.post(path: "/routes/#{id}/verify-inbound-domain") + end + end + end +end diff --git a/lib/lettermint/resources/stats.rb b/lib/lettermint/resources/stats.rb new file mode 100644 index 0000000..000b4df --- /dev/null +++ b/lib/lettermint/resources/stats.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Lettermint + module Resources + # Stats resource for retrieving email statistics. + class Stats < Base + # Get statistics for a date range. + # @param from [String] Start date (Y-m-d format, required) + # @param to [String] End date (Y-m-d format, required, max 90 days from start) + # @param project_id [String, nil] Filter by project ID + # @param route_id [String, nil] Filter by a single route ID + # @param route_ids [Array, String, nil] Filter by multiple route IDs + # @return [Hash] Stats data with totals and daily breakdown + def get(from:, to:, project_id: nil, route_id: nil, route_ids: nil) + params = { 'from' => from, 'to' => to } + params['project_id'] = project_id if project_id + params['route_id'] = route_id if route_id + params['route_ids'] = route_ids if route_ids + @http_client.get(path: '/stats', params: params) + end + end + end +end diff --git a/lib/lettermint/resources/suppressions.rb b/lib/lettermint/resources/suppressions.rb new file mode 100644 index 0000000..7a3b897 --- /dev/null +++ b/lib/lettermint/resources/suppressions.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +module Lettermint + module Resources + # Suppressions resource for managing email suppression lists. + class Suppressions < Base + # List suppressions. + # @param page_size [Integer, nil] Number of items per page (default: 30) + # @param page_cursor [String, nil] Cursor for pagination + # @param sort [String, nil] Sort field: value, created_at, reason + # @param scope [String, nil] Filter: team, project, route + # @param route_id [String, nil] Filter by route ID + # @param project_id [String, nil] Filter by project ID + # @param value [String, nil] Filter by suppression value (email/domain/extension) + # @param reason [String, nil] Filter: spam_complaint, hard_bounce, unsubscribe, manual + # @return [Hash] Paginated list of suppressions + # rubocop:disable Metrics/ParameterLists + def list(page_size: nil, page_cursor: nil, sort: nil, scope: nil, + route_id: nil, project_id: nil, value: nil, reason: nil) + params = build_params( + page_size: page_size, + page_cursor: page_cursor, + sort: sort, + scope: scope, + route_id: route_id, + project_id: project_id, + value: value, + reason: reason + ) + @http_client.get(path: '/suppressions', params: params) + end + # rubocop:enable Metrics/ParameterLists + + # Create a suppression entry. + # @param reason [String] Reason: spam_complaint, hard_bounce, unsubscribe, manual + # @param scope [String] Scope: team, project, route + # @param email [String, nil] Single email to suppress (max 255 chars) + # @param emails [Array, nil] Multiple emails to suppress (max 1000) + # @param route_id [String, nil] Route ID (required if scope is route) + # @param project_id [String, nil] Project ID (required if scope is project) + # @return [Hash] Created suppression data + def create(reason:, scope:, email: nil, emails: nil, route_id: nil, project_id: nil) # rubocop:disable Metrics/ParameterLists + data = { reason: reason, scope: scope } + data[:email] = email if email + data[:emails] = emails if emails + data[:route_id] = route_id if route_id + data[:project_id] = project_id if project_id + @http_client.post(path: '/suppressions', data: data) + end + + # Delete a suppression entry. + # @param id [String] Suppression ID + # @return [Hash] Confirmation message + def delete(id) + @http_client.delete(path: "/suppressions/#{id}") + end + end + end +end diff --git a/lib/lettermint/resources/team.rb b/lib/lettermint/resources/team.rb new file mode 100644 index 0000000..a3f53e9 --- /dev/null +++ b/lib/lettermint/resources/team.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Lettermint + module Resources + # Team resource for managing team settings, usage, and members. + class Team < Base + # Get team details. + # @param include [String, nil] Related data to include (features, featuresCount, featuresExists) + # @return [Hash] Team data + def get(include: nil) + params = include ? { 'include' => include } : nil + @http_client.get(path: '/team', params: params) + end + + # Update team settings. + # @param name [String] New team name (max 255 chars) + # @return [Hash] Updated team data + def update(name:) + @http_client.put(path: '/team', data: { name: name }) + end + + # Get team usage statistics. + # @return [Hash] Current period and up to 12 historical periods + def usage + @http_client.get(path: '/team/usage') + end + + # List team members. + # @param page_size [Integer, nil] Number of items per page (default: 30) + # @param page_cursor [String, nil] Cursor for pagination + # @return [Hash] Paginated list of team members + def members(page_size: nil, page_cursor: nil) + params = build_params(page_size: page_size, page_cursor: page_cursor) + @http_client.get(path: '/team/members', params: params) + end + end + end +end diff --git a/lib/lettermint/resources/webhooks.rb b/lib/lettermint/resources/webhooks.rb new file mode 100644 index 0000000..b7faea4 --- /dev/null +++ b/lib/lettermint/resources/webhooks.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +module Lettermint + module Resources + # Webhooks resource for managing webhook endpoints and viewing deliveries. + class Webhooks < Base + # List all webhooks. + # @param page_size [Integer, nil] Number of items per page (default: 30) + # @param page_cursor [String, nil] Cursor for pagination + # @param sort [String, nil] Sort field: name, url, created_at (prefix - for desc) + # @param enabled [Boolean, nil] Filter by enabled status + # @param event [String, nil] Filter by event type + # @param route_id [String, nil] Filter by route ID + # @param search [String, nil] Search filter + # @return [Hash] Paginated list of webhooks + # rubocop:disable Metrics/ParameterLists + def list(page_size: nil, page_cursor: nil, sort: nil, enabled: nil, + event: nil, route_id: nil, search: nil) + params = build_params( + page_size: page_size, + page_cursor: page_cursor, + sort: sort, + enabled: enabled, + event: event, + route_id: route_id, + search: search + ) + @http_client.get(path: '/webhooks', params: params) + end + # rubocop:enable Metrics/ParameterLists + + # Create a new webhook. + # @param route_id [String] Route ID to attach webhook to + # @param name [String] Webhook name (max 255 chars) + # @param url [String] Webhook URL (max 500 chars) + # @param events [Array] Event types to subscribe (min 1) + # @param enabled [Boolean, nil] Enable webhook (default: true) + # @return [Hash] Created webhook data including secret (shown only once) + def create(route_id:, name:, url:, events:, enabled: nil) + data = { route_id: route_id, name: name, url: url, events: events } + data[:enabled] = enabled unless enabled.nil? + @http_client.post(path: '/webhooks', data: data) + end + + # Get webhook details. + # @param id [String] Webhook ID + # @return [Hash] Webhook data including secret + def find(id) + @http_client.get(path: "/webhooks/#{id}") + end + + # Update a webhook. + # @param id [String] Webhook ID + # @param name [String, nil] New webhook name + # @param url [String, nil] New webhook URL + # @param enabled [Boolean, nil] Enable/disable webhook + # @param events [Array, nil] Event types (min 1) + # @return [Hash] Updated webhook data + def update(id, name: nil, url: nil, enabled: nil, events: nil) + data = {} + data[:name] = name if name + data[:url] = url if url + data[:enabled] = enabled unless enabled.nil? + data[:events] = events if events + @http_client.put(path: "/webhooks/#{id}", data: data) + end + + # Delete a webhook. + # @param id [String] Webhook ID + # @return [Hash] Confirmation message + def delete(id) + @http_client.delete(path: "/webhooks/#{id}") + end + + # Test a webhook by sending a test delivery. + # @param id [String] Webhook ID + # @return [Hash] Contains delivery_id for tracking + def test(id) + @http_client.post(path: "/webhooks/#{id}/test") + end + + # Regenerate webhook secret. + # @param id [String] Webhook ID + # @return [Hash] Updated webhook data with new secret + def regenerate_secret(id) + @http_client.post(path: "/webhooks/#{id}/regenerate-secret") + end + + # List webhook deliveries. + # @param webhook_id [String] Webhook ID + # @param page_size [Integer, nil] Number of items per page + # @param page_cursor [String, nil] Cursor for pagination + # @param sort [String, nil] Sort field: created_at, attempt_number + # @param status [String, nil] Filter: pending, success, failed, client_error, server_error, timeout + # @param event_type [String, nil] Filter by event type + # @param from_date [String, nil] Filter from date (Y-m-d) + # @param to_date [String, nil] Filter to date (Y-m-d) + # @return [Hash] Paginated list of deliveries + # rubocop:disable Metrics/ParameterLists + def deliveries(webhook_id, page_size: nil, page_cursor: nil, sort: nil, + status: nil, event_type: nil, from_date: nil, to_date: nil) + params = build_params( + page_size: page_size, + page_cursor: page_cursor, + sort: sort, + status: status, + event_type: event_type, + from_date: from_date, + to_date: to_date + ) + @http_client.get(path: "/webhooks/#{webhook_id}/deliveries", params: params) + end + # rubocop:enable Metrics/ParameterLists + + # Get a specific delivery. + # @param webhook_id [String] Webhook ID + # @param delivery_id [String] Delivery ID + # @return [Hash] Delivery data including payload and response + def delivery(webhook_id, delivery_id) + @http_client.get(path: "/webhooks/#{webhook_id}/deliveries/#{delivery_id}") + end + end + end +end diff --git a/lib/lettermint/sending_api.rb b/lib/lettermint/sending_api.rb new file mode 100644 index 0000000..7d9f842 --- /dev/null +++ b/lib/lettermint/sending_api.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +module Lettermint + # Client for the Lettermint Sending API (project-level email sending). + # Authenticates with project tokens via x-lettermint-token header. + class SendingAPI + attr_reader :configuration + + def initialize(api_token:, base_url: nil, timeout: nil) + validate_api_token!(api_token) + + @configuration = Configuration.new + @configuration.base_url = base_url || Lettermint.configuration.base_url + @configuration.timeout = timeout || Lettermint.configuration.timeout + + yield @configuration if block_given? + + @http_client = HttpClient.new( + api_token: api_token, + base_url: @configuration.base_url, + timeout: @configuration.timeout, + auth_scheme: :project + ) + end + + def email + EmailMessage.new(http_client: @http_client) + end + + # Makes a GET request to an arbitrary API endpoint. + # + # @param path [String] The API endpoint path (e.g., '/domains') + # @param params [Hash, nil] Query parameters to include in the request + # @param headers [Hash, nil] Additional HTTP headers + # @return [Hash] The parsed JSON response body + def get(path, params: nil, headers: nil) + @http_client.get(path: path, params: params, headers: headers) + end + + # Makes a POST request to an arbitrary API endpoint. + # + # @param path [String] The API endpoint path + # @param data [Hash, nil] The request body (will be JSON-encoded) + # @param headers [Hash, nil] Additional HTTP headers + # @return [Hash] The parsed JSON response body + def post(path, data: nil, headers: nil) + @http_client.post(path: path, data: data, headers: headers) + end + + # Makes a PUT request to an arbitrary API endpoint. + # + # @param path [String] The API endpoint path + # @param data [Hash, nil] The request body (will be JSON-encoded) + # @param headers [Hash, nil] Additional HTTP headers + # @return [Hash] The parsed JSON response body + def put(path, data: nil, headers: nil) + @http_client.put(path: path, data: data, headers: headers) + end + + # Makes a DELETE request to an arbitrary API endpoint. + # + # @param path [String] The API endpoint path + # @param headers [Hash, nil] Additional HTTP headers + # @return [Hash] The parsed JSON response body + def delete(path, headers: nil) + @http_client.delete(path: path, headers: headers) + end + + private + + def validate_api_token!(token) + raise ArgumentError, 'API token cannot be empty' if token.nil? || token.to_s.strip.empty? + end + end +end diff --git a/lib/lettermint/team_api.rb b/lib/lettermint/team_api.rb new file mode 100644 index 0000000..f8371f2 --- /dev/null +++ b/lib/lettermint/team_api.rb @@ -0,0 +1,90 @@ +# frozen_string_literal: true + +module Lettermint + # Client for the Lettermint Team API (team-level management operations). + # Authenticates with team tokens (lm_team_*) via Authorization: Bearer header. + class TeamAPI + attr_reader :configuration + + def initialize(team_token:, base_url: nil, timeout: nil) + validate_team_token!(team_token) + + @configuration = Configuration.new + @configuration.base_url = base_url || Lettermint.configuration.base_url + @configuration.timeout = timeout || Lettermint.configuration.timeout + + yield @configuration if block_given? + + @http_client = HttpClient.new( + api_token: team_token, + base_url: @configuration.base_url, + timeout: @configuration.timeout, + auth_scheme: :team + ) + end + + # Health check endpoint (accepts both token types) + # @return [Hash] Parsed response body, e.g. { 'ok' => true } on success + def ping + @http_client.get(path: '/ping') + end + + # Team resource accessor + # @return [Resources::Team] + def team + Resources::Team.new(http_client: @http_client) + end + + # Domains resource accessor + # @return [Resources::Domains] + def domains + Resources::Domains.new(http_client: @http_client) + end + + # Projects resource accessor + # @return [Resources::Projects] + def projects + Resources::Projects.new(http_client: @http_client) + end + + # Webhooks resource accessor + # @return [Resources::Webhooks] + def webhooks + Resources::Webhooks.new(http_client: @http_client) + end + + # Messages resource accessor + # @return [Resources::Messages] + def messages + Resources::Messages.new(http_client: @http_client) + end + + # Suppressions resource accessor + # @return [Resources::Suppressions] + def suppressions + Resources::Suppressions.new(http_client: @http_client) + end + + # Stats resource accessor + # @return [Resources::Stats] + def stats + Resources::Stats.new(http_client: @http_client) + end + + # Routes resource accessor (top-level for direct route access) + # For project-scoped routes, use projects.routes(project_id) + # @return [Resources::Routes] + def routes + Resources::Routes.new(http_client: @http_client) + end + + private + + def validate_team_token!(token) + raise ArgumentError, 'Team token cannot be empty' if token.nil? || token.to_s.strip.empty? + return if token.to_s.start_with?('lm_team_') + + raise ArgumentError, "Invalid team token format (expected 'lm_team_*')" + end + end +end diff --git a/spec/lettermint/resources/base_spec.rb b/spec/lettermint/resources/base_spec.rb new file mode 100644 index 0000000..7cce5f5 --- /dev/null +++ b/spec/lettermint/resources/base_spec.rb @@ -0,0 +1,137 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Lettermint::Resources::Base do + # Create a test subclass to expose the private build_params method + let(:test_resource_class) do + Class.new(described_class) do + def test_build_params(**args) + build_params(**args) + end + end + end + + let(:http_client) { instance_double(Lettermint::HttpClient) } + let(:resource) { test_resource_class.new(http_client: http_client) } + + describe '#initialize' do + it 'stores the http_client' do + base = described_class.new(http_client: http_client) + expect(base.instance_variable_get(:@http_client)).to eq(http_client) + end + end + + describe '#build_params (private)' do + describe 'pagination parameters' do + it 'returns nil when no parameters are provided' do + expect(resource.test_build_params).to be_nil + end + + it 'builds page[size] parameter' do + result = resource.test_build_params(page_size: 50) + expect(result).to eq({ 'page[size]' => 50 }) + end + + it 'builds page[cursor] parameter' do + result = resource.test_build_params(page_cursor: 'abc123') + expect(result).to eq({ 'page[cursor]' => 'abc123' }) + end + + it 'builds both pagination parameters together' do + result = resource.test_build_params(page_size: 25, page_cursor: 'xyz789') + expect(result).to eq({ + 'page[size]' => 25, + 'page[cursor]' => 'xyz789' + }) + end + end + + describe 'sort parameter' do + it 'builds sort parameter for ascending order' do + result = resource.test_build_params(sort: 'created_at') + expect(result).to eq({ 'sort' => 'created_at' }) + end + + it 'builds sort parameter for descending order' do + result = resource.test_build_params(sort: '-created_at') + expect(result).to eq({ 'sort' => '-created_at' }) + end + end + + describe 'include parameter' do + it 'builds include parameter' do + result = resource.test_build_params(include: 'dnsRecords') + expect(result).to eq({ 'include' => 'dnsRecords' }) + end + + it 'builds include parameter with multiple values' do + result = resource.test_build_params(include: 'dnsRecords,features') + expect(result).to eq({ 'include' => 'dnsRecords,features' }) + end + end + + describe 'filter parameters' do + it 'builds single filter parameter' do + result = resource.test_build_params(status: 'verified') + expect(result).to eq({ 'filter[status]' => 'verified' }) + end + + it 'builds multiple filter parameters' do + result = resource.test_build_params(status: 'verified', domain: 'example.com') + expect(result).to eq({ + 'filter[status]' => 'verified', + 'filter[domain]' => 'example.com' + }) + end + + it 'ignores nil filter values' do + result = resource.test_build_params(status: 'verified', domain: nil) + expect(result).to eq({ 'filter[status]' => 'verified' }) + end + + it 'returns nil when all filters are nil' do + expect(resource.test_build_params(status: nil, domain: nil)).to be_nil + end + end + + describe 'combined parameters' do + it 'builds all parameter types together' do + result = resource.test_build_params( + page_size: 30, + page_cursor: 'cursor123', + sort: '-created_at', + include: 'dnsRecords', + status: 'verified', + domain: 'example.com' + ) + + expect(result).to eq({ + 'page[size]' => 30, + 'page[cursor]' => 'cursor123', + 'sort' => '-created_at', + 'include' => 'dnsRecords', + 'filter[status]' => 'verified', + 'filter[domain]' => 'example.com' + }) + end + + it 'omits nil values from combined params' do + result = resource.test_build_params( + page_size: 30, + page_cursor: nil, + sort: 'name', + include: nil, + status: 'active', + domain: nil + ) + + expect(result).to eq({ + 'page[size]' => 30, + 'sort' => 'name', + 'filter[status]' => 'active' + }) + end + end + end +end diff --git a/spec/lettermint/resources/domains_spec.rb b/spec/lettermint/resources/domains_spec.rb new file mode 100644 index 0000000..6ec3a8b --- /dev/null +++ b/spec/lettermint/resources/domains_spec.rb @@ -0,0 +1,504 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Lettermint::Resources::Domains do + let(:team_token) { 'lm_team_test123' } + let(:base_url) { Lettermint::Configuration::DEFAULT_BASE_URL } + let(:api) { Lettermint::TeamAPI.new(team_token: team_token) } + let(:domains) { api.domains } + + describe '#list' do + it 'sends GET request to /domains' do + stub_request(:get, "#{base_url}/domains") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"data":[{"id":"dom_1","domain":"example.com","status":"verified"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = domains.list + expect(result['data']).to be_an(Array) + expect(result['data'].first['domain']).to eq('example.com') + end + + it 'sends GET request without params when none specified' do + stub = stub_request(:get, "#{base_url}/domains") + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + domains.list + expect(stub).to have_been_requested + expect(WebMock).to(have_requested(:get, "#{base_url}/domains") + .with { |req| req.uri.query.nil? }) + end + + describe 'pagination parameters' do + it 'passes page_size parameter' do + stub_request(:get, "#{base_url}/domains") + .with(query: { 'page[size]' => '50' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + domains.list(page_size: 50) + expect(WebMock).to have_requested(:get, "#{base_url}/domains") + .with(query: { 'page[size]' => '50' }) + end + + it 'passes page_cursor parameter' do + stub_request(:get, "#{base_url}/domains") + .with(query: { 'page[cursor]' => 'cursor_abc' }) + .to_return( + status: 200, + body: '{"data":[],"meta":{"next_cursor":"cursor_def"}}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = domains.list(page_cursor: 'cursor_abc') + expect(result['meta']['next_cursor']).to eq('cursor_def') + end + + it 'passes both pagination parameters' do + stub_request(:get, "#{base_url}/domains") + .with(query: { 'page[size]' => '25', 'page[cursor]' => 'xyz789' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + domains.list(page_size: 25, page_cursor: 'xyz789') + expect(WebMock).to have_requested(:get, "#{base_url}/domains") + .with(query: { 'page[size]' => '25', 'page[cursor]' => 'xyz789' }) + end + end + + describe 'sort parameter' do + it 'sorts by domain ascending' do + stub_request(:get, "#{base_url}/domains") + .with(query: { 'sort' => 'domain' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + domains.list(sort: 'domain') + expect(WebMock).to have_requested(:get, "#{base_url}/domains") + .with(query: { 'sort' => 'domain' }) + end + + it 'sorts by created_at descending' do + stub_request(:get, "#{base_url}/domains") + .with(query: { 'sort' => '-created_at' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + domains.list(sort: '-created_at') + expect(WebMock).to have_requested(:get, "#{base_url}/domains") + .with(query: { 'sort' => '-created_at' }) + end + + it 'sorts by status_changed_at' do + stub_request(:get, "#{base_url}/domains") + .with(query: { 'sort' => 'status_changed_at' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + domains.list(sort: 'status_changed_at') + expect(WebMock).to have_requested(:get, "#{base_url}/domains") + .with(query: { 'sort' => 'status_changed_at' }) + end + end + + describe 'filter parameters' do + it 'filters by status' do + stub_request(:get, "#{base_url}/domains") + .with(query: { 'filter[status]' => 'verified' }) + .to_return( + status: 200, + body: '{"data":[{"status":"verified"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = domains.list(status: 'verified') + expect(result['data'].first['status']).to eq('verified') + end + + it 'filters by domain (partial match)' do + stub_request(:get, "#{base_url}/domains") + .with(query: { 'filter[domain]' => 'example' }) + .to_return( + status: 200, + body: '{"data":[{"domain":"example.com"},{"domain":"mail.example.org"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = domains.list(domain: 'example') + expect(result['data'].length).to eq(2) + end + + it 'filters by multiple criteria' do + stub_request(:get, "#{base_url}/domains") + .with(query: { 'filter[status]' => 'partially_verified', 'filter[domain]' => 'test' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + domains.list(status: 'partially_verified', domain: 'test') + expect(WebMock).to have_requested(:get, "#{base_url}/domains") + .with(query: { 'filter[status]' => 'partially_verified', 'filter[domain]' => 'test' }) + end + end + + describe 'combined parameters' do + it 'passes all parameters together' do + stub = stub_request(:get, "#{base_url}/domains") + .with(query: { + 'page[size]' => '10', + 'page[cursor]' => 'abc', + 'sort' => '-created_at', + 'filter[status]' => 'verified' + }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + domains.list(page_size: 10, page_cursor: 'abc', sort: '-created_at', status: 'verified') + expect(stub).to have_been_requested + end + end + end + + describe '#create' do + it 'sends POST request to /domains with domain name' do + stub_request(:post, "#{base_url}/domains") + .with( + body: { domain: 'newdomain.com' }, + headers: { 'Authorization' => "Bearer #{team_token}" } + ) + .to_return( + status: 201, + body: '{"id":"dom_new","domain":"newdomain.com","status":"pending"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = domains.create(domain: 'newdomain.com') + expect(result['id']).to eq('dom_new') + expect(result['domain']).to eq('newdomain.com') + expect(result['status']).to eq('pending') + end + + it 'raises ValidationError for invalid domain' do + stub_request(:post, "#{base_url}/domains") + .with(body: { domain: 'invalid' }) + .to_return( + status: 422, + body: '{"message":"Invalid domain format","error":"validation_error"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { domains.create(domain: 'invalid') }.to raise_error(Lettermint::ValidationError) + end + + it 'raises ValidationError for duplicate domain' do + stub_request(:post, "#{base_url}/domains") + .with(body: { domain: 'existing.com' }) + .to_return( + status: 422, + body: '{"message":"Domain already exists","error":"validation_error"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { domains.create(domain: 'existing.com') } + .to raise_error(Lettermint::ValidationError, /Domain already exists/) + end + end + + describe '#find' do + let(:domain_id) { 'dom_123' } + + it 'sends GET request to /domains/:id' do + stub_request(:get, "#{base_url}/domains/#{domain_id}") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"id":"dom_123","domain":"example.com","status":"verified"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = domains.find(domain_id) + expect(result['id']).to eq('dom_123') + expect(result['domain']).to eq('example.com') + end + + it 'sends GET request without include param when not specified' do + stub = stub_request(:get, "#{base_url}/domains/#{domain_id}") + .to_return( + status: 200, + body: '{"id":"dom_123"}', + headers: { 'Content-Type' => 'application/json' } + ) + + domains.find(domain_id) + expect(stub).to have_been_requested + expect(WebMock).to(have_requested(:get, "#{base_url}/domains/#{domain_id}") + .with { |req| req.uri.query.nil? }) + end + + it 'includes dnsRecords when requested' do + stub_request(:get, "#{base_url}/domains/#{domain_id}?include=dnsRecords") + .to_return( + status: 200, + body: '{"id":"dom_123","dnsRecords":[{"type":"TXT","value":"v=spf1"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = domains.find(domain_id, include: 'dnsRecords') + expect(result['dnsRecords']).to be_an(Array) + expect(result['dnsRecords'].first['type']).to eq('TXT') + end + + it 'includes dnsRecordsCount when requested' do + stub_request(:get, "#{base_url}/domains/#{domain_id}?include=dnsRecordsCount") + .to_return( + status: 200, + body: '{"id":"dom_123","dnsRecordsCount":3}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = domains.find(domain_id, include: 'dnsRecordsCount') + expect(result['dnsRecordsCount']).to eq(3) + end + + it 'includes dnsRecordsExists when requested' do + stub_request(:get, "#{base_url}/domains/#{domain_id}?include=dnsRecordsExists") + .to_return( + status: 200, + body: '{"id":"dom_123","dnsRecordsExists":true}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = domains.find(domain_id, include: 'dnsRecordsExists') + expect(result['dnsRecordsExists']).to eq(true) + end + + it 'raises HttpRequestError for non-existent domain' do + stub_request(:get, "#{base_url}/domains/nonexistent") + .to_return( + status: 404, + body: '{"message":"Domain not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { domains.find('nonexistent') }.to raise_error(Lettermint::HttpRequestError) + end + end + + describe '#delete' do + let(:domain_id) { 'dom_123' } + + it 'sends DELETE request to /domains/:id' do + stub_request(:delete, "#{base_url}/domains/#{domain_id}") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"message":"Domain deleted"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = domains.delete(domain_id) + expect(result['message']).to eq('Domain deleted') + end + + it 'handles 204 No Content response' do + stub_request(:delete, "#{base_url}/domains/#{domain_id}") + .to_return(status: 204, body: '') + + result = domains.delete(domain_id) + expect(result).to eq('') + end + + it 'raises HttpRequestError for non-existent domain' do + stub_request(:delete, "#{base_url}/domains/nonexistent") + .to_return( + status: 404, + body: '{"message":"Domain not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { domains.delete('nonexistent') }.to raise_error(Lettermint::HttpRequestError) + end + end + + describe '#verify_dns' do + let(:domain_id) { 'dom_123' } + + it 'sends POST request to /domains/:id/dns-records/verify' do + stub_request(:post, "#{base_url}/domains/#{domain_id}/dns-records/verify") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"verified":true,"records":[{"type":"TXT","verified":true}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = domains.verify_dns(domain_id) + expect(result['verified']).to eq(true) + expect(result['records'].first['verified']).to eq(true) + end + + it 'returns partial verification results' do + stub_request(:post, "#{base_url}/domains/#{domain_id}/dns-records/verify") + .to_return( + status: 200, + body: '{"verified":false,"records":[{"type":"TXT","verified":true},{"type":"DKIM","verified":false}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = domains.verify_dns(domain_id) + expect(result['verified']).to eq(false) + expect(result['records'].count { |r| r['verified'] }).to eq(1) + end + end + + describe '#verify_dns_record' do + let(:domain_id) { 'dom_123' } + let(:record_id) { 'rec_456' } + + it 'sends POST request to /domains/:domain_id/dns-records/:record_id/verify' do + stub_request(:post, "#{base_url}/domains/#{domain_id}/dns-records/#{record_id}/verify") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"id":"rec_456","type":"TXT","verified":true}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = domains.verify_dns_record(domain_id, record_id) + expect(result['id']).to eq('rec_456') + expect(result['verified']).to eq(true) + end + + it 'returns unverified status when DNS record not found' do + stub_request(:post, "#{base_url}/domains/#{domain_id}/dns-records/#{record_id}/verify") + .to_return( + status: 200, + body: '{"id":"rec_456","type":"DKIM","verified":false,"error":"Record not found in DNS"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = domains.verify_dns_record(domain_id, record_id) + expect(result['verified']).to eq(false) + expect(result['error']).to include('not found') + end + + it 'raises HttpRequestError for non-existent record' do + stub_request(:post, "#{base_url}/domains/#{domain_id}/dns-records/nonexistent/verify") + .to_return( + status: 404, + body: '{"message":"DNS record not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { domains.verify_dns_record(domain_id, 'nonexistent') } + .to raise_error(Lettermint::HttpRequestError) + end + end + + describe '#update_projects' do + let(:domain_id) { 'dom_123' } + let(:project_ids) { %w[proj_1 proj_2 proj_3] } + + it 'sends PUT request to /domains/:id/projects with project_ids' do + stub_request(:put, "#{base_url}/domains/#{domain_id}/projects") + .with( + body: { project_ids: project_ids }, + headers: { 'Authorization' => "Bearer #{team_token}" } + ) + .to_return( + status: 200, + body: '{"id":"dom_123","projects":["proj_1","proj_2","proj_3"]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = domains.update_projects(domain_id, project_ids: project_ids) + expect(result['projects']).to eq(project_ids) + end + + it 'accepts empty project_ids array to remove all associations' do + stub_request(:put, "#{base_url}/domains/#{domain_id}/projects") + .with(body: { project_ids: [] }) + .to_return( + status: 200, + body: '{"id":"dom_123","projects":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = domains.update_projects(domain_id, project_ids: []) + expect(result['projects']).to eq([]) + end + + it 'raises ValidationError for invalid project_id' do + stub_request(:put, "#{base_url}/domains/#{domain_id}/projects") + .with(body: { project_ids: ['invalid_proj'] }) + .to_return( + status: 422, + body: '{"message":"Invalid project ID: invalid_proj","error":"validation_error"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { domains.update_projects(domain_id, project_ids: ['invalid_proj']) } + .to raise_error(Lettermint::ValidationError) + end + end + + describe 'error handling' do + it 'raises AuthenticationError on 401' do + stub_request(:get, "#{base_url}/domains") + .to_return( + status: 401, + body: '{"message":"Invalid token"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { domains.list }.to raise_error(Lettermint::AuthenticationError) + end + + it 'raises RateLimitError on 429' do + stub_request(:get, "#{base_url}/domains") + .to_return( + status: 429, + body: '{"message":"Too many requests"}', + headers: { 'Content-Type' => 'application/json', 'Retry-After' => '30' } + ) + + expect { domains.list }.to raise_error(Lettermint::RateLimitError) { |e| + expect(e.retry_after).to eq(30) + } + end + + it 'raises TimeoutError on timeout' do + stub_request(:get, "#{base_url}/domains").to_raise(Faraday::TimeoutError) + + expect { domains.list }.to raise_error(Lettermint::TimeoutError) + end + end +end diff --git a/spec/lettermint/resources/messages_spec.rb b/spec/lettermint/resources/messages_spec.rb new file mode 100644 index 0000000..4f9f37d --- /dev/null +++ b/spec/lettermint/resources/messages_spec.rb @@ -0,0 +1,552 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Lettermint::Resources::Messages do + let(:team_token) { 'lm_team_test123' } + let(:base_url) { Lettermint::Configuration::DEFAULT_BASE_URL } + let(:api) { Lettermint::TeamAPI.new(team_token: team_token) } + let(:messages) { api.messages } + + describe '#list' do + it 'sends GET request to /messages' do + stub_request(:get, "#{base_url}/messages") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"data":[{"id":"msg_1","subject":"Hello","status":"delivered"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = messages.list + expect(result['data']).to be_an(Array) + expect(result['data'].first['subject']).to eq('Hello') + end + + it 'sends GET request without params when none specified' do + stub = stub_request(:get, "#{base_url}/messages") + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + messages.list + expect(stub).to have_been_requested + expect(WebMock).to(have_requested(:get, "#{base_url}/messages") + .with { |req| req.uri.query.nil? }) + end + + describe 'pagination parameters' do + it 'passes page_size parameter' do + stub_request(:get, "#{base_url}/messages") + .with(query: { 'page[size]' => '50' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + messages.list(page_size: 50) + expect(WebMock).to have_requested(:get, "#{base_url}/messages") + .with(query: { 'page[size]' => '50' }) + end + + it 'passes page_cursor parameter' do + stub_request(:get, "#{base_url}/messages") + .with(query: { 'page[cursor]' => 'cursor_abc' }) + .to_return( + status: 200, + body: '{"data":[],"meta":{"next_cursor":"cursor_def"}}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = messages.list(page_cursor: 'cursor_abc') + expect(result['meta']['next_cursor']).to eq('cursor_def') + end + + it 'passes both pagination parameters' do + stub_request(:get, "#{base_url}/messages") + .with(query: { 'page[size]' => '25', 'page[cursor]' => 'xyz789' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + messages.list(page_size: 25, page_cursor: 'xyz789') + expect(WebMock).to have_requested(:get, "#{base_url}/messages") + .with(query: { 'page[size]' => '25', 'page[cursor]' => 'xyz789' }) + end + end + + describe 'sort parameter' do + it 'sorts by subject ascending' do + stub_request(:get, "#{base_url}/messages") + .with(query: { 'sort' => 'subject' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + messages.list(sort: 'subject') + expect(WebMock).to have_requested(:get, "#{base_url}/messages") + .with(query: { 'sort' => 'subject' }) + end + + it 'sorts by created_at descending' do + stub_request(:get, "#{base_url}/messages") + .with(query: { 'sort' => '-created_at' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + messages.list(sort: '-created_at') + expect(WebMock).to have_requested(:get, "#{base_url}/messages") + .with(query: { 'sort' => '-created_at' }) + end + + it 'sorts by status_changed_at' do + stub_request(:get, "#{base_url}/messages") + .with(query: { 'sort' => 'status_changed_at' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + messages.list(sort: 'status_changed_at') + expect(WebMock).to have_requested(:get, "#{base_url}/messages") + .with(query: { 'sort' => 'status_changed_at' }) + end + end + + describe 'filter parameters' do + it 'filters by type' do + stub_request(:get, "#{base_url}/messages") + .with(query: { 'filter[type]' => 'outbound' }) + .to_return( + status: 200, + body: '{"data":[{"type":"outbound"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = messages.list(type: 'outbound') + expect(result['data'].first['type']).to eq('outbound') + end + + it 'filters by status' do + stub_request(:get, "#{base_url}/messages") + .with(query: { 'filter[status]' => 'delivered' }) + .to_return( + status: 200, + body: '{"data":[{"status":"delivered"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = messages.list(status: 'delivered') + expect(result['data'].first['status']).to eq('delivered') + end + + it 'filters by route_id' do + stub_request(:get, "#{base_url}/messages") + .with(query: { 'filter[route_id]' => 'route_123' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + messages.list(route_id: 'route_123') + expect(WebMock).to have_requested(:get, "#{base_url}/messages") + .with(query: { 'filter[route_id]' => 'route_123' }) + end + + it 'filters by domain_id' do + stub_request(:get, "#{base_url}/messages") + .with(query: { 'filter[domain_id]' => 'dom_456' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + messages.list(domain_id: 'dom_456') + expect(WebMock).to have_requested(:get, "#{base_url}/messages") + .with(query: { 'filter[domain_id]' => 'dom_456' }) + end + + it 'filters by tag' do + stub_request(:get, "#{base_url}/messages") + .with(query: { 'filter[tag]' => 'newsletter' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + messages.list(tag: 'newsletter') + expect(WebMock).to have_requested(:get, "#{base_url}/messages") + .with(query: { 'filter[tag]' => 'newsletter' }) + end + + it 'filters by from_email' do + stub_request(:get, "#{base_url}/messages") + .with(query: { 'filter[from_email]' => 'sender@example.com' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + messages.list(from_email: 'sender@example.com') + expect(WebMock).to have_requested(:get, "#{base_url}/messages") + .with(query: { 'filter[from_email]' => 'sender@example.com' }) + end + + it 'filters by subject' do + stub_request(:get, "#{base_url}/messages") + .with(query: { 'filter[subject]' => 'Welcome' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + messages.list(subject: 'Welcome') + expect(WebMock).to have_requested(:get, "#{base_url}/messages") + .with(query: { 'filter[subject]' => 'Welcome' }) + end + + it 'filters by date range' do + stub_request(:get, "#{base_url}/messages") + .with(query: { 'filter[from_date]' => '2024-01-01', 'filter[to_date]' => '2024-01-31' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + messages.list(from_date: '2024-01-01', to_date: '2024-01-31') + expect(WebMock).to have_requested(:get, "#{base_url}/messages") + .with(query: { 'filter[from_date]' => '2024-01-01', 'filter[to_date]' => '2024-01-31' }) + end + + it 'filters by multiple criteria' do + stub_request(:get, "#{base_url}/messages") + .with(query: { 'filter[type]' => 'inbound', 'filter[status]' => 'received', 'filter[tag]' => 'support' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + messages.list(type: 'inbound', status: 'received', tag: 'support') + expect(WebMock).to have_requested(:get, "#{base_url}/messages") + .with(query: { 'filter[type]' => 'inbound', 'filter[status]' => 'received', 'filter[tag]' => 'support' }) + end + end + + describe 'combined parameters' do + it 'passes all parameters together' do + stub = stub_request(:get, "#{base_url}/messages") + .with(query: { + 'page[size]' => '10', + 'page[cursor]' => 'abc', + 'sort' => '-created_at', + 'filter[type]' => 'outbound', + 'filter[status]' => 'delivered' + }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + messages.list(page_size: 10, page_cursor: 'abc', sort: '-created_at', type: 'outbound', status: 'delivered') + expect(stub).to have_been_requested + end + end + end + + describe '#find' do + let(:message_id) { 'msg_123' } + + it 'sends GET request to /messages/:id' do + stub_request(:get, "#{base_url}/messages/#{message_id}") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"id":"msg_123","subject":"Hello World","from_email":"sender@example.com"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = messages.find(message_id) + expect(result['id']).to eq('msg_123') + expect(result['subject']).to eq('Hello World') + end + + it 'raises HttpRequestError for non-existent message' do + stub_request(:get, "#{base_url}/messages/nonexistent") + .to_return( + status: 404, + body: '{"message":"Message not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { messages.find('nonexistent') }.to raise_error(Lettermint::HttpRequestError) + end + end + + describe '#events' do + let(:message_id) { 'msg_123' } + + it 'sends GET request to /messages/:id/events' do + stub_request(:get, "#{base_url}/messages/#{message_id}/events") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"data":[{"event":"delivered","timestamp":"2024-01-15T10:00:00Z"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = messages.events(message_id) + expect(result['data']).to be_an(Array) + expect(result['data'].first['event']).to eq('delivered') + end + + it 'sends GET request without sort param when not specified' do + stub = stub_request(:get, "#{base_url}/messages/#{message_id}/events") + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + messages.events(message_id) + expect(stub).to have_been_requested + expect(WebMock).to(have_requested(:get, "#{base_url}/messages/#{message_id}/events") + .with { |req| req.uri.query.nil? }) + end + + it 'passes sort parameter' do + stub_request(:get, "#{base_url}/messages/#{message_id}/events") + .with(query: { 'sort' => '-timestamp' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + messages.events(message_id, sort: '-timestamp') + expect(WebMock).to have_requested(:get, "#{base_url}/messages/#{message_id}/events") + .with(query: { 'sort' => '-timestamp' }) + end + + it 'sorts by event type' do + stub_request(:get, "#{base_url}/messages/#{message_id}/events") + .with(query: { 'sort' => 'event' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + messages.events(message_id, sort: 'event') + expect(WebMock).to have_requested(:get, "#{base_url}/messages/#{message_id}/events") + .with(query: { 'sort' => 'event' }) + end + + it 'raises HttpRequestError for non-existent message' do + stub_request(:get, "#{base_url}/messages/nonexistent/events") + .to_return( + status: 404, + body: '{"message":"Message not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { messages.events('nonexistent') }.to raise_error(Lettermint::HttpRequestError) + end + end + + describe '#source' do + let(:message_id) { 'msg_123' } + + it 'sends GET request to /messages/:id/source' do + raw_source = "From: sender@example.com\r\nTo: recipient@example.com\r\nSubject: Hello\r\n\r\nBody content" + stub_request(:get, "#{base_url}/messages/#{message_id}/source") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: raw_source, + headers: { 'Content-Type' => 'message/rfc822' } + ) + + result = messages.source(message_id) + expect(result).to include('From: sender@example.com') + expect(result).to include('Subject: Hello') + end + + it 'returns plain text content' do + stub_request(:get, "#{base_url}/messages/#{message_id}/source") + .to_return( + status: 200, + body: 'Raw RFC822 message content', + headers: { 'Content-Type' => 'message/rfc822' } + ) + + result = messages.source(message_id) + expect(result).to eq('Raw RFC822 message content') + end + + it 'raises HttpRequestError for non-existent message' do + stub_request(:get, "#{base_url}/messages/nonexistent/source") + .to_return( + status: 404, + body: '{"message":"Message not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { messages.source('nonexistent') }.to raise_error(Lettermint::HttpRequestError) + end + end + + describe '#html' do + let(:message_id) { 'msg_123' } + + it 'sends GET request to /messages/:id/html' do + html_content = '

Hello World

' + stub_request(:get, "#{base_url}/messages/#{message_id}/html") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: html_content, + headers: { 'Content-Type' => 'text/html' } + ) + + result = messages.html(message_id) + expect(result).to include('

Hello World

') + end + + it 'returns HTML content as string' do + stub_request(:get, "#{base_url}/messages/#{message_id}/html") + .to_return( + status: 200, + body: '

Email content

', + headers: { 'Content-Type' => 'text/html' } + ) + + result = messages.html(message_id) + expect(result).to eq('

Email content

') + end + + it 'raises HttpRequestError for non-existent message' do + stub_request(:get, "#{base_url}/messages/nonexistent/html") + .to_return( + status: 404, + body: '{"message":"Message not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { messages.html('nonexistent') }.to raise_error(Lettermint::HttpRequestError) + end + + it 'raises HttpRequestError when message has no HTML body' do + stub_request(:get, "#{base_url}/messages/#{message_id}/html") + .to_return( + status: 404, + body: '{"message":"HTML body not available"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { messages.html(message_id) }.to raise_error(Lettermint::HttpRequestError) + end + end + + describe '#text' do + let(:message_id) { 'msg_123' } + + it 'sends GET request to /messages/:id/text' do + text_content = 'Hello World\n\nThis is a plain text email.' + stub_request(:get, "#{base_url}/messages/#{message_id}/text") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: text_content, + headers: { 'Content-Type' => 'text/plain' } + ) + + result = messages.text(message_id) + expect(result).to include('Hello World') + end + + it 'returns plain text content as string' do + stub_request(:get, "#{base_url}/messages/#{message_id}/text") + .to_return( + status: 200, + body: 'Plain text email body', + headers: { 'Content-Type' => 'text/plain' } + ) + + result = messages.text(message_id) + expect(result).to eq('Plain text email body') + end + + it 'raises HttpRequestError for non-existent message' do + stub_request(:get, "#{base_url}/messages/nonexistent/text") + .to_return( + status: 404, + body: '{"message":"Message not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { messages.text('nonexistent') }.to raise_error(Lettermint::HttpRequestError) + end + + it 'raises HttpRequestError when message has no text body' do + stub_request(:get, "#{base_url}/messages/#{message_id}/text") + .to_return( + status: 404, + body: '{"message":"Text body not available"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { messages.text(message_id) }.to raise_error(Lettermint::HttpRequestError) + end + end + + describe 'error handling' do + it 'raises AuthenticationError on 401' do + stub_request(:get, "#{base_url}/messages") + .to_return( + status: 401, + body: '{"message":"Invalid token"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { messages.list }.to raise_error(Lettermint::AuthenticationError) + end + + it 'raises RateLimitError on 429' do + stub_request(:get, "#{base_url}/messages") + .to_return( + status: 429, + body: '{"message":"Too many requests"}', + headers: { 'Content-Type' => 'application/json', 'Retry-After' => '30' } + ) + + expect { messages.list }.to raise_error(Lettermint::RateLimitError) { |e| + expect(e.retry_after).to eq(30) + } + end + + it 'raises TimeoutError on timeout' do + stub_request(:get, "#{base_url}/messages").to_raise(Faraday::TimeoutError) + + expect { messages.list }.to raise_error(Lettermint::TimeoutError) + end + end +end diff --git a/spec/lettermint/resources/projects_spec.rb b/spec/lettermint/resources/projects_spec.rb new file mode 100644 index 0000000..9945b00 --- /dev/null +++ b/spec/lettermint/resources/projects_spec.rb @@ -0,0 +1,515 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Lettermint::Resources::Projects do + let(:team_token) { 'lm_team_test123' } + let(:base_url) { Lettermint::Configuration::DEFAULT_BASE_URL } + let(:api) { Lettermint::TeamAPI.new(team_token: team_token) } + let(:projects) { api.projects } + + describe '#list' do + it 'sends GET request to /projects' do + stub_request(:get, "#{base_url}/projects") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"data":[{"id":"proj_1","name":"My Project"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = projects.list + expect(result['data']).to be_an(Array) + expect(result['data'].first['name']).to eq('My Project') + end + + it 'sends GET request without params when none specified' do + stub = stub_request(:get, "#{base_url}/projects") + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + projects.list + expect(stub).to have_been_requested + expect(WebMock).to(have_requested(:get, "#{base_url}/projects") + .with { |req| req.uri.query.nil? }) + end + + describe 'pagination parameters' do + it 'passes page_size parameter' do + stub_request(:get, "#{base_url}/projects") + .with(query: { 'page[size]' => '50' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + projects.list(page_size: 50) + expect(WebMock).to have_requested(:get, "#{base_url}/projects") + .with(query: { 'page[size]' => '50' }) + end + + it 'passes page_cursor parameter' do + stub_request(:get, "#{base_url}/projects") + .with(query: { 'page[cursor]' => 'cursor_abc' }) + .to_return( + status: 200, + body: '{"data":[],"meta":{"next_cursor":"cursor_def"}}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = projects.list(page_cursor: 'cursor_abc') + expect(result['meta']['next_cursor']).to eq('cursor_def') + end + end + + describe 'sort parameter' do + it 'sorts by name ascending' do + stub_request(:get, "#{base_url}/projects") + .with(query: { 'sort' => 'name' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + projects.list(sort: 'name') + expect(WebMock).to have_requested(:get, "#{base_url}/projects") + .with(query: { 'sort' => 'name' }) + end + + it 'sorts by created_at descending' do + stub_request(:get, "#{base_url}/projects") + .with(query: { 'sort' => '-created_at' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + projects.list(sort: '-created_at') + expect(WebMock).to have_requested(:get, "#{base_url}/projects") + .with(query: { 'sort' => '-created_at' }) + end + end + + describe 'search parameter' do + it 'passes search filter' do + stub_request(:get, "#{base_url}/projects") + .with(query: { 'filter[search]' => 'test' }) + .to_return( + status: 200, + body: '{"data":[{"name":"test project"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + projects.list(search: 'test') + expect(WebMock).to have_requested(:get, "#{base_url}/projects") + .with(query: { 'filter[search]' => 'test' }) + end + end + end + + describe '#create' do + it 'sends POST request to /projects with name' do + stub_request(:post, "#{base_url}/projects") + .with( + body: { name: 'New Project' }, + headers: { 'Authorization' => "Bearer #{team_token}" } + ) + .to_return( + status: 201, + body: '{"id":"proj_new","name":"New Project","api_token":"lm_proj_xyz"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = projects.create(name: 'New Project') + expect(result['id']).to eq('proj_new') + expect(result['name']).to eq('New Project') + expect(result['api_token']).to eq('lm_proj_xyz') + end + + it 'sends POST request with smtp_enabled option' do + stub_request(:post, "#{base_url}/projects") + .with(body: { name: 'SMTP Project', smtp_enabled: true }) + .to_return( + status: 201, + body: '{"id":"proj_smtp","name":"SMTP Project","smtp_enabled":true}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = projects.create(name: 'SMTP Project', smtp_enabled: true) + expect(result['smtp_enabled']).to eq(true) + end + + it 'sends POST request with initial_routes option' do + stub_request(:post, "#{base_url}/projects") + .with(body: { name: 'Routes Project', initial_routes: 'transactional' }) + .to_return( + status: 201, + body: '{"id":"proj_routes","name":"Routes Project"}', + headers: { 'Content-Type' => 'application/json' } + ) + + projects.create(name: 'Routes Project', initial_routes: 'transactional') + expect(WebMock).to have_requested(:post, "#{base_url}/projects") + .with(body: { name: 'Routes Project', initial_routes: 'transactional' }) + end + + it 'raises ValidationError for invalid name' do + stub_request(:post, "#{base_url}/projects") + .with(body: { name: '' }) + .to_return( + status: 422, + body: '{"message":"Name is required","error":"validation_error"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { projects.create(name: '') }.to raise_error(Lettermint::ValidationError) + end + end + + describe '#find' do + let(:project_id) { 'proj_123' } + + it 'sends GET request to /projects/:id' do + stub_request(:get, "#{base_url}/projects/#{project_id}") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"id":"proj_123","name":"My Project"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = projects.find(project_id) + expect(result['id']).to eq('proj_123') + expect(result['name']).to eq('My Project') + end + + it 'sends GET request without include param when not specified' do + stub = stub_request(:get, "#{base_url}/projects/#{project_id}") + .to_return( + status: 200, + body: '{"id":"proj_123"}', + headers: { 'Content-Type' => 'application/json' } + ) + + projects.find(project_id) + expect(stub).to have_been_requested + expect(WebMock).to(have_requested(:get, "#{base_url}/projects/#{project_id}") + .with { |req| req.uri.query.nil? }) + end + + it 'includes routes when requested' do + stub_request(:get, "#{base_url}/projects/#{project_id}?include=routes") + .to_return( + status: 200, + body: '{"id":"proj_123","routes":[{"id":"route_1","name":"Default"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = projects.find(project_id, include: 'routes') + expect(result['routes']).to be_an(Array) + expect(result['routes'].first['name']).to eq('Default') + end + + it 'includes teamMembers when requested' do + stub_request(:get, "#{base_url}/projects/#{project_id}?include=teamMembers") + .to_return( + status: 200, + body: '{"id":"proj_123","teamMembers":[{"id":"mem_1","email":"test@example.com"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = projects.find(project_id, include: 'teamMembers') + expect(result['teamMembers']).to be_an(Array) + end + + it 'raises HttpRequestError for non-existent project' do + stub_request(:get, "#{base_url}/projects/nonexistent") + .to_return( + status: 404, + body: '{"message":"Project not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { projects.find('nonexistent') }.to raise_error(Lettermint::HttpRequestError) + end + end + + describe '#update' do + let(:project_id) { 'proj_123' } + + it 'sends PUT request to /projects/:id with name' do + stub_request(:put, "#{base_url}/projects/#{project_id}") + .with( + body: { name: 'Updated Name' }, + headers: { 'Authorization' => "Bearer #{team_token}" } + ) + .to_return( + status: 200, + body: '{"id":"proj_123","name":"Updated Name"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = projects.update(project_id, name: 'Updated Name') + expect(result['name']).to eq('Updated Name') + end + + it 'sends PUT request with smtp_enabled' do + stub_request(:put, "#{base_url}/projects/#{project_id}") + .with(body: { smtp_enabled: true }) + .to_return( + status: 200, + body: '{"id":"proj_123","smtp_enabled":true}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = projects.update(project_id, smtp_enabled: true) + expect(result['smtp_enabled']).to eq(true) + end + + it 'sends PUT request with default_route_id' do + stub_request(:put, "#{base_url}/projects/#{project_id}") + .with(body: { default_route_id: 'route_456' }) + .to_return( + status: 200, + body: '{"id":"proj_123","default_route_id":"route_456"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = projects.update(project_id, default_route_id: 'route_456') + expect(result['default_route_id']).to eq('route_456') + end + end + + describe '#delete' do + let(:project_id) { 'proj_123' } + + it 'sends DELETE request to /projects/:id' do + stub_request(:delete, "#{base_url}/projects/#{project_id}") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"message":"Project deleted"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = projects.delete(project_id) + expect(result['message']).to eq('Project deleted') + end + + it 'handles 204 No Content response' do + stub_request(:delete, "#{base_url}/projects/#{project_id}") + .to_return(status: 204, body: '') + + result = projects.delete(project_id) + expect(result).to eq('') + end + + it 'raises HttpRequestError for non-existent project' do + stub_request(:delete, "#{base_url}/projects/nonexistent") + .to_return( + status: 404, + body: '{"message":"Project not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { projects.delete('nonexistent') }.to raise_error(Lettermint::HttpRequestError) + end + end + + describe '#rotate_token' do + let(:project_id) { 'proj_123' } + + it 'sends POST request to /projects/:id/rotate-token' do + stub_request(:post, "#{base_url}/projects/#{project_id}/rotate-token") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"new_token":"lm_proj_newtoken123"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = projects.rotate_token(project_id) + expect(result['new_token']).to eq('lm_proj_newtoken123') + end + + it 'raises HttpRequestError for non-existent project' do + stub_request(:post, "#{base_url}/projects/nonexistent/rotate-token") + .to_return( + status: 404, + body: '{"message":"Project not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { projects.rotate_token('nonexistent') }.to raise_error(Lettermint::HttpRequestError) + end + end + + describe '#update_members' do + let(:project_id) { 'proj_123' } + let(:team_member_ids) { %w[mem_1 mem_2 mem_3] } + + it 'sends PUT request to /projects/:id/members with team_member_ids' do + stub_request(:put, "#{base_url}/projects/#{project_id}/members") + .with( + body: { team_member_ids: team_member_ids }, + headers: { 'Authorization' => "Bearer #{team_token}" } + ) + .to_return( + status: 200, + body: '{"message":"Members updated"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = projects.update_members(project_id, team_member_ids: team_member_ids) + expect(result['message']).to eq('Members updated') + end + + it 'accepts empty team_member_ids array to remove all members' do + stub_request(:put, "#{base_url}/projects/#{project_id}/members") + .with(body: { team_member_ids: [] }) + .to_return( + status: 200, + body: '{"message":"Members updated"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = projects.update_members(project_id, team_member_ids: []) + expect(result['message']).to eq('Members updated') + end + end + + describe '#add_member' do + let(:project_id) { 'proj_123' } + let(:member_id) { 'mem_456' } + + it 'sends POST request to /projects/:project_id/members/:member_id' do + stub_request(:post, "#{base_url}/projects/#{project_id}/members/#{member_id}") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"message":"Member added"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = projects.add_member(project_id, member_id) + expect(result['message']).to eq('Member added') + end + + it 'raises HttpRequestError for non-existent member' do + stub_request(:post, "#{base_url}/projects/#{project_id}/members/nonexistent") + .to_return( + status: 404, + body: '{"message":"Member not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { projects.add_member(project_id, 'nonexistent') } + .to raise_error(Lettermint::HttpRequestError) + end + end + + describe '#remove_member' do + let(:project_id) { 'proj_123' } + let(:member_id) { 'mem_456' } + + it 'sends DELETE request to /projects/:project_id/members/:member_id' do + stub_request(:delete, "#{base_url}/projects/#{project_id}/members/#{member_id}") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"message":"Member removed"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = projects.remove_member(project_id, member_id) + expect(result['message']).to eq('Member removed') + end + + it 'handles 204 No Content response' do + stub_request(:delete, "#{base_url}/projects/#{project_id}/members/#{member_id}") + .to_return(status: 204, body: '') + + result = projects.remove_member(project_id, member_id) + expect(result).to eq('') + end + end + + describe '#routes' do + let(:project_id) { 'proj_123' } + + it 'returns a Routes resource scoped to the project' do + routes = projects.routes(project_id) + expect(routes).to be_a(Lettermint::Resources::Routes) + end + + it 'returns a Routes resource that can list project routes' do + stub_request(:get, "#{base_url}/projects/#{project_id}/routes") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"data":[{"id":"route_1","name":"Transactional"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + routes = projects.routes(project_id) + result = routes.list + expect(result['data']).to be_an(Array) + expect(result['data'].first['name']).to eq('Transactional') + end + + it 'returns a Routes resource that can create project routes' do + stub_request(:post, "#{base_url}/projects/#{project_id}/routes") + .with( + body: { name: 'New Route', route_type: 'transactional' }, + headers: { 'Authorization' => "Bearer #{team_token}" } + ) + .to_return( + status: 201, + body: '{"id":"route_new","name":"New Route","route_type":"transactional"}', + headers: { 'Content-Type' => 'application/json' } + ) + + routes = projects.routes(project_id) + result = routes.create(name: 'New Route', route_type: 'transactional') + expect(result['id']).to eq('route_new') + end + end + + describe 'error handling' do + it 'raises AuthenticationError on 401' do + stub_request(:get, "#{base_url}/projects") + .to_return( + status: 401, + body: '{"message":"Invalid token"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { projects.list }.to raise_error(Lettermint::AuthenticationError) + end + + it 'raises RateLimitError on 429' do + stub_request(:get, "#{base_url}/projects") + .to_return( + status: 429, + body: '{"message":"Too many requests"}', + headers: { 'Content-Type' => 'application/json', 'Retry-After' => '30' } + ) + + expect { projects.list }.to raise_error(Lettermint::RateLimitError) { |e| + expect(e.retry_after).to eq(30) + } + end + + it 'raises TimeoutError on timeout' do + stub_request(:get, "#{base_url}/projects").to_raise(Faraday::TimeoutError) + + expect { projects.list }.to raise_error(Lettermint::TimeoutError) + end + end +end diff --git a/spec/lettermint/resources/routes_spec.rb b/spec/lettermint/resources/routes_spec.rb new file mode 100644 index 0000000..5fe01c3 --- /dev/null +++ b/spec/lettermint/resources/routes_spec.rb @@ -0,0 +1,450 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Lettermint::Resources::Routes do + let(:team_token) { 'lm_team_test123' } + let(:base_url) { Lettermint::Configuration::DEFAULT_BASE_URL } + let(:api) { Lettermint::TeamAPI.new(team_token: team_token) } + let(:project_id) { 'proj_123' } + + describe 'project-scoped operations' do + let(:routes) { api.projects.routes(project_id) } + + describe '#list' do + it 'sends GET request to /projects/:project_id/routes' do + stub_request(:get, "#{base_url}/projects/#{project_id}/routes") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"data":[{"id":"route_1","name":"Transactional","route_type":"transactional"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = routes.list + expect(result['data']).to be_an(Array) + expect(result['data'].first['name']).to eq('Transactional') + end + + it 'sends GET request without params when none specified' do + stub = stub_request(:get, "#{base_url}/projects/#{project_id}/routes") + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + routes.list + expect(stub).to have_been_requested + expect(WebMock).to(have_requested(:get, "#{base_url}/projects/#{project_id}/routes") + .with { |req| req.uri.query.nil? }) + end + + describe 'pagination parameters' do + it 'passes page_size parameter' do + stub_request(:get, "#{base_url}/projects/#{project_id}/routes") + .with(query: { 'page[size]' => '50' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + routes.list(page_size: 50) + expect(WebMock).to have_requested(:get, "#{base_url}/projects/#{project_id}/routes") + .with(query: { 'page[size]' => '50' }) + end + + it 'passes page_cursor parameter' do + stub_request(:get, "#{base_url}/projects/#{project_id}/routes") + .with(query: { 'page[cursor]' => 'cursor_abc' }) + .to_return( + status: 200, + body: '{"data":[],"meta":{"next_cursor":"cursor_def"}}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = routes.list(page_cursor: 'cursor_abc') + expect(result['meta']['next_cursor']).to eq('cursor_def') + end + end + + describe 'sort parameter' do + it 'sorts by name ascending' do + stub_request(:get, "#{base_url}/projects/#{project_id}/routes") + .with(query: { 'sort' => 'name' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + routes.list(sort: 'name') + expect(WebMock).to have_requested(:get, "#{base_url}/projects/#{project_id}/routes") + .with(query: { 'sort' => 'name' }) + end + + it 'sorts by created_at descending' do + stub_request(:get, "#{base_url}/projects/#{project_id}/routes") + .with(query: { 'sort' => '-created_at' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + routes.list(sort: '-created_at') + expect(WebMock).to have_requested(:get, "#{base_url}/projects/#{project_id}/routes") + .with(query: { 'sort' => '-created_at' }) + end + end + + describe 'filter parameters' do + it 'filters by route_type' do + stub_request(:get, "#{base_url}/projects/#{project_id}/routes") + .with(query: { 'filter[route_type]' => 'transactional' }) + .to_return( + status: 200, + body: '{"data":[{"route_type":"transactional"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = routes.list(route_type: 'transactional') + expect(result['data'].first['route_type']).to eq('transactional') + end + + it 'filters by is_default' do + stub_request(:get, "#{base_url}/projects/#{project_id}/routes") + .with(query: { 'filter[is_default]' => 'true' }) + .to_return( + status: 200, + body: '{"data":[{"is_default":true}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + routes.list(is_default: true) + expect(WebMock).to have_requested(:get, "#{base_url}/projects/#{project_id}/routes") + .with(query: { 'filter[is_default]' => 'true' }) + end + + it 'filters by search' do + stub_request(:get, "#{base_url}/projects/#{project_id}/routes") + .with(query: { 'filter[search]' => 'marketing' }) + .to_return( + status: 200, + body: '{"data":[{"name":"Marketing Route"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + routes.list(search: 'marketing') + expect(WebMock).to have_requested(:get, "#{base_url}/projects/#{project_id}/routes") + .with(query: { 'filter[search]' => 'marketing' }) + end + end + end + + describe '#create' do + it 'sends POST request to /projects/:project_id/routes' do + stub_request(:post, "#{base_url}/projects/#{project_id}/routes") + .with( + body: { name: 'New Route', route_type: 'transactional' }, + headers: { 'Authorization' => "Bearer #{team_token}" } + ) + .to_return( + status: 201, + body: '{"id":"route_new","name":"New Route","route_type":"transactional"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = routes.create(name: 'New Route', route_type: 'transactional') + expect(result['id']).to eq('route_new') + expect(result['name']).to eq('New Route') + expect(result['route_type']).to eq('transactional') + end + + it 'sends POST request with optional slug' do + stub_request(:post, "#{base_url}/projects/#{project_id}/routes") + .with(body: { name: 'Marketing', route_type: 'broadcast', slug: 'marketing-emails' }) + .to_return( + status: 201, + body: '{"id":"route_mkt","name":"Marketing","slug":"marketing-emails"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = routes.create(name: 'Marketing', route_type: 'broadcast', slug: 'marketing-emails') + expect(result['slug']).to eq('marketing-emails') + end + + it 'raises ValidationError for invalid route_type' do + stub_request(:post, "#{base_url}/projects/#{project_id}/routes") + .with(body: { name: 'Invalid', route_type: 'invalid' }) + .to_return( + status: 422, + body: '{"message":"Invalid route_type","error":"validation_error"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { routes.create(name: 'Invalid', route_type: 'invalid') } + .to raise_error(Lettermint::ValidationError) + end + end + end + + describe 'project_id required guard' do + let(:routes_without_project) { api.routes } + + describe '#list' do + it 'raises ArgumentError when project_id is not set' do + expect { routes_without_project.list } + .to raise_error(ArgumentError, 'project_id required for listing routes') + end + end + + describe '#create' do + it 'raises ArgumentError when project_id is not set' do + expect { routes_without_project.create(name: 'Test', route_type: 'transactional') } + .to raise_error(ArgumentError, 'project_id required for creating routes') + end + end + end + + describe 'direct route operations' do + let(:routes) { api.routes } + let(:route_id) { 'route_456' } + + describe '#find' do + it 'sends GET request to /routes/:id' do + stub_request(:get, "#{base_url}/routes/#{route_id}") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"id":"route_456","name":"My Route","route_type":"transactional"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = routes.find(route_id) + expect(result['id']).to eq('route_456') + expect(result['name']).to eq('My Route') + end + + it 'sends GET request without include param when not specified' do + stub = stub_request(:get, "#{base_url}/routes/#{route_id}") + .to_return( + status: 200, + body: '{"id":"route_456"}', + headers: { 'Content-Type' => 'application/json' } + ) + + routes.find(route_id) + expect(stub).to have_been_requested + expect(WebMock).to(have_requested(:get, "#{base_url}/routes/#{route_id}") + .with { |req| req.uri.query.nil? }) + end + + it 'includes project when requested' do + stub_request(:get, "#{base_url}/routes/#{route_id}?include=project") + .to_return( + status: 200, + body: '{"id":"route_456","project":{"id":"proj_123","name":"My Project"}}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = routes.find(route_id, include: 'project') + expect(result['project']).to be_a(Hash) + expect(result['project']['id']).to eq('proj_123') + end + + it 'includes statistics when requested' do + stub_request(:get, "#{base_url}/routes/#{route_id}?include=statistics") + .to_return( + status: 200, + body: '{"id":"route_456","statistics":{"sent":100,"delivered":95}}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = routes.find(route_id, include: 'statistics') + expect(result['statistics']['sent']).to eq(100) + end + + it 'raises HttpRequestError for non-existent route' do + stub_request(:get, "#{base_url}/routes/nonexistent") + .to_return( + status: 404, + body: '{"message":"Route not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { routes.find('nonexistent') }.to raise_error(Lettermint::HttpRequestError) + end + end + + describe '#update' do + it 'sends PUT request to /routes/:id with name' do + stub_request(:put, "#{base_url}/routes/#{route_id}") + .with( + body: { name: 'Updated Route' }, + headers: { 'Authorization' => "Bearer #{team_token}" } + ) + .to_return( + status: 200, + body: '{"id":"route_456","name":"Updated Route"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = routes.update(route_id, name: 'Updated Route') + expect(result['name']).to eq('Updated Route') + end + + it 'sends PUT request with settings' do + settings = { track_opens: true, track_clicks: false } + stub_request(:put, "#{base_url}/routes/#{route_id}") + .with(body: { settings: settings }) + .to_return( + status: 200, + body: '{"id":"route_456","settings":{"track_opens":true,"track_clicks":false}}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = routes.update(route_id, settings: settings) + expect(result['settings']['track_opens']).to eq(true) + expect(result['settings']['track_clicks']).to eq(false) + end + + it 'sends PUT request with inbound_settings' do + inbound_settings = { inbound_domain: 'inbound.example.com', spam_threshold: 5 } + stub_request(:put, "#{base_url}/routes/#{route_id}") + .with(body: { inbound_settings: inbound_settings }) + .to_return( + status: 200, + body: '{"id":"route_456","inbound_settings":{"inbound_domain":"inbound.example.com"}}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = routes.update(route_id, inbound_settings: inbound_settings) + expect(result['inbound_settings']['inbound_domain']).to eq('inbound.example.com') + end + + it 'raises HttpRequestError for non-existent route' do + stub_request(:put, "#{base_url}/routes/nonexistent") + .with(body: { name: 'Test' }) + .to_return( + status: 404, + body: '{"message":"Route not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { routes.update('nonexistent', name: 'Test') } + .to raise_error(Lettermint::HttpRequestError) + end + end + + describe '#delete' do + it 'sends DELETE request to /routes/:id' do + stub_request(:delete, "#{base_url}/routes/#{route_id}") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"message":"Route deleted"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = routes.delete(route_id) + expect(result['message']).to eq('Route deleted') + end + + it 'handles 204 No Content response' do + stub_request(:delete, "#{base_url}/routes/#{route_id}") + .to_return(status: 204, body: '') + + result = routes.delete(route_id) + expect(result).to eq('') + end + + it 'raises HttpRequestError for non-existent route' do + stub_request(:delete, "#{base_url}/routes/nonexistent") + .to_return( + status: 404, + body: '{"message":"Route not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { routes.delete('nonexistent') }.to raise_error(Lettermint::HttpRequestError) + end + end + + describe '#verify_inbound_domain' do + it 'sends POST request to /routes/:id/verify-inbound-domain' do + stub_request(:post, "#{base_url}/routes/#{route_id}/verify-inbound-domain") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"verified":true,"domain":"inbound.example.com"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = routes.verify_inbound_domain(route_id) + expect(result['verified']).to eq(true) + expect(result['domain']).to eq('inbound.example.com') + end + + it 'returns unverified status when domain is not configured' do + stub_request(:post, "#{base_url}/routes/#{route_id}/verify-inbound-domain") + .to_return( + status: 200, + body: '{"verified":false,"error":"DNS records not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = routes.verify_inbound_domain(route_id) + expect(result['verified']).to eq(false) + expect(result['error']).to include('DNS records not found') + end + + it 'raises HttpRequestError for non-existent route' do + stub_request(:post, "#{base_url}/routes/nonexistent/verify-inbound-domain") + .to_return( + status: 404, + body: '{"message":"Route not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { routes.verify_inbound_domain('nonexistent') } + .to raise_error(Lettermint::HttpRequestError) + end + end + end + + describe 'error handling' do + let(:routes) { api.projects.routes(project_id) } + + it 'raises AuthenticationError on 401' do + stub_request(:get, "#{base_url}/projects/#{project_id}/routes") + .to_return( + status: 401, + body: '{"message":"Invalid token"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { routes.list }.to raise_error(Lettermint::AuthenticationError) + end + + it 'raises RateLimitError on 429' do + stub_request(:get, "#{base_url}/projects/#{project_id}/routes") + .to_return( + status: 429, + body: '{"message":"Too many requests"}', + headers: { 'Content-Type' => 'application/json', 'Retry-After' => '30' } + ) + + expect { routes.list }.to raise_error(Lettermint::RateLimitError) { |e| + expect(e.retry_after).to eq(30) + } + end + + it 'raises TimeoutError on timeout' do + stub_request(:get, "#{base_url}/projects/#{project_id}/routes").to_raise(Faraday::TimeoutError) + + expect { routes.list }.to raise_error(Lettermint::TimeoutError) + end + end +end diff --git a/spec/lettermint/resources/stats_spec.rb b/spec/lettermint/resources/stats_spec.rb new file mode 100644 index 0000000..69ea887 --- /dev/null +++ b/spec/lettermint/resources/stats_spec.rb @@ -0,0 +1,245 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Lettermint::Resources::Stats do + let(:team_token) { 'lm_team_test123' } + let(:base_url) { Lettermint::Configuration::DEFAULT_BASE_URL } + let(:api) { Lettermint::TeamAPI.new(team_token: team_token) } + let(:stats) { api.stats } + + describe '#get' do + let(:from_date) { '2024-01-01' } + let(:to_date) { '2024-01-31' } + + it 'sends GET request to /stats with required date params' do + stub_request(:get, "#{base_url}/stats") + .with( + query: { 'from' => from_date, 'to' => to_date }, + headers: { 'Authorization' => "Bearer #{team_token}" } + ) + .to_return( + status: 200, + body: '{"totals":{"sent":1000,"delivered":950},"daily":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = stats.get(from: from_date, to: to_date) + expect(result['totals']['sent']).to eq(1000) + expect(result['totals']['delivered']).to eq(950) + end + + it 'returns daily breakdown data' do + stub_request(:get, "#{base_url}/stats") + .with(query: { 'from' => from_date, 'to' => to_date }) + .to_return( + status: 200, + body: '{"totals":{},"daily":[{"date":"2024-01-01","sent":100},{"date":"2024-01-02","sent":150}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = stats.get(from: from_date, to: to_date) + expect(result['daily']).to be_an(Array) + expect(result['daily'].length).to eq(2) + expect(result['daily'].first['date']).to eq('2024-01-01') + end + + describe 'optional project_id parameter' do + it 'includes project_id when specified' do + stub_request(:get, "#{base_url}/stats") + .with(query: { 'from' => from_date, 'to' => to_date, 'project_id' => 'proj_123' }) + .to_return( + status: 200, + body: '{"totals":{"sent":500},"daily":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + stats.get(from: from_date, to: to_date, project_id: 'proj_123') + expect(WebMock).to have_requested(:get, "#{base_url}/stats") + .with(query: { 'from' => from_date, 'to' => to_date, 'project_id' => 'proj_123' }) + end + + it 'excludes project_id when nil' do + stub = stub_request(:get, "#{base_url}/stats") + .with(query: { 'from' => from_date, 'to' => to_date }) + .to_return( + status: 200, + body: '{"totals":{},"daily":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + stats.get(from: from_date, to: to_date, project_id: nil) + expect(stub).to have_been_requested + expect(WebMock).not_to have_requested(:get, "#{base_url}/stats") + .with(query: hash_including('project_id')) + end + end + + describe 'route filtering parameters' do + it 'includes route_id when specified' do + stub_request(:get, "#{base_url}/stats") + .with(query: { 'from' => from_date, 'to' => to_date, 'route_id' => 'route_456' }) + .to_return( + status: 200, + body: '{"totals":{"sent":200},"daily":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + stats.get(from: from_date, to: to_date, route_id: 'route_456') + expect(WebMock).to have_requested(:get, "#{base_url}/stats") + .with(query: { 'from' => from_date, 'to' => to_date, 'route_id' => 'route_456' }) + end + + it 'includes route_ids array when specified' do + url = "#{base_url}/stats?from=#{from_date}&route_ids%5B%5D=route_1&route_ids%5B%5D=route_2&to=#{to_date}" + stub_request(:get, url) + .to_return( + status: 200, + body: '{"totals":{"sent":400},"daily":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = stats.get(from: from_date, to: to_date, route_ids: %w[route_1 route_2]) + expect(result['totals']['sent']).to eq(400) + end + + it 'excludes route_id when nil' do + stub = stub_request(:get, "#{base_url}/stats") + .with(query: { 'from' => from_date, 'to' => to_date }) + .to_return( + status: 200, + body: '{"totals":{},"daily":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + stats.get(from: from_date, to: to_date, route_id: nil) + expect(stub).to have_been_requested + end + end + + describe 'date validation' do + it 'accepts dates at maximum range (90 days)' do + stub_request(:get, "#{base_url}/stats") + .with(query: { 'from' => '2024-01-01', 'to' => '2024-03-31' }) + .to_return( + status: 200, + body: '{"totals":{},"daily":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = stats.get(from: '2024-01-01', to: '2024-03-31') + expect(result).to be_a(Hash) + end + + it 'raises ValidationError when date range exceeds 90 days' do + stub_request(:get, "#{base_url}/stats") + .with(query: { 'from' => '2024-01-01', 'to' => '2024-06-01' }) + .to_return( + status: 422, + body: '{"message":"Date range cannot exceed 90 days","error":"validation_error"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { stats.get(from: '2024-01-01', to: '2024-06-01') } + .to raise_error(Lettermint::ValidationError, /90 days/) + end + + it 'raises ValidationError for invalid date format' do + stub_request(:get, "#{base_url}/stats") + .with(query: { 'from' => 'invalid', 'to' => to_date }) + .to_return( + status: 422, + body: '{"message":"Invalid date format for from","error":"validation_error"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { stats.get(from: 'invalid', to: to_date) } + .to raise_error(Lettermint::ValidationError, /Invalid date format/) + end + + it 'raises ValidationError when from is after to' do + stub_request(:get, "#{base_url}/stats") + .with(query: { 'from' => '2024-02-01', 'to' => '2024-01-01' }) + .to_return( + status: 422, + body: '{"message":"from date must be before to date","error":"validation_error"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { stats.get(from: '2024-02-01', to: '2024-01-01') } + .to raise_error(Lettermint::ValidationError) + end + end + + describe 'combined parameters' do + it 'passes all parameters together' do + stub = stub_request(:get, "#{base_url}/stats") + .with(query: { + 'from' => from_date, + 'to' => to_date, + 'project_id' => 'proj_789' + }) + .to_return( + status: 200, + body: '{"totals":{"sent":100,"delivered":95,"bounced":5},"daily":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = stats.get(from: from_date, to: to_date, project_id: 'proj_789') + expect(stub).to have_been_requested + expect(result['totals']['bounced']).to eq(5) + end + end + end + + describe 'error handling' do + let(:from_date) { '2024-01-01' } + let(:to_date) { '2024-01-31' } + + it 'raises AuthenticationError on 401' do + stub_request(:get, "#{base_url}/stats") + .with(query: { 'from' => from_date, 'to' => to_date }) + .to_return( + status: 401, + body: '{"message":"Invalid token"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { stats.get(from: from_date, to: to_date) }.to raise_error(Lettermint::AuthenticationError) + end + + it 'raises RateLimitError on 429' do + stub_request(:get, "#{base_url}/stats") + .with(query: { 'from' => from_date, 'to' => to_date }) + .to_return( + status: 429, + body: '{"message":"Too many requests"}', + headers: { 'Content-Type' => 'application/json', 'Retry-After' => '60' } + ) + + expect { stats.get(from: from_date, to: to_date) }.to raise_error(Lettermint::RateLimitError) { |e| + expect(e.retry_after).to eq(60) + } + end + + it 'raises TimeoutError on timeout' do + stub_request(:get, "#{base_url}/stats") + .with(query: { 'from' => from_date, 'to' => to_date }) + .to_raise(Faraday::TimeoutError) + + expect { stats.get(from: from_date, to: to_date) }.to raise_error(Lettermint::TimeoutError) + end + + it 'raises HttpRequestError on server error' do + stub_request(:get, "#{base_url}/stats") + .with(query: { 'from' => from_date, 'to' => to_date }) + .to_return( + status: 500, + body: '{"message":"Internal server error"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { stats.get(from: from_date, to: to_date) }.to raise_error(Lettermint::HttpRequestError) + end + end +end diff --git a/spec/lettermint/resources/suppressions_spec.rb b/spec/lettermint/resources/suppressions_spec.rb new file mode 100644 index 0000000..c9cb38a --- /dev/null +++ b/spec/lettermint/resources/suppressions_spec.rb @@ -0,0 +1,389 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Lettermint::Resources::Suppressions do + let(:team_token) { 'lm_team_test123' } + let(:base_url) { Lettermint::Configuration::DEFAULT_BASE_URL } + let(:api) { Lettermint::TeamAPI.new(team_token: team_token) } + let(:suppressions) { api.suppressions } + + describe '#list' do + it 'sends GET request to /suppressions' do + stub_request(:get, "#{base_url}/suppressions") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"data":[{"id":"sup_1","value":"spam@example.com","reason":"spam_complaint"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = suppressions.list + expect(result['data']).to be_an(Array) + expect(result['data'].first['value']).to eq('spam@example.com') + end + + it 'sends GET request without params when none specified' do + stub = stub_request(:get, "#{base_url}/suppressions") + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + suppressions.list + expect(stub).to have_been_requested + expect(WebMock).to(have_requested(:get, "#{base_url}/suppressions") + .with { |req| req.uri.query.nil? }) + end + + describe 'pagination parameters' do + it 'passes page_size parameter' do + stub_request(:get, "#{base_url}/suppressions") + .with(query: { 'page[size]' => '50' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + suppressions.list(page_size: 50) + expect(WebMock).to have_requested(:get, "#{base_url}/suppressions") + .with(query: { 'page[size]' => '50' }) + end + + it 'passes page_cursor parameter' do + stub_request(:get, "#{base_url}/suppressions") + .with(query: { 'page[cursor]' => 'cursor_abc' }) + .to_return( + status: 200, + body: '{"data":[],"meta":{"next_cursor":"cursor_def"}}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = suppressions.list(page_cursor: 'cursor_abc') + expect(result['meta']['next_cursor']).to eq('cursor_def') + end + + it 'passes both pagination parameters' do + stub_request(:get, "#{base_url}/suppressions") + .with(query: { 'page[size]' => '25', 'page[cursor]' => 'xyz789' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + suppressions.list(page_size: 25, page_cursor: 'xyz789') + expect(WebMock).to have_requested(:get, "#{base_url}/suppressions") + .with(query: { 'page[size]' => '25', 'page[cursor]' => 'xyz789' }) + end + end + + describe 'sort parameter' do + it 'sorts by value ascending' do + stub_request(:get, "#{base_url}/suppressions") + .with(query: { 'sort' => 'value' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + suppressions.list(sort: 'value') + expect(WebMock).to have_requested(:get, "#{base_url}/suppressions") + .with(query: { 'sort' => 'value' }) + end + + it 'sorts by created_at descending' do + stub_request(:get, "#{base_url}/suppressions") + .with(query: { 'sort' => '-created_at' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + suppressions.list(sort: '-created_at') + expect(WebMock).to have_requested(:get, "#{base_url}/suppressions") + .with(query: { 'sort' => '-created_at' }) + end + + it 'sorts by reason' do + stub_request(:get, "#{base_url}/suppressions") + .with(query: { 'sort' => 'reason' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + suppressions.list(sort: 'reason') + expect(WebMock).to have_requested(:get, "#{base_url}/suppressions") + .with(query: { 'sort' => 'reason' }) + end + end + + describe 'filter parameters' do + it 'filters by scope' do + stub_request(:get, "#{base_url}/suppressions") + .with(query: { 'filter[scope]' => 'team' }) + .to_return( + status: 200, + body: '{"data":[{"scope":"team"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = suppressions.list(scope: 'team') + expect(result['data'].first['scope']).to eq('team') + end + + it 'filters by route_id' do + stub_request(:get, "#{base_url}/suppressions") + .with(query: { 'filter[route_id]' => 'route_123' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + suppressions.list(route_id: 'route_123') + expect(WebMock).to have_requested(:get, "#{base_url}/suppressions") + .with(query: { 'filter[route_id]' => 'route_123' }) + end + + it 'filters by project_id' do + stub_request(:get, "#{base_url}/suppressions") + .with(query: { 'filter[project_id]' => 'proj_456' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + suppressions.list(project_id: 'proj_456') + expect(WebMock).to have_requested(:get, "#{base_url}/suppressions") + .with(query: { 'filter[project_id]' => 'proj_456' }) + end + + it 'filters by value' do + stub_request(:get, "#{base_url}/suppressions") + .with(query: { 'filter[value]' => 'spam@example.com' }) + .to_return( + status: 200, + body: '{"data":[{"value":"spam@example.com"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = suppressions.list(value: 'spam@example.com') + expect(result['data'].first['value']).to eq('spam@example.com') + end + + it 'filters by reason' do + stub_request(:get, "#{base_url}/suppressions") + .with(query: { 'filter[reason]' => 'hard_bounce' }) + .to_return( + status: 200, + body: '{"data":[{"reason":"hard_bounce"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = suppressions.list(reason: 'hard_bounce') + expect(result['data'].first['reason']).to eq('hard_bounce') + end + + it 'filters by multiple criteria' do + stub_request(:get, "#{base_url}/suppressions") + .with(query: { 'filter[scope]' => 'project', 'filter[reason]' => 'spam_complaint' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + suppressions.list(scope: 'project', reason: 'spam_complaint') + expect(WebMock).to have_requested(:get, "#{base_url}/suppressions") + .with(query: { 'filter[scope]' => 'project', 'filter[reason]' => 'spam_complaint' }) + end + end + + describe 'combined parameters' do + it 'passes all parameters together' do + stub = stub_request(:get, "#{base_url}/suppressions") + .with(query: { + 'page[size]' => '10', + 'page[cursor]' => 'abc', + 'sort' => '-created_at', + 'filter[scope]' => 'team', + 'filter[reason]' => 'manual' + }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + suppressions.list(page_size: 10, page_cursor: 'abc', sort: '-created_at', scope: 'team', reason: 'manual') + expect(stub).to have_been_requested + end + end + end + + describe '#create' do + it 'sends POST request to /suppressions with single email' do + stub_request(:post, "#{base_url}/suppressions") + .with( + body: { reason: 'manual', scope: 'team', email: 'blocked@example.com' }, + headers: { 'Authorization' => "Bearer #{team_token}" } + ) + .to_return( + status: 201, + body: '{"id":"sup_new","value":"blocked@example.com","reason":"manual","scope":"team"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = suppressions.create(reason: 'manual', scope: 'team', email: 'blocked@example.com') + expect(result['id']).to eq('sup_new') + expect(result['value']).to eq('blocked@example.com') + expect(result['reason']).to eq('manual') + end + + it 'sends POST request with multiple emails' do + emails = ['spam1@example.com', 'spam2@example.com'] + stub_request(:post, "#{base_url}/suppressions") + .with(body: { reason: 'spam_complaint', scope: 'team', emails: emails }) + .to_return( + status: 201, + body: '{"count":2}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = suppressions.create(reason: 'spam_complaint', scope: 'team', emails: emails) + expect(result['count']).to eq(2) + end + + it 'sends POST request with project scope and project_id' do + stub_request(:post, "#{base_url}/suppressions") + .with(body: { reason: 'unsubscribe', scope: 'project', email: 'user@example.com', project_id: 'proj_123' }) + .to_return( + status: 201, + body: '{"id":"sup_proj","scope":"project","project_id":"proj_123"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = suppressions.create(reason: 'unsubscribe', scope: 'project', email: 'user@example.com', + project_id: 'proj_123') + expect(result['scope']).to eq('project') + expect(result['project_id']).to eq('proj_123') + end + + it 'sends POST request with route scope and route_id' do + stub_request(:post, "#{base_url}/suppressions") + .with(body: { reason: 'hard_bounce', scope: 'route', email: 'bounced@example.com', route_id: 'route_456' }) + .to_return( + status: 201, + body: '{"id":"sup_route","scope":"route","route_id":"route_456"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = suppressions.create(reason: 'hard_bounce', scope: 'route', email: 'bounced@example.com', + route_id: 'route_456') + expect(result['scope']).to eq('route') + expect(result['route_id']).to eq('route_456') + end + + it 'raises ValidationError for invalid reason' do + stub_request(:post, "#{base_url}/suppressions") + .with(body: { reason: 'invalid_reason', scope: 'team', email: 'test@example.com' }) + .to_return( + status: 422, + body: '{"message":"Invalid reason","error":"validation_error"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { suppressions.create(reason: 'invalid_reason', scope: 'team', email: 'test@example.com') } + .to raise_error(Lettermint::ValidationError) + end + + it 'raises ValidationError for missing scope identifier' do + stub_request(:post, "#{base_url}/suppressions") + .with(body: { reason: 'manual', scope: 'project', email: 'test@example.com' }) + .to_return( + status: 422, + body: '{"message":"project_id required when scope is project","error":"validation_error"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { suppressions.create(reason: 'manual', scope: 'project', email: 'test@example.com') } + .to raise_error(Lettermint::ValidationError, /project_id required/) + end + end + + describe '#delete' do + let(:suppression_id) { 'sup_123' } + + it 'sends DELETE request to /suppressions/:id' do + stub_request(:delete, "#{base_url}/suppressions/#{suppression_id}") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"message":"Suppression deleted"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = suppressions.delete(suppression_id) + expect(result['message']).to eq('Suppression deleted') + end + + it 'handles 204 No Content response' do + stub_request(:delete, "#{base_url}/suppressions/#{suppression_id}") + .to_return(status: 204, body: '') + + result = suppressions.delete(suppression_id) + expect(result).to eq('') + end + + it 'raises HttpRequestError for non-existent suppression' do + stub_request(:delete, "#{base_url}/suppressions/nonexistent") + .to_return( + status: 404, + body: '{"message":"Suppression not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { suppressions.delete('nonexistent') }.to raise_error(Lettermint::HttpRequestError) + end + end + + describe 'error handling' do + it 'raises AuthenticationError on 401' do + stub_request(:get, "#{base_url}/suppressions") + .to_return( + status: 401, + body: '{"message":"Invalid token"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { suppressions.list }.to raise_error(Lettermint::AuthenticationError) + end + + it 'raises RateLimitError on 429' do + stub_request(:get, "#{base_url}/suppressions") + .to_return( + status: 429, + body: '{"message":"Too many requests"}', + headers: { 'Content-Type' => 'application/json', 'Retry-After' => '30' } + ) + + expect { suppressions.list }.to raise_error(Lettermint::RateLimitError) { |e| + expect(e.retry_after).to eq(30) + } + end + + it 'raises TimeoutError on timeout' do + stub_request(:get, "#{base_url}/suppressions").to_raise(Faraday::TimeoutError) + + expect { suppressions.list }.to raise_error(Lettermint::TimeoutError) + end + end +end diff --git a/spec/lettermint/resources/team_spec.rb b/spec/lettermint/resources/team_spec.rb new file mode 100644 index 0000000..8bb999d --- /dev/null +++ b/spec/lettermint/resources/team_spec.rb @@ -0,0 +1,238 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Lettermint::Resources::Team do + let(:team_token) { 'lm_team_test123' } + let(:base_url) { Lettermint::Configuration::DEFAULT_BASE_URL } + let(:api) { Lettermint::TeamAPI.new(team_token: team_token) } + let(:team) { api.team } + + describe '#get' do + it 'sends GET request to /team' do + stub_request(:get, "#{base_url}/team") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"id":"team_123","name":"Acme Corp","plan":"pro"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = team.get + expect(result).to eq( + { + 'id' => 'team_123', + 'name' => 'Acme Corp', + 'plan' => 'pro' + } + ) + end + + it 'sends GET request without include param when not specified' do + stub = stub_request(:get, "#{base_url}/team") + .to_return( + status: 200, + body: '{"id":"team_123"}', + headers: { 'Content-Type' => 'application/json' } + ) + + team.get + expect(stub).to have_been_requested + expect(WebMock).to(have_requested(:get, "#{base_url}/team") + .with { |req| req.uri.query.nil? }) + end + + it 'includes features when requested' do + stub_request(:get, "#{base_url}/team?include=features") + .to_return( + status: 200, + body: '{"id":"team_123","features":["custom_domains","webhooks"]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = team.get(include: 'features') + expect(result['features']).to eq(%w[custom_domains webhooks]) + end + + it 'includes featuresCount when requested' do + stub_request(:get, "#{base_url}/team?include=featuresCount") + .to_return( + status: 200, + body: '{"id":"team_123","featuresCount":5}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = team.get(include: 'featuresCount') + expect(result['featuresCount']).to eq(5) + end + + it 'includes featuresExists when requested' do + stub_request(:get, "#{base_url}/team?include=featuresExists") + .to_return( + status: 200, + body: '{"id":"team_123","featuresExists":true}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = team.get(include: 'featuresExists') + expect(result['featuresExists']).to eq(true) + end + end + + describe '#update' do + it 'sends PUT request to /team with name' do + stub_request(:put, "#{base_url}/team") + .with( + body: { name: 'New Team Name' }, + headers: { 'Authorization' => "Bearer #{team_token}" } + ) + .to_return( + status: 200, + body: '{"id":"team_123","name":"New Team Name"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = team.update(name: 'New Team Name') + expect(result['name']).to eq('New Team Name') + end + + it 'raises ValidationError on invalid name' do + stub_request(:put, "#{base_url}/team") + .with(body: { name: '' }) + .to_return( + status: 422, + body: '{"message":"Name cannot be blank","error":"validation_error"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { team.update(name: '') }.to raise_error(Lettermint::ValidationError) + end + end + + describe '#usage' do + it 'sends GET request to /team/usage' do + stub_request(:get, "#{base_url}/team/usage") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{ + "current_period": {"emails_sent": 1000, "limit": 10000}, + "historical": [{"month": "2024-01", "emails_sent": 800}] + }', + headers: { 'Content-Type' => 'application/json' } + ) + + result = team.usage + expect(result['current_period']['emails_sent']).to eq(1000) + expect(result['historical']).to be_an(Array) + end + end + + describe '#members' do + it 'sends GET request to /team/members' do + stub_request(:get, "#{base_url}/team/members") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"data":[{"id":"mem_1","email":"admin@example.com","role":"admin"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = team.members + expect(result['data']).to be_an(Array) + expect(result['data'].first['email']).to eq('admin@example.com') + end + + it 'sends GET request without params when none specified' do + stub = stub_request(:get, "#{base_url}/team/members") + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + team.members + expect(stub).to have_been_requested + expect(WebMock).to(have_requested(:get, "#{base_url}/team/members") + .with { |req| req.uri.query.nil? }) + end + + it 'passes page_size parameter' do + stub_request(:get, "#{base_url}/team/members") + .with(query: { 'page[size]' => '10' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + team.members(page_size: 10) + expect(WebMock).to have_requested(:get, "#{base_url}/team/members") + .with(query: { 'page[size]' => '10' }) + end + + it 'passes page_cursor parameter' do + stub_request(:get, "#{base_url}/team/members") + .with(query: { 'page[cursor]' => 'cursor_abc' }) + .to_return( + status: 200, + body: '{"data":[],"meta":{"next_cursor":"cursor_def"}}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = team.members(page_cursor: 'cursor_abc') + expect(result['meta']['next_cursor']).to eq('cursor_def') + end + + it 'passes both pagination parameters' do + stub_request(:get, "#{base_url}/team/members") + .with(query: { 'page[size]' => '25', 'page[cursor]' => 'xyz789' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + team.members(page_size: 25, page_cursor: 'xyz789') + expect(WebMock).to have_requested(:get, "#{base_url}/team/members") + .with(query: { 'page[size]' => '25', 'page[cursor]' => 'xyz789' }) + end + end + + describe 'error handling' do + it 'raises AuthenticationError on 401' do + stub_request(:get, "#{base_url}/team") + .to_return( + status: 401, + body: '{"message":"Invalid token"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { team.get }.to raise_error(Lettermint::AuthenticationError) + end + + it 'raises AuthenticationError on 403' do + stub_request(:get, "#{base_url}/team") + .to_return( + status: 403, + body: '{"message":"Forbidden"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { team.get }.to raise_error(Lettermint::AuthenticationError) + end + + it 'raises RateLimitError on 429' do + stub_request(:get, "#{base_url}/team/members") + .to_return( + status: 429, + body: '{"message":"Too many requests"}', + headers: { 'Content-Type' => 'application/json', 'Retry-After' => '60' } + ) + + expect { team.members }.to raise_error(Lettermint::RateLimitError) { |e| + expect(e.retry_after).to eq(60) + } + end + end +end diff --git a/spec/lettermint/resources/webhooks_spec.rb b/spec/lettermint/resources/webhooks_spec.rb new file mode 100644 index 0000000..3e31789 --- /dev/null +++ b/spec/lettermint/resources/webhooks_spec.rb @@ -0,0 +1,622 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Lettermint::Resources::Webhooks do + let(:team_token) { 'lm_team_test123' } + let(:base_url) { Lettermint::Configuration::DEFAULT_BASE_URL } + let(:api) { Lettermint::TeamAPI.new(team_token: team_token) } + let(:webhooks) { api.webhooks } + + describe '#list' do + it 'sends GET request to /webhooks' do + stub_request(:get, "#{base_url}/webhooks") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"data":[{"id":"wh_1","name":"My Webhook","url":"https://example.com/hook"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = webhooks.list + expect(result['data']).to be_an(Array) + expect(result['data'].first['name']).to eq('My Webhook') + end + + it 'sends GET request without params when none specified' do + stub = stub_request(:get, "#{base_url}/webhooks") + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + webhooks.list + expect(stub).to have_been_requested + expect(WebMock).to(have_requested(:get, "#{base_url}/webhooks") + .with { |req| req.uri.query.nil? }) + end + + describe 'pagination parameters' do + it 'passes page_size parameter' do + stub_request(:get, "#{base_url}/webhooks") + .with(query: { 'page[size]' => '50' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + webhooks.list(page_size: 50) + expect(WebMock).to have_requested(:get, "#{base_url}/webhooks") + .with(query: { 'page[size]' => '50' }) + end + + it 'passes page_cursor parameter' do + stub_request(:get, "#{base_url}/webhooks") + .with(query: { 'page[cursor]' => 'cursor_abc' }) + .to_return( + status: 200, + body: '{"data":[],"meta":{"next_cursor":"cursor_def"}}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = webhooks.list(page_cursor: 'cursor_abc') + expect(result['meta']['next_cursor']).to eq('cursor_def') + end + + it 'passes both pagination parameters' do + stub_request(:get, "#{base_url}/webhooks") + .with(query: { 'page[size]' => '25', 'page[cursor]' => 'xyz789' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + webhooks.list(page_size: 25, page_cursor: 'xyz789') + expect(WebMock).to have_requested(:get, "#{base_url}/webhooks") + .with(query: { 'page[size]' => '25', 'page[cursor]' => 'xyz789' }) + end + end + + describe 'sort parameter' do + it 'sorts by name ascending' do + stub_request(:get, "#{base_url}/webhooks") + .with(query: { 'sort' => 'name' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + webhooks.list(sort: 'name') + expect(WebMock).to have_requested(:get, "#{base_url}/webhooks") + .with(query: { 'sort' => 'name' }) + end + + it 'sorts by created_at descending' do + stub_request(:get, "#{base_url}/webhooks") + .with(query: { 'sort' => '-created_at' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + webhooks.list(sort: '-created_at') + expect(WebMock).to have_requested(:get, "#{base_url}/webhooks") + .with(query: { 'sort' => '-created_at' }) + end + end + + describe 'filter parameters' do + it 'filters by enabled status' do + stub_request(:get, "#{base_url}/webhooks") + .with(query: { 'filter[enabled]' => 'true' }) + .to_return( + status: 200, + body: '{"data":[{"enabled":true}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = webhooks.list(enabled: true) + expect(result['data'].first['enabled']).to eq(true) + end + + it 'filters by event type' do + stub_request(:get, "#{base_url}/webhooks") + .with(query: { 'filter[event]' => 'message.sent' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + webhooks.list(event: 'message.sent') + expect(WebMock).to have_requested(:get, "#{base_url}/webhooks") + .with(query: { 'filter[event]' => 'message.sent' }) + end + + it 'filters by route_id' do + stub_request(:get, "#{base_url}/webhooks") + .with(query: { 'filter[route_id]' => 'route_123' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + webhooks.list(route_id: 'route_123') + expect(WebMock).to have_requested(:get, "#{base_url}/webhooks") + .with(query: { 'filter[route_id]' => 'route_123' }) + end + + it 'filters by search term' do + stub_request(:get, "#{base_url}/webhooks") + .with(query: { 'filter[search]' => 'production' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + webhooks.list(search: 'production') + expect(WebMock).to have_requested(:get, "#{base_url}/webhooks") + .with(query: { 'filter[search]' => 'production' }) + end + + it 'filters by multiple criteria' do + stub_request(:get, "#{base_url}/webhooks") + .with(query: { 'filter[enabled]' => 'true', 'filter[event]' => 'message.delivered' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + webhooks.list(enabled: true, event: 'message.delivered') + expect(WebMock).to have_requested(:get, "#{base_url}/webhooks") + .with(query: { 'filter[enabled]' => 'true', 'filter[event]' => 'message.delivered' }) + end + end + end + + describe '#create' do + it 'sends POST request to /webhooks with required fields' do + stub_request(:post, "#{base_url}/webhooks") + .with( + body: { route_id: 'route_123', name: 'My Webhook', url: 'https://example.com/hook', + events: ['message.sent'] }, + headers: { 'Authorization' => "Bearer #{team_token}" } + ) + .to_return( + status: 201, + body: '{"id":"wh_new","name":"My Webhook","url":"https://example.com/hook","secret":"whsec_abc123"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = webhooks.create(route_id: 'route_123', name: 'My Webhook', url: 'https://example.com/hook', + events: ['message.sent']) + expect(result['id']).to eq('wh_new') + expect(result['secret']).to eq('whsec_abc123') + end + + it 'sends POST request with optional enabled field' do + stub_request(:post, "#{base_url}/webhooks") + .with( + body: { route_id: 'route_123', name: 'My Webhook', url: 'https://example.com/hook', events: ['message.sent'], enabled: false } + ) + .to_return( + status: 201, + body: '{"id":"wh_new","enabled":false}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = webhooks.create(route_id: 'route_123', name: 'My Webhook', url: 'https://example.com/hook', + events: ['message.sent'], enabled: false) + expect(result['enabled']).to eq(false) + end + + it 'raises ValidationError for invalid URL' do + stub_request(:post, "#{base_url}/webhooks") + .to_return( + status: 422, + body: '{"message":"Invalid URL format","error":"validation_error"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { webhooks.create(route_id: 'route_123', name: 'Test', url: 'not-a-url', events: ['message.sent']) } + .to raise_error(Lettermint::ValidationError) + end + + it 'raises ValidationError for empty events array' do + stub_request(:post, "#{base_url}/webhooks") + .to_return( + status: 422, + body: '{"message":"Events must have at least 1 item","error":"validation_error"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { webhooks.create(route_id: 'route_123', name: 'Test', url: 'https://example.com', events: []) } + .to raise_error(Lettermint::ValidationError, /at least 1 item/) + end + end + + describe '#find' do + let(:webhook_id) { 'wh_123' } + + it 'sends GET request to /webhooks/:id' do + stub_request(:get, "#{base_url}/webhooks/#{webhook_id}") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"id":"wh_123","name":"My Webhook","secret":"whsec_abc"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = webhooks.find(webhook_id) + expect(result['id']).to eq('wh_123') + expect(result['secret']).to eq('whsec_abc') + end + + it 'raises HttpRequestError for non-existent webhook' do + stub_request(:get, "#{base_url}/webhooks/nonexistent") + .to_return( + status: 404, + body: '{"message":"Webhook not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { webhooks.find('nonexistent') }.to raise_error(Lettermint::HttpRequestError) + end + end + + describe '#update' do + let(:webhook_id) { 'wh_123' } + + it 'sends PUT request to /webhooks/:id with name' do + stub_request(:put, "#{base_url}/webhooks/#{webhook_id}") + .with( + body: { name: 'Updated Webhook' }, + headers: { 'Authorization' => "Bearer #{team_token}" } + ) + .to_return( + status: 200, + body: '{"id":"wh_123","name":"Updated Webhook"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = webhooks.update(webhook_id, name: 'Updated Webhook') + expect(result['name']).to eq('Updated Webhook') + end + + it 'sends PUT request with url' do + stub_request(:put, "#{base_url}/webhooks/#{webhook_id}") + .with(body: { url: 'https://new-url.com/hook' }) + .to_return( + status: 200, + body: '{"id":"wh_123","url":"https://new-url.com/hook"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = webhooks.update(webhook_id, url: 'https://new-url.com/hook') + expect(result['url']).to eq('https://new-url.com/hook') + end + + it 'sends PUT request with enabled status' do + stub_request(:put, "#{base_url}/webhooks/#{webhook_id}") + .with(body: { enabled: false }) + .to_return( + status: 200, + body: '{"id":"wh_123","enabled":false}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = webhooks.update(webhook_id, enabled: false) + expect(result['enabled']).to eq(false) + end + + it 'sends PUT request with events array' do + stub_request(:put, "#{base_url}/webhooks/#{webhook_id}") + .with(body: { events: %w[message.sent message.delivered] }) + .to_return( + status: 200, + body: '{"id":"wh_123","events":["message.sent","message.delivered"]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = webhooks.update(webhook_id, events: %w[message.sent message.delivered]) + expect(result['events']).to eq(%w[message.sent message.delivered]) + end + + it 'sends PUT request with multiple fields' do + stub_request(:put, "#{base_url}/webhooks/#{webhook_id}") + .with(body: { name: 'New Name', url: 'https://new.com', enabled: true }) + .to_return( + status: 200, + body: '{"id":"wh_123","name":"New Name","url":"https://new.com","enabled":true}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = webhooks.update(webhook_id, name: 'New Name', url: 'https://new.com', enabled: true) + expect(result['name']).to eq('New Name') + expect(result['url']).to eq('https://new.com') + end + end + + describe '#delete' do + let(:webhook_id) { 'wh_123' } + + it 'sends DELETE request to /webhooks/:id' do + stub_request(:delete, "#{base_url}/webhooks/#{webhook_id}") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"message":"Webhook deleted"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = webhooks.delete(webhook_id) + expect(result['message']).to eq('Webhook deleted') + end + + it 'handles 204 No Content response' do + stub_request(:delete, "#{base_url}/webhooks/#{webhook_id}") + .to_return(status: 204, body: '') + + result = webhooks.delete(webhook_id) + expect(result).to eq('') + end + + it 'raises HttpRequestError for non-existent webhook' do + stub_request(:delete, "#{base_url}/webhooks/nonexistent") + .to_return( + status: 404, + body: '{"message":"Webhook not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { webhooks.delete('nonexistent') }.to raise_error(Lettermint::HttpRequestError) + end + end + + describe '#test' do + let(:webhook_id) { 'wh_123' } + + it 'sends POST request to /webhooks/:id/test' do + stub_request(:post, "#{base_url}/webhooks/#{webhook_id}/test") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"delivery_id":"del_test123"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = webhooks.test(webhook_id) + expect(result['delivery_id']).to eq('del_test123') + end + + it 'raises HttpRequestError for non-existent webhook' do + stub_request(:post, "#{base_url}/webhooks/nonexistent/test") + .to_return( + status: 404, + body: '{"message":"Webhook not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { webhooks.test('nonexistent') }.to raise_error(Lettermint::HttpRequestError) + end + end + + describe '#regenerate_secret' do + let(:webhook_id) { 'wh_123' } + + it 'sends POST request to /webhooks/:id/regenerate-secret' do + stub_request(:post, "#{base_url}/webhooks/#{webhook_id}/regenerate-secret") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"id":"wh_123","secret":"whsec_newsecret456"}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = webhooks.regenerate_secret(webhook_id) + expect(result['secret']).to eq('whsec_newsecret456') + end + + it 'raises HttpRequestError for non-existent webhook' do + stub_request(:post, "#{base_url}/webhooks/nonexistent/regenerate-secret") + .to_return( + status: 404, + body: '{"message":"Webhook not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { webhooks.regenerate_secret('nonexistent') }.to raise_error(Lettermint::HttpRequestError) + end + end + + describe '#deliveries' do + let(:webhook_id) { 'wh_123' } + + it 'sends GET request to /webhooks/:id/deliveries' do + stub_request(:get, "#{base_url}/webhooks/#{webhook_id}/deliveries") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"data":[{"id":"del_1","status":"success","event_type":"message.sent"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = webhooks.deliveries(webhook_id) + expect(result['data']).to be_an(Array) + expect(result['data'].first['status']).to eq('success') + end + + it 'sends GET request without params when none specified' do + stub = stub_request(:get, "#{base_url}/webhooks/#{webhook_id}/deliveries") + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + webhooks.deliveries(webhook_id) + expect(stub).to have_been_requested + expect(WebMock).to(have_requested(:get, "#{base_url}/webhooks/#{webhook_id}/deliveries") + .with { |req| req.uri.query.nil? }) + end + + describe 'pagination parameters' do + it 'passes page_size parameter' do + stub_request(:get, "#{base_url}/webhooks/#{webhook_id}/deliveries") + .with(query: { 'page[size]' => '20' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + webhooks.deliveries(webhook_id, page_size: 20) + expect(WebMock).to have_requested(:get, "#{base_url}/webhooks/#{webhook_id}/deliveries") + .with(query: { 'page[size]' => '20' }) + end + + it 'passes page_cursor parameter' do + stub_request(:get, "#{base_url}/webhooks/#{webhook_id}/deliveries") + .with(query: { 'page[cursor]' => 'cursor_xyz' }) + .to_return( + status: 200, + body: '{"data":[],"meta":{"next_cursor":"cursor_next"}}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = webhooks.deliveries(webhook_id, page_cursor: 'cursor_xyz') + expect(result['meta']['next_cursor']).to eq('cursor_next') + end + end + + describe 'filter parameters' do + it 'filters by status' do + stub_request(:get, "#{base_url}/webhooks/#{webhook_id}/deliveries") + .with(query: { 'filter[status]' => 'failed' }) + .to_return( + status: 200, + body: '{"data":[{"status":"failed"}]}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = webhooks.deliveries(webhook_id, status: 'failed') + expect(result['data'].first['status']).to eq('failed') + end + + it 'filters by event_type' do + stub_request(:get, "#{base_url}/webhooks/#{webhook_id}/deliveries") + .with(query: { 'filter[event_type]' => 'message.bounced' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + webhooks.deliveries(webhook_id, event_type: 'message.bounced') + expect(WebMock).to have_requested(:get, "#{base_url}/webhooks/#{webhook_id}/deliveries") + .with(query: { 'filter[event_type]' => 'message.bounced' }) + end + + it 'filters by date range' do + stub_request(:get, "#{base_url}/webhooks/#{webhook_id}/deliveries") + .with(query: { 'filter[from_date]' => '2024-01-01', 'filter[to_date]' => '2024-01-31' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + webhooks.deliveries(webhook_id, from_date: '2024-01-01', to_date: '2024-01-31') + expect(WebMock).to have_requested(:get, "#{base_url}/webhooks/#{webhook_id}/deliveries") + .with(query: { 'filter[from_date]' => '2024-01-01', 'filter[to_date]' => '2024-01-31' }) + end + end + + describe 'sort parameter' do + it 'sorts by created_at' do + stub_request(:get, "#{base_url}/webhooks/#{webhook_id}/deliveries") + .with(query: { 'sort' => '-created_at' }) + .to_return( + status: 200, + body: '{"data":[]}', + headers: { 'Content-Type' => 'application/json' } + ) + + webhooks.deliveries(webhook_id, sort: '-created_at') + expect(WebMock).to have_requested(:get, "#{base_url}/webhooks/#{webhook_id}/deliveries") + .with(query: { 'sort' => '-created_at' }) + end + end + end + + describe '#delivery' do + let(:webhook_id) { 'wh_123' } + let(:delivery_id) { 'del_456' } + + it 'sends GET request to /webhooks/:webhook_id/deliveries/:delivery_id' do + stub_request(:get, "#{base_url}/webhooks/#{webhook_id}/deliveries/#{delivery_id}") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return( + status: 200, + body: '{"id":"del_456","status":"success","payload":{"event":"message.sent"},"response":{"status_code":200}}', + headers: { 'Content-Type' => 'application/json' } + ) + + result = webhooks.delivery(webhook_id, delivery_id) + expect(result['id']).to eq('del_456') + expect(result['payload']['event']).to eq('message.sent') + expect(result['response']['status_code']).to eq(200) + end + + it 'raises HttpRequestError for non-existent delivery' do + stub_request(:get, "#{base_url}/webhooks/#{webhook_id}/deliveries/nonexistent") + .to_return( + status: 404, + body: '{"message":"Delivery not found"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { webhooks.delivery(webhook_id, 'nonexistent') }.to raise_error(Lettermint::HttpRequestError) + end + end + + describe 'error handling' do + it 'raises AuthenticationError on 401' do + stub_request(:get, "#{base_url}/webhooks") + .to_return( + status: 401, + body: '{"message":"Invalid token"}', + headers: { 'Content-Type' => 'application/json' } + ) + + expect { webhooks.list }.to raise_error(Lettermint::AuthenticationError) + end + + it 'raises RateLimitError on 429' do + stub_request(:get, "#{base_url}/webhooks") + .to_return( + status: 429, + body: '{"message":"Too many requests"}', + headers: { 'Content-Type' => 'application/json', 'Retry-After' => '30' } + ) + + expect { webhooks.list }.to raise_error(Lettermint::RateLimitError) { |e| + expect(e.retry_after).to eq(30) + } + end + + it 'raises TimeoutError on timeout' do + stub_request(:get, "#{base_url}/webhooks").to_raise(Faraday::TimeoutError) + + expect { webhooks.list }.to raise_error(Lettermint::TimeoutError) + end + end +end diff --git a/spec/lettermint/sending_api_spec.rb b/spec/lettermint/sending_api_spec.rb new file mode 100644 index 0000000..58d2ad8 --- /dev/null +++ b/spec/lettermint/sending_api_spec.rb @@ -0,0 +1,246 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Lettermint::SendingAPI do + let(:api_token) { 'test_project_token' } + let(:base_url) { Lettermint::Configuration::DEFAULT_BASE_URL } + + describe '#initialize' do + it 'creates a client with required api_token' do + client = described_class.new(api_token: api_token) + expect(client.configuration.base_url).to eq(Lettermint::Configuration::DEFAULT_BASE_URL) + expect(client.configuration.timeout).to eq(Lettermint::Configuration::DEFAULT_TIMEOUT) + end + + it 'raises ArgumentError when api_token is nil' do + expect { described_class.new(api_token: nil) }.to raise_error(ArgumentError, /API token cannot be empty/) + end + + it 'raises ArgumentError when api_token is empty string' do + expect { described_class.new(api_token: '') }.to raise_error(ArgumentError, /API token cannot be empty/) + end + + it 'raises ArgumentError when api_token is whitespace only' do + expect { described_class.new(api_token: ' ') }.to raise_error(ArgumentError, /API token cannot be empty/) + end + + it 'accepts any non-empty token format' do + # Unlike TeamAPI, SendingAPI accepts any token format + expect { described_class.new(api_token: 'any_format_works') }.not_to raise_error + expect { described_class.new(api_token: 'lm_proj_abc123') }.not_to raise_error + expect { described_class.new(api_token: 'custom-token') }.not_to raise_error + end + + it 'accepts custom base_url and timeout' do + client = described_class.new(api_token: api_token, base_url: 'https://custom.co/v2', timeout: 60) + expect(client.configuration.base_url).to eq('https://custom.co/v2') + expect(client.configuration.timeout).to eq(60) + end + + it 'accepts a configuration block' do + client = described_class.new(api_token: api_token) do |c| + c.timeout = 90 + end + expect(client.configuration.timeout).to eq(90) + end + + it 'falls back to Lettermint.configuration for defaults' do + Lettermint.configure do |c| + c.base_url = 'https://global.example.com/v1' + c.timeout = 45 + end + + client = described_class.new(api_token: api_token) + expect(client.configuration.base_url).to eq('https://global.example.com/v1') + expect(client.configuration.timeout).to eq(45) + ensure + Lettermint.reset_configuration! + end + + it 'prefers explicit kwargs over global configuration' do + Lettermint.configure do |c| + c.base_url = 'https://global.example.com/v1' + c.timeout = 45 + end + + client = described_class.new(api_token: api_token, base_url: 'https://override.co/v1', timeout: 10) + expect(client.configuration.base_url).to eq('https://override.co/v1') + expect(client.configuration.timeout).to eq(10) + ensure + Lettermint.reset_configuration! + end + end + + describe 'authentication header' do + let(:client) { described_class.new(api_token: api_token) } + + it 'uses x-lettermint-token header for authentication' do + stub = stub_request(:get, "#{base_url}/ping") + .with(headers: { 'x-lettermint-token' => api_token }) + .to_return(status: 200, body: '{"ok":true}', + headers: { 'Content-Type' => 'application/json' }) + + client.get('/ping') + expect(stub).to have_been_requested + end + + it 'does not use Authorization Bearer header' do + stub = stub_request(:get, "#{base_url}/ping") + .to_return(status: 200, body: '{"ok":true}', + headers: { 'Content-Type' => 'application/json' }) + + client.get('/ping') + + # Verify the request was made without Bearer auth + expect(stub).to have_been_requested + expect(WebMock).to(have_requested(:get, "#{base_url}/ping") + .with { |req| req.headers['Authorization'].nil? }) + end + end + + describe '#email' do + it 'returns an EmailMessage instance' do + client = described_class.new(api_token: api_token) + expect(client.email).to be_a(Lettermint::EmailMessage) + end + + it 'returns a new instance each call' do + client = described_class.new(api_token: api_token) + msg_a = client.email + msg_b = client.email + expect(msg_a).not_to equal(msg_b) + end + end + + describe 'HTTP method delegation' do + let(:client) { described_class.new(api_token: api_token) } + + describe '#get' do + it 'sends GET request and returns parsed response' do + stub_request(:get, "#{base_url}/domains") + .to_return(status: 200, body: '{"domains":[]}', + headers: { 'Content-Type' => 'application/json' }) + + result = client.get('/domains') + expect(result).to eq({ 'domains' => [] }) + end + + it 'passes query params' do + stub_request(:get, "#{base_url}/messages?status=delivered") + .to_return(status: 200, body: '{"messages":[]}', + headers: { 'Content-Type' => 'application/json' }) + + result = client.get('/messages', params: { status: 'delivered' }) + expect(result).to eq({ 'messages' => [] }) + end + + it 'passes custom headers' do + stub = stub_request(:get, "#{base_url}/data") + .with(headers: { 'X-Request-Id' => 'req-123' }) + .to_return(status: 200, body: '{}', + headers: { 'Content-Type' => 'application/json' }) + + client.get('/data', headers: { 'X-Request-Id' => 'req-123' }) + expect(stub).to have_been_requested + end + end + + describe '#post' do + it 'sends POST request with JSON body and returns parsed response' do + stub_request(:post, "#{base_url}/domains") + .with(body: { domain: 'example.com' }) + .to_return(status: 201, body: '{"id":"dom_123"}', + headers: { 'Content-Type' => 'application/json' }) + + result = client.post('/domains', data: { domain: 'example.com' }) + expect(result).to eq({ 'id' => 'dom_123' }) + end + + it 'works with empty data hash' do + stub_request(:post, "#{base_url}/trigger") + .with(body: {}) + .to_return(status: 200, body: '{"triggered":true}', + headers: { 'Content-Type' => 'application/json' }) + + result = client.post('/trigger', data: {}) + expect(result).to eq({ 'triggered' => true }) + end + end + + describe '#put' do + it 'sends PUT request with JSON body' do + stub_request(:put, "#{base_url}/domains/123") + .with(body: { verified: true }) + .to_return(status: 200, body: '{"id":"123","verified":true}', + headers: { 'Content-Type' => 'application/json' }) + + result = client.put('/domains/123', data: { verified: true }) + expect(result).to eq({ 'id' => '123', 'verified' => true }) + end + end + + describe '#delete' do + it 'sends DELETE request' do + stub_request(:delete, "#{base_url}/domains/123") + .to_return(status: 200, body: '{"deleted":true}', + headers: { 'Content-Type' => 'application/json' }) + + result = client.delete('/domains/123') + expect(result).to eq({ 'deleted' => true }) + end + + it 'handles 204 No Content' do + stub_request(:delete, "#{base_url}/sessions/current") + .to_return(status: 204, body: '') + + result = client.delete('/sessions/current') + expect(result).to eq('') + end + end + + describe 'path normalization' do + it 'handles path without leading slash' do + stub = stub_request(:get, "#{base_url}/users") + .to_return(status: 200, body: '[]', + headers: { 'Content-Type' => 'application/json' }) + + client.get('users') + expect(stub).to have_been_requested + end + end + + describe 'error propagation' do + it 'raises ValidationError from underlying HttpClient' do + stub_request(:post, "#{base_url}/invalid") + .to_return(status: 422, body: '{"message":"Bad data","error":"validation_error"}', + headers: { 'Content-Type' => 'application/json' }) + + expect { client.post('/invalid', data: {}) } + .to raise_error(Lettermint::ValidationError) + end + + it 'raises AuthenticationError on 401' do + stub_request(:get, "#{base_url}/protected") + .to_return(status: 401, body: '{"message":"Unauthorized"}', + headers: { 'Content-Type' => 'application/json' }) + + expect { client.get('/protected') } + .to raise_error(Lettermint::AuthenticationError) + end + + it 'raises TimeoutError on timeout' do + stub_request(:get, "#{base_url}/slow").to_raise(Faraday::TimeoutError) + + expect { client.get('/slow') } + .to raise_error(Lettermint::TimeoutError) + end + end + end + + describe 'backward compatibility' do + it 'Lettermint::Client is an alias for SendingAPI' do + expect(Lettermint::Client).to eq(described_class) + end + end +end diff --git a/spec/lettermint/team_api_spec.rb b/spec/lettermint/team_api_spec.rb new file mode 100644 index 0000000..1fdfc45 --- /dev/null +++ b/spec/lettermint/team_api_spec.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Lettermint::TeamAPI do + let(:team_token) { 'lm_team_test123' } + let(:base_url) { Lettermint::Configuration::DEFAULT_BASE_URL } + + describe '#initialize' do + it 'creates a client with valid team token' do + client = described_class.new(team_token: team_token) + expect(client.configuration.base_url).to eq(Lettermint::Configuration::DEFAULT_BASE_URL) + expect(client.configuration.timeout).to eq(Lettermint::Configuration::DEFAULT_TIMEOUT) + end + + context 'token validation' do + it 'raises ArgumentError when team_token is nil' do + expect { described_class.new(team_token: nil) } + .to raise_error(ArgumentError, /Team token cannot be empty/) + end + + it 'raises ArgumentError when team_token is empty string' do + expect { described_class.new(team_token: '') } + .to raise_error(ArgumentError, /Team token cannot be empty/) + end + + it 'raises ArgumentError when team_token is whitespace only' do + expect { described_class.new(team_token: ' ') } + .to raise_error(ArgumentError, /Team token cannot be empty/) + end + + it 'raises ArgumentError when team_token does not start with lm_team_' do + expect { described_class.new(team_token: 'invalid_token') } + .to raise_error(ArgumentError, /Invalid team token format/) + end + + it 'raises ArgumentError for project tokens' do + expect { described_class.new(team_token: 'lm_proj_abc123') } + .to raise_error(ArgumentError, /Invalid team token format/) + end + + it 'raises ArgumentError for bearer tokens without prefix' do + expect { described_class.new(team_token: 'some_random_token') } + .to raise_error(ArgumentError, /Invalid team token format/) + end + + it 'accepts token starting with lm_team_' do + expect { described_class.new(team_token: 'lm_team_') }.not_to raise_error + expect { described_class.new(team_token: 'lm_team_abc123xyz') }.not_to raise_error + expect { described_class.new(team_token: 'lm_team_with-dashes') }.not_to raise_error + end + end + + it 'accepts custom base_url and timeout' do + client = described_class.new(team_token: team_token, base_url: 'https://custom.co/v2', timeout: 60) + expect(client.configuration.base_url).to eq('https://custom.co/v2') + expect(client.configuration.timeout).to eq(60) + end + + it 'accepts a configuration block' do + client = described_class.new(team_token: team_token) do |c| + c.timeout = 90 + end + expect(client.configuration.timeout).to eq(90) + end + + it 'falls back to Lettermint.configuration for defaults' do + Lettermint.configure do |c| + c.base_url = 'https://global.example.com/v1' + c.timeout = 45 + end + + client = described_class.new(team_token: team_token) + expect(client.configuration.base_url).to eq('https://global.example.com/v1') + expect(client.configuration.timeout).to eq(45) + ensure + Lettermint.reset_configuration! + end + + it 'prefers explicit kwargs over global configuration' do + Lettermint.configure do |c| + c.base_url = 'https://global.example.com/v1' + c.timeout = 45 + end + + client = described_class.new(team_token: team_token, base_url: 'https://override.co/v1', timeout: 10) + expect(client.configuration.base_url).to eq('https://override.co/v1') + expect(client.configuration.timeout).to eq(10) + ensure + Lettermint.reset_configuration! + end + end + + describe 'authentication header' do + let(:client) { described_class.new(team_token: team_token) } + + it 'uses Authorization Bearer header for authentication' do + stub = stub_request(:get, "#{base_url}/ping") + .with(headers: { 'Authorization' => "Bearer #{team_token}" }) + .to_return(status: 200, body: '{"ok":true}', + headers: { 'Content-Type' => 'application/json' }) + + client.ping + expect(stub).to have_been_requested + end + + it 'does not use x-lettermint-token header' do + stub = stub_request(:get, "#{base_url}/ping") + .to_return(status: 200, body: '{"ok":true}', + headers: { 'Content-Type' => 'application/json' }) + + client.ping + + expect(stub).to have_been_requested + expect(WebMock).to(have_requested(:get, "#{base_url}/ping") + .with { |req| req.headers['X-Lettermint-Token'].nil? }) + end + end + + describe '#ping' do + let(:client) { described_class.new(team_token: team_token) } + + it 'sends GET request to /ping' do + stub_request(:get, "#{base_url}/ping") + .to_return(status: 200, body: '{"ok":true}', + headers: { 'Content-Type' => 'application/json' }) + + result = client.ping + expect(result).to eq({ 'ok' => true }) + end + end + + describe 'resource accessors' do + let(:client) { described_class.new(team_token: team_token) } + + describe '#team' do + it 'returns a Team resource instance' do + expect(client.team).to be_a(Lettermint::Resources::Team) + end + + it 'returns a new instance each call' do + team_a = client.team + team_b = client.team + expect(team_a).not_to equal(team_b) + end + end + + describe '#domains' do + it 'returns a Domains resource instance' do + expect(client.domains).to be_a(Lettermint::Resources::Domains) + end + end + + describe '#projects' do + it 'returns a Projects resource instance' do + expect(client.projects).to be_a(Lettermint::Resources::Projects) + end + end + + describe '#webhooks' do + it 'returns a Webhooks resource instance' do + expect(client.webhooks).to be_a(Lettermint::Resources::Webhooks) + end + end + + describe '#messages' do + it 'returns a Messages resource instance' do + expect(client.messages).to be_a(Lettermint::Resources::Messages) + end + end + + describe '#suppressions' do + it 'returns a Suppressions resource instance' do + expect(client.suppressions).to be_a(Lettermint::Resources::Suppressions) + end + end + + describe '#stats' do + it 'returns a Stats resource instance' do + expect(client.stats).to be_a(Lettermint::Resources::Stats) + end + end + + describe '#routes' do + it 'returns a Routes resource instance' do + expect(client.routes).to be_a(Lettermint::Resources::Routes) + end + end + end + + describe 'error propagation' do + let(:client) { described_class.new(team_token: team_token) } + + it 'raises AuthenticationError on 401' do + stub_request(:get, "#{base_url}/ping") + .to_return(status: 401, body: '{"message":"Invalid token"}', + headers: { 'Content-Type' => 'application/json' }) + + expect { client.ping }.to raise_error(Lettermint::AuthenticationError) + end + + it 'raises AuthenticationError on 403' do + stub_request(:get, "#{base_url}/ping") + .to_return(status: 403, body: '{"message":"Forbidden"}', + headers: { 'Content-Type' => 'application/json' }) + + expect { client.ping }.to raise_error(Lettermint::AuthenticationError) + end + + it 'raises TimeoutError on timeout' do + stub_request(:get, "#{base_url}/ping").to_raise(Faraday::TimeoutError) + + expect { client.ping }.to raise_error(Lettermint::TimeoutError) + end + + it 'raises ConnectionError on connection failure' do + stub_request(:get, "#{base_url}/ping").to_raise(Faraday::ConnectionFailed.new('refused')) + + expect { client.ping }.to raise_error(Lettermint::ConnectionError) + end + end +end