diff --git a/examples/contact_fields.rb b/examples/contact_fields.rb new file mode 100644 index 0000000..b0ddc2b --- /dev/null +++ b/examples/contact_fields.rb @@ -0,0 +1,25 @@ +require 'mailtrap' + +client = Mailtrap::Client.new(api_key: 'your-api-key') +contact_fields = Mailtrap::ContactFieldsAPI.new 3229, client + +# Set your API credentials as environment variables +# export MAILTRAP_API_KEY='your-api-key' +# export MAILTRAP_ACCOUNT_ID=your-account-id +# +# contact_fields = Mailtrap::ContactFieldsAPI.new + +# Create new contact field +field = contact_fields.create(name: 'Updated name', data_type: 'text', merge_tag: 'updated_name') + +# Get all contact fields +contact_fields.list + +# Update contact field +contact_fields.update(field.id, name: 'Updated name 2', merge_tag: 'updated_name_2') + +# Get contact field +field = contact_fields.get(field.id) + +# Delete contact field +contact_fields.delete(field.id) diff --git a/examples/contacts_api.rb b/examples/contacts_api.rb new file mode 100644 index 0000000..2187ad7 --- /dev/null +++ b/examples/contacts_api.rb @@ -0,0 +1,48 @@ +require 'mailtrap' + +client = Mailtrap::Client.new(api_key: 'your-api-key') +contact_list = Mailtrap::ContactListsAPI.new 3229, client +contacts = Mailtrap::ContactsAPI.new 3229, client + +# Set your API credentials as environment variables +# export MAILTRAP_API_KEY='your-api-key' +# export MAILTRAP_ACCOUNT_ID=your-account-id +# +# contact_list = Mailtrap::ContactListsAPI.new +# contacts = Mailtrap::ContactsAPI.new + +# Create new contact list +list = contact_list.create(name: 'Test List') + +# Get all contact lists +contact_list.list + +# Update contact list +contact_list.update(list.id, name: 'Test List Updated') + +# Get contact list +list = contact_list.get(list.id) + +# Create new contact +contact = contacts.create(email: 'test@example.com', fields: { first_name: 'John Doe' }, list_ids: [list.id]) + +# Get contact +contact = contacts.get(contact.id) + +# Update contact using id +updated_contact = contacts.update(contact.id, email: 'test2@example.com', fields: { first_name: 'Jane Doe' }) + +# Update contact using email +contacts.update(updated_contact.data.email, email: 'test3@example.com', fields: { first_name: 'Jane Doe' }) + +# Remove contact from lists +contacts.remove_from_lists(contact.id, [list.id]) + +# Add contact to lists +contacts.add_to_lists(contact.id, [list.id]) + +# Delete contact +contacts.delete(contact.id) + +# Delete contact list +contact_list.delete(list.id) diff --git a/lib/mailtrap.rb b/lib/mailtrap.rb index 9858086..25b0e4f 100644 --- a/lib/mailtrap.rb +++ b/lib/mailtrap.rb @@ -3,8 +3,12 @@ require_relative 'mailtrap/action_mailer' if defined? ActionMailer require_relative 'mailtrap/mail' require_relative 'mailtrap/errors' +require_relative 'mailtrap/base_api' require_relative 'mailtrap/version' require_relative 'mailtrap/email_templates_api' +require_relative 'mailtrap/contacts_api' +require_relative 'mailtrap/contact_lists_api' +require_relative 'mailtrap/contact_fields_api' module Mailtrap # @!macro api_errors diff --git a/lib/mailtrap/base_api.rb b/lib/mailtrap/base_api.rb new file mode 100644 index 0000000..2f91d5f --- /dev/null +++ b/lib/mailtrap/base_api.rb @@ -0,0 +1,98 @@ +# frozen_string_literal: true + +module Mailtrap + module BaseAPI + attr_reader :account_id, :client + + def self.included(base) + base.extend(ClassMethods) + end + + module ClassMethods + def supported_options(options) + @supported_options = options + end + + def get_supported_options # rubocop:disable Naming/AccessorMethodName + @supported_options + end + + def response_class(response_class) + @response_class = response_class + end + + def get_response_class # rubocop:disable Naming/AccessorMethodName + @response_class + end + end + + # @param account_id [Integer] The account ID + # @param client [Mailtrap::Client] The client instance + # @raise [ArgumentError] If account_id is nil + def initialize(account_id = ENV.fetch('MAILTRAP_ACCOUNT_ID'), client = Mailtrap::Client.new) + raise ArgumentError, 'account_id is required' if account_id.nil? + + @account_id = account_id + @client = client + end + + private + + def supported_options + self.class.get_supported_options + end + + def response_class + self.class.get_response_class + end + + def validate_options!(options, supported_options) + invalid_options = options.keys - supported_options + return if invalid_options.empty? + + raise ArgumentError, "invalid options are given: #{invalid_options}, supported_options: #{supported_options}" + end + + def build_entity(options, response_class) + response_class.new(options.slice(*response_class.members)) + end + + def base_get(id) + response = client.get("#{base_path}/#{id}") + handle_response(response) + end + + def base_create(options, supported_options_override = supported_options) + validate_options!(options, supported_options_override) + response = client.post(base_path, wrap_request(options)) + handle_response(response) + end + + def base_update(id, options, supported_options_override = supported_options) + validate_options!(options, supported_options_override) + response = client.patch("#{base_path}/#{id}", wrap_request(options)) + handle_response(response) + end + + def base_delete(id) + client.delete("#{base_path}/#{id}") + end + + def base_list + response = client.get(base_path) + response.map { |item| handle_response(item) } + end + + def handle_response(response) + build_entity(response, response_class) + end + + def wrap_request(options) + options + end + + def base_path + raise NotImplementedError, 'base_path must be implemented in the including class' + end + end +end diff --git a/lib/mailtrap/contact.rb b/lib/mailtrap/contact.rb new file mode 100644 index 0000000..ff02f90 --- /dev/null +++ b/lib/mailtrap/contact.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Mailtrap + # Data Transfer Object for Contact + # @see https://api-docs.mailtrap.io/docs/mailtrap-api-docs/220a54e31e5ca-contact + # @attr_reader id [String] The contact ID + # @attr_reader email [String] The contact's email address + # @attr_reader fields [Hash] Object of fields with merge tags + # @attr_reader list_ids [Array] Array of list IDs + # @attr_reader status [String] The contact status (subscribed/unsubscribed) + # @attr_reader created_at [Integer] The creation timestamp + # @attr_reader updated_at [Integer] The last update timestamp + Contact = Struct.new( + :id, + :email, + :fields, + :list_ids, + :status, + :created_at, + :updated_at, + keyword_init: true + ) do + # @return [Hash] The contact attributes as a hash + def to_h + super.compact + end + end + + # Data Transfer Object for Contact Update Response + # @see https://api-docs.mailtrap.io/docs/mailtrap-api-docs/16eab4fff9740-contact-update-response + # @attr_reader action [String] The performed action (created/updated) + # @attr_reader data [Contact, Hash] The contact data + ContactUpdateResponse = Struct.new(:action, :data, keyword_init: true) do + def initialize(*) + super + self.data = Contact.new(data) if data.is_a?(Hash) + end + + # @return [Hash] The response attributes as a hash + def to_h + super.compact + end + end +end diff --git a/lib/mailtrap/contact_field.rb b/lib/mailtrap/contact_field.rb new file mode 100644 index 0000000..3bb0eb4 --- /dev/null +++ b/lib/mailtrap/contact_field.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Mailtrap + # Data Transfer Object for Contact Field + # @see https://api-docs.mailtrap.io/docs/mailtrap-api-docs/33efe96c91dcc-get-all-contact-fields + # @attr_reader id [Integer] The contact field ID + # @attr_reader name [String] The name of the contact field (max 80 characters) + # @attr_reader data_type [String] The data type of the field + # Allowed values: text, integer, float, boolean, date + # @attr_reader merge_tag [String] Personalize your campaigns by adding a merge tag. + # This field will be replaced with unique contact details for each recipient (max 80 characters) + ContactField = Struct.new(:id, :name, :data_type, :merge_tag, keyword_init: true) do + # @return [Hash] The contact field attributes as a hash + def to_h + super.compact + end + end +end diff --git a/lib/mailtrap/contact_fields_api.rb b/lib/mailtrap/contact_fields_api.rb new file mode 100644 index 0000000..3cc403b --- /dev/null +++ b/lib/mailtrap/contact_fields_api.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +require_relative 'contact_field' + +module Mailtrap + class ContactFieldsAPI + include BaseAPI + + supported_options %i[name data_type merge_tag] + + response_class ContactField + + # Retrieves a specific contact field + # @param field_id [Integer] The contact field identifier + # @return [ContactField] Contact field object + # @!macro api_errors + def get(field_id) + base_get(field_id) + end + + # Creates a new contact field + # @param [Hash] options The parameters to create + # @option options [String] :name The contact field name + # @option options [String] :data_type The data type of the field + # @option options [String] :merge_tag The merge tag of the field + # @return [ContactField] Created contact field object + # @!macro api_errors + # @raise [ArgumentError] If invalid options are provided + def create(options) + base_create(options) + end + + # Updates an existing contact field + # @param field_id [Integer] The contact field ID + # @param [Hash] options The parameters to update + # @option options [String] :name The contact field name + # @option options [String] :merge_tag The merge tag of the field + # @return [ContactField] Updated contact field object + # @!macro api_errors + # @raise [ArgumentError] If invalid options are provided + def update(field_id, options) + base_update(field_id, options, %i[name merge_tag]) + end + + # Deletes a contact field + # @param field_id [Integer] The contact field ID + # @return nil + # @!macro api_errors + def delete(field_id) + base_delete(field_id) + end + + # Lists all contact fields for the account + # @return [Array] Array of contact field objects + # @!macro api_errors + def list + base_list + end + + private + + def base_path + "/api/accounts/#{account_id}/contacts/fields" + end + end +end diff --git a/lib/mailtrap/contact_list.rb b/lib/mailtrap/contact_list.rb new file mode 100644 index 0000000..dd597d2 --- /dev/null +++ b/lib/mailtrap/contact_list.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Mailtrap + # Data Transfer Object for Contact List + # @see https://api-docs.mailtrap.io/docs/mailtrap-api-docs/6ec7a37234af2-contact-list + # @attr_reader id [Integer] The contact list ID + # @attr_reader name [String] The name of the contact list + ContactList = Struct.new(:id, :name, keyword_init: true) do + # @return [Hash] The contact list attributes as a hash + def to_h + super.compact + end + end +end diff --git a/lib/mailtrap/contact_lists_api.rb b/lib/mailtrap/contact_lists_api.rb new file mode 100644 index 0000000..5d87055 --- /dev/null +++ b/lib/mailtrap/contact_lists_api.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +require_relative 'contact_list' + +module Mailtrap + class ContactListsAPI + include BaseAPI + + supported_options %i[name] + + response_class ContactList + + # Retrieves a specific contact list + # @param list_id [Integer] The contact list identifier + # @return [ContactList] Contact list object + # @!macro api_errors + def get(list_id) + base_get(list_id) + end + + # Creates a new contact list + # @param [Hash] options The parameters to create + # @option options [String] :name The contact list name + # @return [ContactList] Created contact list object + # @!macro api_errors + # @raise [ArgumentError] If invalid options are provided + def create(options) + base_create(options) + end + + # Updates an existing contact list + # @param list_id [Integer] The contact list ID + # @param [Hash] options The parameters to update + # @option options [String] :name The contact list name + # @return [ContactList] Updated contact list object + # @!macro api_errors + # @raise [ArgumentError] If invalid options are provided + def update(list_id, options) + base_update(list_id, options) + end + + # Deletes a contact list + # @param list_id [Integer] The contact list ID + # @return nil + # @!macro api_errors + def delete(list_id) + base_delete(list_id) + end + + # Lists all contact lists for the account + # @return [Array] Array of contact list objects + # @!macro api_errors + def list + base_list + end + + private + + def base_path + "/api/accounts/#{account_id}/contacts/lists" + end + end +end diff --git a/lib/mailtrap/contacts_api.rb b/lib/mailtrap/contacts_api.rb new file mode 100644 index 0000000..6f08d8a --- /dev/null +++ b/lib/mailtrap/contacts_api.rb @@ -0,0 +1,96 @@ +# frozen_string_literal: true + +require_relative 'contact' + +module Mailtrap + class ContactsAPI + include BaseAPI + + supported_options %i[email fields list_ids] + response_class Contact + + # Retrieves a specific contact + # @param contact_id [String] The contact identifier, which can be either a UUID or an email address + # @return [Contact] Contact object + # @!macro api_errors + def get(contact_id) + base_get(contact_id) + end + + # Creates a new contact + # @param [Hash] options The parameters to create + # @option options [String] :email The contact's email address + # @option options [Hash] :fields The contact's fields + # @option options [Array] :list_ids The contact's list IDs + # @return [Contact] Created contact object + # @!macro api_errors + # @raise [ArgumentError] If invalid options are provided + def create(options) + base_create(options) + end + + # Deletes a contact + # @param contact_id [String] The contact ID + # @return nil + # @!macro api_errors + def delete(contact_id) + base_delete(contact_id) + end + + # Updates an existing contact + # @param contact_id [String] The contact ID or email address + # @param [Hash] options The parameters to update + # @option options [String] :email The contact's email address + # @option options [Hash] :fields The contact's fields + # @option options [Boolean] :unsubscribed Whether to unsubscribe the contact + # @return [ContactUpdateResponse] Updated contact object + # @!macro api_errors + # @raise [ArgumentError] If invalid options are provided + def update(contact_id, options) + base_update(contact_id, options, %i[email fields unsubscribed]) + end + + # Adds a contact to specified lists + # @param contact_id [String] The contact ID or email address + # @param contact_list_ids [Array] Array of list IDs to add the contact to + # @return [ContactUpdateResponse] Updated contact object + # @!macro api_errors + def add_to_lists(contact_id, contact_list_ids = []) + update_lists(contact_id, list_ids_included: contact_list_ids) + end + + # Removes a contact from specified lists + # @param contact_id [String] The contact ID or email address + # @param contact_list_ids [Array] Array of list IDs to remove the contact from + # @return [ContactUpdateResponse] Updated contact object + # @!macro api_errors + def remove_from_lists(contact_id, contact_list_ids = []) + update_lists(contact_id, list_ids_excluded: contact_list_ids) + end + + private + + def base_update(id, options, supported_options_override = supported_options) + validate_options!(options, supported_options_override) + + response = client.patch("#{base_path}/#{id}", wrap_request(options)) + build_entity(response, ContactUpdateResponse) + end + + def update_lists(contact_id, options) + base_update(contact_id, options, %i[list_ids_included list_ids_excluded]) + end + + def wrap_request(options) + { contact: options } + end + + def handle_response(response) + build_entity(response[:data], response_class) + end + + def base_path + "/api/accounts/#{account_id}/contacts" + end + end +end diff --git a/lib/mailtrap/email_templates_api.rb b/lib/mailtrap/email_templates_api.rb index a929839..49f80e6 100644 --- a/lib/mailtrap/email_templates_api.rb +++ b/lib/mailtrap/email_templates_api.rb @@ -4,27 +4,17 @@ module Mailtrap class EmailTemplatesAPI - SUPPORTED_OPTIONS = %i[name subject category body_html body_text].freeze - private_constant :SUPPORTED_OPTIONS + include BaseAPI - attr_reader :account_id, :client + supported_options %i[name subject category body_html body_text] - # @param account_id [Integer] The account ID - # @param client [Mailtrap::Client] The client instance - # @raise [ArgumentError] If account_id is nil - def initialize(account_id = ENV.fetch('MAILTRAP_ACCOUNT_ID'), client = Client.new) - raise ArgumentError, 'account_id is required' if account_id.nil? - - @account_id = account_id - @client = client - end + response_class EmailTemplate # Lists all email templates for the account # @return [Array] Array of template objects # @!macro api_errors def list - response = client.get(base_path) - response.map { |template| build_email_template(template) } + base_list end # Retrieves a specific email template @@ -32,8 +22,7 @@ def list # @return [EmailTemplate] Template object # @!macro api_errors def get(template_id) - response = client.get("#{base_path}/#{template_id}") - build_email_template(response) + base_get(template_id) end # Creates a new email template @@ -47,10 +36,7 @@ def get(template_id) # @!macro api_errors # @raise [ArgumentError] If invalid options are provided def create(options) - validate_options!(options) - - response = client.post(base_path, email_template: options) - build_email_template(response) + base_create(options) end # Updates an existing email template @@ -65,10 +51,7 @@ def create(options) # @!macro api_errors # @raise [ArgumentError] If invalid options are provided def update(template_id, options) - validate_options!(options) - - response = client.patch("#{base_path}/#{template_id}", email_template: options) - build_email_template(response) + base_update(template_id, options) end # Deletes an email template @@ -76,24 +59,17 @@ def update(template_id, options) # @return nil # @!macro api_errors def delete(template_id) - client.delete("#{base_path}/#{template_id}") + base_delete(template_id) end private - def build_email_template(options) - EmailTemplate.new(options.slice(*EmailTemplate.members)) - end - def base_path "/api/accounts/#{account_id}/email_templates" end - def validate_options!(options) - invalid_options = options.keys - SUPPORTED_OPTIONS - return if invalid_options.empty? - - raise ArgumentError, "invalid options are given: #{invalid_options}, supported_options: #{SUPPORTED_OPTIONS}" + def wrap_request(options) + { email_template: options } end end end diff --git a/spec/mailtrap/contact_fields_api_spec.rb b/spec/mailtrap/contact_fields_api_spec.rb new file mode 100644 index 0000000..a0474b1 --- /dev/null +++ b/spec/mailtrap/contact_fields_api_spec.rb @@ -0,0 +1,203 @@ +# frozen_string_literal: true + +RSpec.describe Mailtrap::ContactFieldsAPI do + let(:client) { described_class.new('1111111', Mailtrap::Client.new(api_key: 'correct-api-key')) } + let(:base_url) { 'https://mailtrap.io/api/accounts/1111111' } + + describe '#list' do + let(:expected_response) do + [ + { 'id' => 1, 'name' => 'First Name', 'data_type' => 'text', 'merge_tag' => 'first_name' }, + { 'id' => 2, 'name' => 'Age', 'data_type' => 'integer', 'merge_tag' => 'age' } + ] + end + + it 'returns all contact fields' do + stub_request(:get, "#{base_url}/contacts/fields") + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.list + expect(response).to be_a(Array) + expect(response.length).to eq(2) + expect(response.first).to have_attributes(id: 1, name: 'First Name', data_type: 'text', merge_tag: 'first_name') + end + end + + describe '#get' do + let(:contact_field_id) { 1 } + let(:expected_response) do + { 'id' => 1, 'name' => 'First Name', 'data_type' => 'text', 'merge_tag' => 'first_name' } + end + + it 'returns a specific contact field' do + stub_request(:get, "#{base_url}/contacts/fields/#{contact_field_id}") + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.get(contact_field_id) + expect(response).to have_attributes(id: 1, name: 'First Name', data_type: 'text', merge_tag: 'first_name') + end + + it 'raises error when contact field not found' do + stub_request(:get, "#{base_url}/contacts/fields/999") + .to_return( + status: 404, + body: { 'error' => 'Not Found' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.get(999) }.to raise_error(Mailtrap::Error) + end + end + + describe '#create' do + let(:contact_field_data) do + { + name: 'Last Name', + data_type: 'text', + merge_tag: 'last_name' + } + end + let(:expected_response) do + { 'id' => 3, 'name' => 'Last Name', 'data_type' => 'text', 'merge_tag' => 'last_name' } + end + + it 'creates a new contact field' do + stub_request(:post, "#{base_url}/contacts/fields") + .with( + body: contact_field_data.to_json + ) + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.create(contact_field_data) + expect(response).to have_attributes(id: 3, name: 'Last Name', data_type: 'text', merge_tag: 'last_name') + end + + it 'raises error when rate limit exceeded' do + stub_request(:post, "#{base_url}/contacts/fields") + .with( + body: contact_field_data.to_json + ) + .to_return( + status: 429, + body: { 'errors' => 'Rate limit exceeded' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.create(contact_field_data) }.to raise_error(Mailtrap::Error) + end + + it 'raises error when validation fails' do + invalid_data = { name: '', data_type: 'invalid_type', merge_tag: 'tag' } + stub_request(:post, "#{base_url}/contacts/fields") + .with( + body: invalid_data.to_json + ) + .to_return( + status: 422, + body: { 'errors' => { 'name' => ['cannot be blank'], + 'data_type' => ['is not included in the list'] } }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.create(invalid_data) }.to raise_error(Mailtrap::Error) + end + end + + describe '#update' do + let(:contact_field_id) { 2 } + let(:update_data) do + { + name: 'Updated Age', + merge_tag: 'updated_age' + } + end + let(:expected_response) do + { 'id' => 2, 'name' => 'Updated Age', 'data_type' => 'integer', 'merge_tag' => 'updated_age' } + end + + it 'updates a contact field' do + stub_request(:patch, "#{base_url}/contacts/fields/#{contact_field_id}") + .with( + body: update_data.to_json + ) + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.update(contact_field_id, update_data) + expect(response).to have_attributes(id: 2, name: 'Updated Age', data_type: 'integer', merge_tag: 'updated_age') + end + + it 'raises error when contact field not found' do + stub_request(:patch, "#{base_url}/contacts/fields/999") + .with( + body: update_data.to_json + ) + .to_return( + status: 404, + body: { 'error' => 'Not Found' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.update(999, update_data) }.to raise_error(Mailtrap::Error) + end + + it 'raises error when data_type is set' do + invalid_data = { name: 'Updated Age', data_type: 'invalid_type' } + stub_request(:patch, "#{base_url}/contacts/fields/#{contact_field_id}") + .with( + body: invalid_data.to_json + ) + + expect { client.update(contact_field_id, invalid_data) }.to raise_error(ArgumentError) + end + end + + describe '#delete' do + let(:contact_field_id) { 1 } + + it 'deletes a contact field' do + stub_request(:delete, "#{base_url}/contacts/fields/#{contact_field_id}") + .to_return(status: 204) + + response = client.delete(contact_field_id) + expect(response).to be_nil + end + + it 'raises error when contact field not found' do + stub_request(:delete, "#{base_url}/contacts/fields/999") + .to_return( + status: 404, + body: { 'error' => 'Not Found' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.delete(999) }.to raise_error(Mailtrap::Error) + end + + it 'raises error when unauthorized' do + stub_request(:delete, "#{base_url}/contacts/fields/#{contact_field_id}") + .to_return( + status: 401, + body: { 'error' => 'Unauthorized' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.delete(contact_field_id) }.to raise_error(Mailtrap::AuthorizationError) + end + end +end diff --git a/spec/mailtrap/contact_lists_api_spec.rb b/spec/mailtrap/contact_lists_api_spec.rb new file mode 100644 index 0000000..3d5d44d --- /dev/null +++ b/spec/mailtrap/contact_lists_api_spec.rb @@ -0,0 +1,180 @@ +# frozen_string_literal: true + +RSpec.describe Mailtrap::ContactListsAPI do + let(:client) { described_class.new('1111111', Mailtrap::Client.new(api_key: 'correct-api-key')) } + let(:base_url) { 'https://mailtrap.io/api/accounts/1111111' } + + describe '#list' do + let(:expected_response) do + [ + { 'id' => 1, 'name' => 'List 1' }, + { 'id' => 2, 'name' => 'List 2' } + ] + end + + it 'returns all contact lists' do + stub_request(:get, "#{base_url}/contacts/lists") + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.list + expect(response).to be_a(Array) + expect(response.length).to eq(2) + expect(response.first).to have_attributes(id: 1, name: 'List 1') + end + end + + describe '#get' do + let(:contact_list_id) { 1 } + let(:expected_response) do + { 'id' => 1, 'name' => 'List 1' } + end + + it 'returns a specific contact list' do + stub_request(:get, "#{base_url}/contacts/lists/#{contact_list_id}") + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.get(contact_list_id) + expect(response).to have_attributes(id: 1, name: 'List 1') + end + + it 'raises error when contact list not found' do + stub_request(:get, "#{base_url}/contacts/lists/999") + .to_return( + status: 404, + body: { 'error' => 'Not Found' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.get(999) }.to raise_error(Mailtrap::Error) + end + end + + describe '#create' do + let(:contact_list_name) { 'List 1' } + let(:expected_response) do + { 'id' => 1, 'name' => contact_list_name } + end + + it 'creates a new contact list' do + stub_request(:post, "#{base_url}/contacts/lists") + .with( + body: { name: contact_list_name }.to_json + ) + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.create(name: contact_list_name) + expect(response).to have_attributes(id: 1, name: contact_list_name) + end + + it 'raises error when rate limit exceeded' do + stub_request(:post, "#{base_url}/contacts/lists") + .with( + body: { name: contact_list_name }.to_json + ) + .to_return( + status: 429, + body: { 'errors' => 'Rate limit exceeded' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.create(name: contact_list_name) }.to raise_error(Mailtrap::Error) + end + end + + describe '#update' do + let(:contact_list_id) { 2 } + let(:new_name) { 'List 2' } + let(:expected_response) do + { 'id' => 2, 'name' => new_name } + end + + it 'updates a contact list' do + stub_request(:patch, "#{base_url}/contacts/lists/#{contact_list_id}") + .with( + body: { name: new_name }.to_json + ) + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.update(contact_list_id, name: new_name) + expect(response).to have_attributes(id: 2, name: new_name) + end + + it 'raises error when contact list not found' do + stub_request(:patch, "#{base_url}/contacts/lists/999") + .with( + body: { name: new_name }.to_json + ) + .to_return( + status: 404, + body: { 'error' => 'Not Found' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.update(999, name: new_name) }.to raise_error(Mailtrap::Error) + end + + it 'raises error when validation fails' do + stub_request(:patch, "#{base_url}/contacts/lists/#{contact_list_id}") + .with( + body: { name: '' }.to_json + ) + .to_return( + status: 422, + body: { 'errors' => { 'name' => ['cannot be blank'] } }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.update(contact_list_id, name: '') }.to raise_error(Mailtrap::Error) + end + end + + describe '#delete' do + let(:contact_list_id) { 1 } + + it 'deletes a contact list' do + stub_request(:delete, "#{base_url}/contacts/lists/#{contact_list_id}") + .to_return(status: 204) + + response = client.delete(contact_list_id) + expect(response).to be_nil + end + + it 'raises error when contact list not found' do + stub_request(:delete, "#{base_url}/contacts/lists/999") + .to_return( + status: 404, + body: { 'error' => 'Not Found' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.delete(999) }.to raise_error(Mailtrap::Error) + end + + it 'raises error when unauthorized' do + stub_request(:delete, "#{base_url}/contacts/lists/#{contact_list_id}") + .to_return( + status: 401, + body: { 'error' => 'Unauthorized' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.delete(contact_list_id) }.to raise_error(Mailtrap::AuthorizationError) + end + end +end diff --git a/spec/mailtrap/contacts_api_spec.rb b/spec/mailtrap/contacts_api_spec.rb new file mode 100644 index 0000000..7adc218 --- /dev/null +++ b/spec/mailtrap/contacts_api_spec.rb @@ -0,0 +1,478 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mailtrap::ContactsAPI do + let(:client) { described_class.new('1111111', Mailtrap::Client.new(api_key: 'correct-api-key')) } + let(:base_url) { 'https://mailtrap.io/api/accounts/1111111' } + let(:email) { 'test@example.com' } + let(:contact_id) { '019706a8-9612-77be-8586-4f26816b467a' } + + describe '#get' do + context 'when contact_id is a UUID' do + let(:expected_response) do + { + 'data' => { + 'id' => contact_id, + 'email' => email, + 'created_at' => 1_748_163_401_202, + 'updated_at' => 1_748_163_401_202, + 'list_ids' => [1, 2], + 'status' => 'subscribed', + 'fields' => { + 'first_name' => 'John', + 'last_name' => nil + } + } + } + end + + it 'returns contact by UUID' do + stub_request(:get, "#{base_url}/contacts/#{contact_id}") + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.get(contact_id) + expect(response).to have_attributes( + email:, + status: 'subscribed' + ) + end + end + + context 'when contact_id is an email' do + let(:expected_response) do + { + 'data' => { + 'id' => '019706a8-9612-77be-8586-4f26816b467a', + 'email' => email, + 'created_at' => 1_748_163_401_202, + 'updated_at' => 1_748_163_401_202, + 'list_ids' => [1, 2], + 'status' => 'subscribed', + 'fields' => { + 'first_name' => 'John', + 'last_name' => nil + } + } + } + end + + it 'returns contact by email' do + stub_request(:get, "#{base_url}/contacts/#{CGI.escape(email)}") + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.get(email) + expect(response).to have_attributes(email:) + end + + it 'handles special characters in email' do + special_email = 'test+special@example.com' + stub_request(:get, "#{base_url}/contacts/#{CGI.escape(special_email)}") + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.get(special_email) + expect(response).to have_attributes(email:) + end + end + + context 'when contact is not found' do + it 'raises error for non-existent UUID' do + stub_request(:get, "#{base_url}/contacts/non-existent-uuid") + .to_return( + status: 404, + body: { 'error' => 'Not Found' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.get('non-existent-uuid') }.to raise_error(Mailtrap::Error) + end + + it 'raises error for non-existent email' do + non_existent_email = 'nonexistent@example.com' + stub_request(:get, "#{base_url}/contacts/#{CGI.escape(non_existent_email)}") + .to_return( + status: 404, + body: { 'error' => 'Not Found' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.get(non_existent_email) }.to raise_error(Mailtrap::Error) + end + end + end + + # rubocop:disable RSpec/MultipleMemoizedHelpers + describe '#create' do + let(:contact_data) do + { + email:, + fields: { first_name: 'John' }, + list_ids: [1, 2] + } + end + let(:expected_response) do + { + 'data' => { + 'id' => contact_id, + 'email' => email, + 'created_at' => 1_748_163_401_202, + 'updated_at' => 1_748_163_401_202, + 'list_ids' => [1, 2], + 'status' => 'subscribed', + 'fields' => { + 'first_name' => 'John', + 'last_name' => nil + } + } + } + end + + it 'creates a new contact' do + stub_request(:post, "#{base_url}/contacts") + .with( + body: { contact: contact_data }.to_json + ) + .to_return( + status: 201, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.create(contact_data) + expect(response).to have_attributes(email:) + end + + it 'raises error for invalid contact data' do + invalid_data = { email: 'invalid-email' } + stub_request(:post, "#{base_url}/contacts") + .with( + body: { contact: invalid_data }.to_json + ) + .to_return( + status: 422, + body: { 'errors' => { 'email' => ['is invalid'] } }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.create(invalid_data) }.to raise_error(Mailtrap::Error) + end + end + + describe '#update' do + let(:update_data) do + { + email:, + fields: { last_name: 'Smith' }, + unsubscribed: true + } + end + let(:expected_response) do + { + 'data' => { + 'id' => contact_id, + 'email' => email, + 'created_at' => 1_748_163_401_202, + 'updated_at' => 1_748_163_401_202, + 'list_ids' => [3], + 'status' => 'unsubscribed', + 'fields' => { + 'first_name' => 'John', + 'last_name' => 'Smith' + } + }, + 'action' => 'updated' + } + end + + it 'contact by id' do + stub_request(:patch, "#{base_url}/contacts/#{contact_id}") + .with( + body: { contact: update_data }.to_json + ) + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + response = client.update(contact_id, update_data) + + expect(response).to have_attributes( + data: have_attributes( + id: contact_id, + fields: include( + last_name: 'Smith' + ) + ), + action: 'updated' + ) + end + + it 'contact by email' do + stub_request(:patch, "#{base_url}/contacts/#{CGI.escape(email)}") + .with( + body: { contact: update_data }.to_json + ) + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.update(email, update_data) + expect(response).to have_attributes( + data: have_attributes( + email: + ) + ) + end + end + # rubocop:enable RSpec/MultipleMemoizedHelpers + + describe '#delete' do + it 'deletes contact by id' do + stub_request(:delete, "#{base_url}/contacts/#{contact_id}") + .to_return(status: 204) + + response = client.delete(contact_id) + expect(response).to be_nil + end + + it 'deletes contact by email' do + stub_request(:delete, "#{base_url}/contacts/#{CGI.escape(email)}") + .to_return(status: 204) + + response = client.delete(email) + expect(response).to be_nil + end + end + + describe '#add_to_lists' do # rubocop:disable RSpec/MultipleMemoizedHelpers + let(:list_ids) { [1, 2, 3] } + let(:expected_response) do + { + 'data' => { + 'id' => contact_id, + 'email' => email, + 'created_at' => 1_748_163_401_202, + 'updated_at' => 1_748_163_401_202, + 'list_ids' => [1, 2, 3, 4, 5], + 'status' => 'subscribed', + 'fields' => { + 'first_name' => 'John', + 'last_name' => 'Smith' + } + }, + 'action' => 'updated' + } + end + + it 'adds contact to lists by id' do + stub_request(:patch, "#{base_url}/contacts/#{contact_id}") + .with( + body: { contact: { list_ids_included: list_ids } }.to_json + ) + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.add_to_lists(contact_id, list_ids) + expect(response).to have_attributes( + data: have_attributes( + id: contact_id, + list_ids: include(1, 2, 3, 4, 5) + ), + action: 'updated' + ) + end + + it 'adds contact to lists by email' do + stub_request(:patch, "#{base_url}/contacts/#{CGI.escape(email)}") + .with( + body: { contact: { list_ids_included: list_ids } }.to_json + ) + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.add_to_lists(email, list_ids) + expect(response).to have_attributes( + data: have_attributes( + email: + ) + ) + end + + it 'handles empty list_ids array' do + stub_request(:patch, "#{base_url}/contacts/#{contact_id}") + .with( + body: { contact: { list_ids_included: [] } }.to_json + ) + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.add_to_lists(contact_id, []) + expect(response).to have_attributes( + data: have_attributes( + id: contact_id + ) + ) + end + + it 'uses default empty array when no list_ids provided' do + stub_request(:patch, "#{base_url}/contacts/#{contact_id}") + .with( + body: { contact: { list_ids_included: [] } }.to_json + ) + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.add_to_lists(contact_id) + expect(response).to have_attributes( + data: have_attributes( + id: contact_id + ) + ) + end + end + + describe '#remove_from_lists' do # rubocop:disable RSpec/MultipleMemoizedHelpers + let(:list_ids) { [1, 2] } + let(:expected_response) do + { + 'data' => { + 'id' => contact_id, + 'email' => email, + 'created_at' => 1_748_163_401_202, + 'updated_at' => 1_748_163_401_202, + 'list_ids' => [3, 4, 5], + 'status' => 'subscribed', + 'fields' => { + 'first_name' => 'John', + 'last_name' => 'Smith' + } + }, + 'action' => 'updated' + } + end + + it 'removes contact from lists by id' do + stub_request(:patch, "#{base_url}/contacts/#{contact_id}") + .with( + body: { contact: { list_ids_excluded: list_ids } }.to_json + ) + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.remove_from_lists(contact_id, list_ids) + expect(response).to have_attributes( + data: have_attributes( + id: contact_id, + list_ids: include(3, 4, 5) + ), + action: 'updated' + ) + end + + it 'removes contact from lists by email' do + stub_request(:patch, "#{base_url}/contacts/#{CGI.escape(email)}") + .with( + body: { contact: { list_ids_excluded: list_ids } }.to_json + ) + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.remove_from_lists(email, list_ids) + expect(response).to have_attributes( + data: have_attributes( + email: + ) + ) + end + + it 'handles empty list_ids array' do + stub_request(:patch, "#{base_url}/contacts/#{contact_id}") + .with( + body: { contact: { list_ids_excluded: [] } }.to_json + ) + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.remove_from_lists(contact_id, []) + expect(response).to have_attributes( + data: have_attributes( + id: contact_id + ) + ) + end + + it 'uses default empty array when no list_ids provided' do + stub_request(:patch, "#{base_url}/contacts/#{contact_id}") + .with( + body: { contact: { list_ids_excluded: [] } }.to_json + ) + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.remove_from_lists(contact_id) + expect(response).to have_attributes( + data: have_attributes( + id: contact_id + ) + ) + end + + it 'handles special characters in email' do + special_email = 'test+special@example.com' + stub_request(:patch, "#{base_url}/contacts/#{CGI.escape(special_email)}") + .with( + body: { contact: { list_ids_excluded: list_ids } }.to_json + ) + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.remove_from_lists(special_email, list_ids) + expect(response).to have_attributes( + data: have_attributes( + email: + ) + ) + end + end +end