From 0a21de3375f3d0df605de2e30d9bfd581b325205 Mon Sep 17 00:00:00 2001 From: sarco3t <14gainward88@gmail.com> Date: Mon, 14 Jul 2025 18:41:22 +0300 Subject: [PATCH 1/4] added ContactImportAPI --- examples/contacts_api.rb | 41 +++++++- lib/mailtrap.rb | 1 + lib/mailtrap/contact_import.rb | 23 +++++ lib/mailtrap/contact_imports_api.rb | 45 +++++++++ spec/mailtrap/contact_imports_api_spec.rb | 111 ++++++++++++++++++++++ 5 files changed, 218 insertions(+), 3 deletions(-) create mode 100644 lib/mailtrap/contact_import.rb create mode 100644 lib/mailtrap/contact_imports_api.rb create mode 100644 spec/mailtrap/contact_imports_api_spec.rb diff --git a/examples/contacts_api.rb b/examples/contacts_api.rb index 6e48aba..3f2f534 100644 --- a/examples/contacts_api.rb +++ b/examples/contacts_api.rb @@ -4,6 +4,7 @@ contact_lists = Mailtrap::ContactListsAPI.new 3229, client contacts = Mailtrap::ContactsAPI.new 3229, client contact_fields = Mailtrap::ContactFieldsAPI.new 3229, client +contact_imports = Mailtrap::ContactImportsAPI.new 3229, client # Set your API credentials as environment variables # export MAILTRAP_API_KEY='your-api-key' @@ -12,6 +13,7 @@ # contact_lists = Mailtrap::ContactListsAPI.new # contacts = Mailtrap::ContactsAPI.new # contact_fields = Mailtrap::ContactFieldsAPI.new +# contact_imports = Mailtrap::ContactImportsAPI.new # Create new contact list list = contact_lists.create(name: 'Test List') @@ -135,8 +137,41 @@ # Delete contact contacts.delete(contact.id) -# Delete contact list -contact_lists.delete(list.id) - # Delete contact field contact_fields.delete(field.id) + +# Create a new contact import +contact_import = contact_imports.create( + [ + { + email: 'imported@example.com', + fields: { + first_name: 'Jane', + }, + list_ids_included: [list.id], + list_ids_excluded: [] + } + ] +) +# => ContactImport.new( +# id: 1, +# status: 'created', +# list_ids: [1], +# created_contacts_count: 1, +# updated_contacts_count: 0, +# contacts_over_limit_count: 0 +# ) + +# Get a contact import by ID +contact_imports.get(contact_import.id) +# => ContactImport.new( +# id: 1, +# status: 'started', +# list_ids: [1], +# created_contacts_count: 1, +# updated_contacts_count: 0, +# contacts_over_limit_count: 0 +# ) + +# Delete contact list +contact_lists.delete(list.id) diff --git a/lib/mailtrap.rb b/lib/mailtrap.rb index 33e4dad..9d3b856 100644 --- a/lib/mailtrap.rb +++ b/lib/mailtrap.rb @@ -8,6 +8,7 @@ require_relative 'mailtrap/contacts_api' require_relative 'mailtrap/contact_lists_api' require_relative 'mailtrap/contact_fields_api' +require_relative 'mailtrap/contact_imports_api' module Mailtrap # @!macro api_errors diff --git a/lib/mailtrap/contact_import.rb b/lib/mailtrap/contact_import.rb new file mode 100644 index 0000000..9194157 --- /dev/null +++ b/lib/mailtrap/contact_import.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Mailtrap + # Data Transfer Object for Contact Import + # @attr_reader id [String] The contact import ID + # @attr_reader status [String] The status of the import (created, started, finished, failed) + # @attr_reader created_contacts_count [Integer, nil] Number of contacts created in this import + # @attr_reader updated_contacts_count [Integer, nil] Number of contacts updated in this import + # @attr_reader contacts_over_limit_count [Integer, nil] Number of contacts over the allowed limit + ContactImport = Struct.new( + :id, + :status, + :created_contacts_count, + :updated_contacts_count, + :contacts_over_limit_count, + keyword_init: true + ) do + # @return [Hash] The contact attributes as a hash + def to_h + super.compact + end + end +end diff --git a/lib/mailtrap/contact_imports_api.rb b/lib/mailtrap/contact_imports_api.rb new file mode 100644 index 0000000..2445e39 --- /dev/null +++ b/lib/mailtrap/contact_imports_api.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +require_relative 'contact_import' + +module Mailtrap + class ContactImportsAPI + include BaseAPI + + self.supported_options = %i[email fields list_ids_included list_ids_excluded] + + self.response_class = ContactImport + + # Retrieves a specific contact import + # @param import_id [String] The contact import identifier + # @return [ContactImport] Contact import object + # @!macro api_errors + def get(import_id) + base_get(import_id) + end + + # Create contacts import + # @param contacts [Array] Array of contact objects to import + # Each contact object should have the following keys: + # - email [String] The contact's email address + # - fields [Hash] Object of fields in the format: field_merge_tag => String, Integer, Float, Boolean, or ISO-8601 date string (yyyy-mm-dd) # rubocop:disable Layout/LineLength + # - list_ids_included [Array] List IDs to include the contact in + # - list_ids_excluded [Array] List IDs to exclude the contact from + # @return [ContactImport] Created contact list object + # @!macro api_errors + # @raise [ArgumentError] If invalid options are provided + def create(contacts) + contacts.each do |contact| + validate_options!(contact, supported_options) + end + response = client.post(base_path, contacts:) + handle_response(response) + end + + private + + def base_path + "/api/accounts/#{account_id}/contacts/imports" + end + end +end diff --git a/spec/mailtrap/contact_imports_api_spec.rb b/spec/mailtrap/contact_imports_api_spec.rb new file mode 100644 index 0000000..8694b61 --- /dev/null +++ b/spec/mailtrap/contact_imports_api_spec.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +RSpec.describe Mailtrap::ContactImportsAPI 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 '#get' do + let(:import_id) { 'import-123' } + let(:expected_response) do + { + 'id' => 'import-123', + 'status' => 'finished', + 'created_contacts_count' => 10, + 'updated_contacts_count' => 2, + 'contacts_over_limit_count' => 0 + } + end + + it 'returns a specific contact import' do + stub_request(:get, "#{base_url}/contacts/imports/#{import_id}") + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.get(import_id) + expect(response).to have_attributes( + id: 'import-123', + status: 'finished', + created_contacts_count: 10, + updated_contacts_count: 2, + contacts_over_limit_count: 0 + ) + end + + it 'raises error when contact import not found' do + stub_request(:get, "#{base_url}/contacts/imports/not-found") + .to_return( + status: 404, + body: { 'error' => 'Not Found' }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.get('not-found') }.to raise_error(Mailtrap::Error) + end + end + + describe '#create' do + let(:contacts) do + [ + { + email: 'example@example.com', + fields: { + fname: 'John', + age: 30, + is_subscribed: true, + birthday: '1990-05-15' + }, + list_ids_included: [1, 2], + list_ids_excluded: [3] + } + ] + end + let(:expected_response) do + { + 'id' => 'import-456', + 'status' => 'created', + 'created_contacts_count' => 1, + 'updated_contacts_count' => 0, + 'contacts_over_limit_count' => 0 + } + end + + it 'creates a new contact import with hash' do + stub_request(:post, "#{base_url}/contacts/imports") + .with(body: { contacts: }.to_json) + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.create(contacts) + expect(response).to have_attributes( + id: 'import-456', + status: 'created', + created_contacts_count: 1, + updated_contacts_count: 0, + contacts_over_limit_count: 0 + ) + end + + it 'raises error when invalid options are provided' do + invalid_contacts = [contacts.first.merge(foo: 'bar')] + expect { client.create(invalid_contacts) }.to raise_error(ArgumentError, /invalid options are given/) + end + + it 'raises error when API returns an error' do + stub_request(:post, "#{base_url}/contacts/imports") + .with(body: { contacts: }.to_json) + .to_return( + status: 422, + body: { 'errors' => { 'email' => ['is invalid'] } }.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + expect { client.create(contacts) }.to raise_error(Mailtrap::Error) + end + end +end From 42839bc07b9d6c88b4d70567df09d828ee0bdd5b Mon Sep 17 00:00:00 2001 From: sarco3t <14gainward88@gmail.com> Date: Wed, 30 Jul 2025 11:26:37 +0300 Subject: [PATCH 2/4] added ContactsImportRequest --- examples/contacts_api.rb | 56 +++++++++++++++++ lib/mailtrap/contact_imports_api.rb | 12 ++-- lib/mailtrap/contacts_import_request.rb | 63 +++++++++++++++++++ spec/mailtrap/contact_imports_api_spec.rb | 77 +++++++++++++++++++++++ 4 files changed, 204 insertions(+), 4 deletions(-) create mode 100644 lib/mailtrap/contacts_import_request.rb diff --git a/examples/contacts_api.rb b/examples/contacts_api.rb index 3f2f534..bebe9bd 100644 --- a/examples/contacts_api.rb +++ b/examples/contacts_api.rb @@ -173,5 +173,61 @@ # contacts_over_limit_count: 0 # ) +# Create a new contact import using ContactsImportRequest builder +import_request = Mailtrap::ContactsImportRequest.new + +# Add contacts using the builder pattern +import_request + .upsert( + email: 'john.doe@example.com', + fields: { first_name: 'John' } + ) + .add_to_lists(email: 'john.doe@example.com', list_ids: [list.id]) + .upsert( + email: 'jane.smith@example.com', + fields: { first_name: 'Jane' } + ) + .add_to_lists(email: 'jane.smith@example.com', list_ids: [list.id]) + .remove_from_lists(email: 'jane.smith@example.com', list_ids: []) + +# Execute the import +contact_imports.create(import_request) +# => ContactImport.new( +# id: 2, +# status: 'created', +# list_ids: [1], +# created_contacts_count: 2, +# updated_contacts_count: 0, +# contacts_over_limit_count: 0 +# ) + +# Alternative: Step-by-step building +builder = Mailtrap::ContactsImportRequest.new +builder.upsert(email: 'jane.doe@example.com', fields: { first_name: 'Jane' }) +builder.add_to_lists(email: 'jane.doe@example.com', list_ids: [list.id]) + +contact_import_2 = contact_imports.create(builder) +# => ContactImport.new( +# id: 3, +# status: 'created', +# list_ids: [1], +# created_contacts_count: 1, +# updated_contacts_count: 0, +# contacts_over_limit_count: 0 +# ) + +sleep 3 # Wait for the import to complete (if needed) + +# Get the import status +contact_imports.get(contact_import_2.id) +# => ContactImport.new( +# id: 3, +# status: 'completed', +# list_ids: [1], +# created_contacts_count: 1, +# updated_contacts_count: 0, +# contacts_over_limit_count: 0 +# ) + # Delete contact list contact_lists.delete(list.id) diff --git a/lib/mailtrap/contact_imports_api.rb b/lib/mailtrap/contact_imports_api.rb index 2445e39..dcf54f7 100644 --- a/lib/mailtrap/contact_imports_api.rb +++ b/lib/mailtrap/contact_imports_api.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require_relative 'contact_import' +require_relative 'contacts_import_request' module Mailtrap class ContactImportsAPI @@ -19,8 +20,9 @@ def get(import_id) end # Create contacts import - # @param contacts [Array] Array of contact objects to import - # Each contact object should have the following keys: + # @param contacts [Array, ContactsImportRequest, #to_a] Any object that responds to #to_a and returns an array of contact hashes. # rubocop:disable Layout/LineLength + # Accepts Array, ContactsImportRequest, or any other object implementing #to_a + # When using Array, each contact object should have the following keys: # - email [String] The contact's email address # - fields [Hash] Object of fields in the format: field_merge_tag => String, Integer, Float, Boolean, or ISO-8601 date string (yyyy-mm-dd) # rubocop:disable Layout/LineLength # - list_ids_included [Array] List IDs to include the contact in @@ -29,12 +31,14 @@ def get(import_id) # @!macro api_errors # @raise [ArgumentError] If invalid options are provided def create(contacts) - contacts.each do |contact| + contact_data = contacts.to_a + contact_data.each do |contact| validate_options!(contact, supported_options) end - response = client.post(base_path, contacts:) + response = client.post(base_path, contacts: contact_data) handle_response(response) end + alias start create private diff --git a/lib/mailtrap/contacts_import_request.rb b/lib/mailtrap/contacts_import_request.rb new file mode 100644 index 0000000..2a280cf --- /dev/null +++ b/lib/mailtrap/contacts_import_request.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Mailtrap + # A builder class for creating contact import requests + # Allows you to build a collection of contacts with their associated fields and list memberships + class ContactsImportRequest + def initialize + @data = {} + end + + # Creates or updates a contact with the provided email and fields + # @param email [String] The contact's email address + # @param fields [Hash] Contact fields in the format: field_merge_tag => String, Integer, Float, Boolean, or ISO-8601 date string (yyyy-mm-dd) # rubocop:disable Layout/LineLength + # @return [ContactsImportRequest] Returns self for method chaining + def upsert(email:, fields: {}) + unless @data[email] + @data[email] = { email:, fields:, list_ids_included: [], list_ids_excluded: [] } + return self + end + + @data[email][:fields] = fields + + self + end + + # Adds a contact to the specified lists + # @param email [String] The contact's email address + # @param list_ids [Array] Array of list IDs to add the contact to + # @return [ContactsImportRequest] Returns self for method chaining + def add_to_lists(email:, list_ids:) + unless @data[email] + @data[email] = { email:, fields: {}, list_ids_included: list_ids, list_ids_excluded: [] } + return self + end + + @data[email][:list_ids_included].concat(list_ids) + + self + end + + # Removes a contact from the specified lists + # @param email [String] The contact's email address + # @param list_ids [Array] Array of list IDs to remove the contact from + # @return [ContactsImportRequest] Returns self for method chaining + def remove_from_lists(email:, list_ids:) + unless @data[email] + @data[email] = { email:, fields: {}, list_ids_included: [], list_ids_excluded: list_ids } + return self + end + + @data[email][:list_ids_excluded].concat(list_ids) + + self + end + + # Converts the import request to a JSON-serializable array + # @return [Array] Array of contact objects ready for import + def as_json + @data.values + end + alias to_a as_json + end +end diff --git a/spec/mailtrap/contact_imports_api_spec.rb b/spec/mailtrap/contact_imports_api_spec.rb index 8694b61..c7d937d 100644 --- a/spec/mailtrap/contact_imports_api_spec.rb +++ b/spec/mailtrap/contact_imports_api_spec.rb @@ -107,5 +107,82 @@ expect { client.create(contacts) }.to raise_error(Mailtrap::Error) end + + context 'when using ContactsImportRequest' do + let(:basic_contact_data) do + { + email: 'example@example.com', + fields: { + fname: 'John', + age: 30, + is_subscribed: true, + birthday: '1990-05-15' + } + } + end + + def expect_successful_import(request_body, response_data = expected_response) + stub_request(:post, "#{base_url}/contacts/imports") + .with(body: request_body.to_json) + .to_return( + status: 200, + body: response_data.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + end + + shared_examples 'successful contact import' do |expected_id, expected_count| + it 'returns the expected import response' do + expect(response).to have_attributes( + id: expected_id, + status: 'created', + created_contacts_count: expected_count, + updated_contacts_count: 0, + contacts_over_limit_count: 0 + ) + end + end + + describe 'step-by-step method calls' do + let(:request) do + req = Mailtrap::ContactsImportRequest.new + req.upsert(email: basic_contact_data[:email], fields: basic_contact_data[:fields]) + req.add_to_lists(email: basic_contact_data[:email], list_ids: [1, 2]) + req.remove_from_lists(email: basic_contact_data[:email], list_ids: [3]) + end + + let(:expected_request_body) do + { + contacts: [ + basic_contact_data.merge( + list_ids_included: [1, 2], + list_ids_excluded: [3] + ) + ] + } + end + + let(:response) do + expect_successful_import(expected_request_body) + client.create(request) + end + + include_examples 'successful contact import', 'import-456', 1 + end + + it 'validates request data and raises error when invalid options are provided' do + request = Mailtrap::ContactsImportRequest.new + # Manually add invalid data to simulate corrupted request + request.instance_variable_get(:@data)['test@example.com'] = { + email: 'test@example.com', + fields: {}, + list_ids_included: [], + list_ids_excluded: [], + invalid_field: 'should not be here' + } + + expect { client.create(request) }.to raise_error(ArgumentError, /invalid options are given/) + end + end end end From 3801aa6ae50c6207c4f0e56d6633e627c5fd56f9 Mon Sep 17 00:00:00 2001 From: sarco3t <14gainward88@gmail.com> Date: Wed, 30 Jul 2025 11:31:07 +0300 Subject: [PATCH 3/4] rubocop fix in specs --- spec/mailtrap/contact_imports_api_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/mailtrap/contact_imports_api_spec.rb b/spec/mailtrap/contact_imports_api_spec.rb index c7d937d..25b474d 100644 --- a/spec/mailtrap/contact_imports_api_spec.rb +++ b/spec/mailtrap/contact_imports_api_spec.rb @@ -143,7 +143,7 @@ def expect_successful_import(request_body, response_data = expected_response) end end - describe 'step-by-step method calls' do + describe 'step-by-step method calls' do # rubocop:disable RSpec/MultipleMemoizedHelpers let(:request) do req = Mailtrap::ContactsImportRequest.new req.upsert(email: basic_contact_data[:email], fields: basic_contact_data[:fields]) From 544023cd2e99c7fc0a2c1b326c68c08716dfe716 Mon Sep 17 00:00:00 2001 From: sarco3t <14gainward88@gmail.com> Date: Wed, 30 Jul 2025 12:14:45 +0300 Subject: [PATCH 4/4] added specs for `Mailtrap::ContactsImportRequest` --- lib/mailtrap/contacts_import_request.rb | 6 +- spec/mailtrap/contact_imports_api_spec.rb | 47 ++++ spec/mailtrap/contacts_import_request_spec.rb | 248 ++++++++++++++++++ 3 files changed, 298 insertions(+), 3 deletions(-) create mode 100644 spec/mailtrap/contacts_import_request_spec.rb diff --git a/lib/mailtrap/contacts_import_request.rb b/lib/mailtrap/contacts_import_request.rb index 2a280cf..656d24c 100644 --- a/lib/mailtrap/contacts_import_request.rb +++ b/lib/mailtrap/contacts_import_request.rb @@ -18,7 +18,7 @@ def upsert(email:, fields: {}) return self end - @data[email][:fields] = fields + @data[email][:fields].merge!(fields) self end @@ -33,7 +33,7 @@ def add_to_lists(email:, list_ids:) return self end - @data[email][:list_ids_included].concat(list_ids) + @data[email][:list_ids_included] |= list_ids self end @@ -48,7 +48,7 @@ def remove_from_lists(email:, list_ids:) return self end - @data[email][:list_ids_excluded].concat(list_ids) + @data[email][:list_ids_excluded] |= list_ids self end diff --git a/spec/mailtrap/contact_imports_api_spec.rb b/spec/mailtrap/contact_imports_api_spec.rb index 25b474d..86f4752 100644 --- a/spec/mailtrap/contact_imports_api_spec.rb +++ b/spec/mailtrap/contact_imports_api_spec.rb @@ -185,4 +185,51 @@ def expect_successful_import(request_body, response_data = expected_response) end end end + + describe '#start' do + let(:contacts) do + [ + { + email: 'example@example.com', + fields: { + fname: 'John', + age: 30, + is_subscribed: true, + birthday: '1990-05-15' + }, + list_ids_included: [1, 2], + list_ids_excluded: [3] + } + ] + end + + let(:expected_response) do + { + 'id' => 'import-789', + 'status' => 'created', + 'created_contacts_count' => 1, + 'updated_contacts_count' => 0, + 'contacts_over_limit_count' => 0 + } + end + + it 'is an alias for #create and works identically' do + stub_request(:post, "#{base_url}/contacts/imports") + .with(body: { contacts: }.to_json) + .to_return( + status: 200, + body: expected_response.to_json, + headers: { 'Content-Type' => 'application/json' } + ) + + response = client.start(contacts) + expect(response).to have_attributes( + id: 'import-789', + status: 'created', + created_contacts_count: 1, + updated_contacts_count: 0, + contacts_over_limit_count: 0 + ) + end + end end diff --git a/spec/mailtrap/contacts_import_request_spec.rb b/spec/mailtrap/contacts_import_request_spec.rb new file mode 100644 index 0000000..03ec665 --- /dev/null +++ b/spec/mailtrap/contacts_import_request_spec.rb @@ -0,0 +1,248 @@ +# frozen_string_literal: true + +RSpec.describe Mailtrap::ContactsImportRequest do + subject(:request) { described_class.new } + + # Test data + let(:test_email) { 'john.doe@example.com' } + let(:another_email) { 'jane.smith@example.com' } + let(:test_fields) { { first_name: 'John', last_name: 'Doe' } } + let(:additional_fields) { { age: 30, company: 'Example Corp' } } + let(:test_list_ids) { [1, 2, 3] } + + # Shared examples for method chaining + shared_examples 'supports method chaining' do |method_name, **args| + it 'returns self for method chaining' do + result = request.public_send(method_name, **args) + expect(result).to eq(request) + end + end + + # Shared examples for contact creation + shared_examples 'creates contact when not exists' do |method_name, expected_data, **method_args| + it "creates a new contact using #{method_name}" do + result = request.public_send(method_name, **method_args) + + expect(result).to eq(request) + expect(request.as_json).to contain_exactly(expected_data) + end + end + + describe '#upsert' do + include_examples 'supports method chaining', :upsert, email: 'test@example.com', fields: { name: 'Test' } + + context 'when contact does not exist' do + include_examples 'creates contact when not exists', :upsert, { + email: 'john.doe@example.com', + fields: { first_name: 'John', last_name: 'Doe' }, + list_ids_included: [], + list_ids_excluded: [] + }, email: 'john.doe@example.com', fields: { first_name: 'John', last_name: 'Doe' } + + it 'creates a contact with empty fields when none provided' do + result = request.upsert(email: test_email) + + expect(result).to eq(request) + expect(request.as_json).to contain_exactly( + email: test_email, + fields: {}, + list_ids_included: [], + list_ids_excluded: [] + ) + end + end + + context 'when contact already exists' do + before { request.upsert(email: test_email, fields: { first_name: 'John' }) } + + it 'merges new fields with existing fields' do + result = request.upsert(email: test_email, fields: additional_fields) + + expect(result).to eq(request) + expect(request.as_json).to contain_exactly( + email: test_email, + fields: { first_name: 'John', **additional_fields }, + list_ids_included: [], + list_ids_excluded: [] + ) + end + + it 'overwrites existing field values' do + result = request.upsert(email: test_email, fields: { first_name: 'Jane' }) + + expect(result).to eq(request) + expect(request.as_json).to contain_exactly( + email: test_email, + fields: { first_name: 'Jane' }, + list_ids_included: [], + list_ids_excluded: [] + ) + end + + it 'preserves existing list associations when upserting fields' do + request.add_to_lists(email: test_email, list_ids: [1, 2]) + request.remove_from_lists(email: test_email, list_ids: [3]) + + request.upsert(email: test_email, fields: { last_name: 'Doe' }) + + expect(request.as_json).to contain_exactly( + email: test_email, + fields: { first_name: 'John', last_name: 'Doe' }, + list_ids_included: [1, 2], + list_ids_excluded: [3] + ) + end + end + end + + describe '#add_to_lists' do + include_examples 'supports method chaining', :add_to_lists, email: 'test@example.com', list_ids: [1, 2] + + context 'when contact does not exist' do + include_examples 'creates contact when not exists', :add_to_lists, { + email: 'jane.doe@example.com', + fields: {}, + list_ids_included: [1, 2, 3], + list_ids_excluded: [] + }, email: 'jane.doe@example.com', list_ids: [1, 2, 3] + end + + context 'when contact already exists' do + before { request.upsert(email: another_email, fields: { name: 'Jane' }) } + + it 'adds new list IDs to existing inclusions' do + request.add_to_lists(email: another_email, list_ids: [1, 2]) + result = request.add_to_lists(email: another_email, list_ids: [3, 4]) + + expect(result).to eq(request) + expect(request.as_json).to contain_exactly( + email: another_email, + fields: { name: 'Jane' }, + list_ids_included: [1, 2, 3, 4], + list_ids_excluded: [] + ) + end + + it 'prevents duplicate list IDs in inclusions' do + request.add_to_lists(email: another_email, list_ids: [1, 2]) + result = request.add_to_lists(email: another_email, list_ids: [2, 3]) + + expect(result).to eq(request) + expect(request.as_json).to contain_exactly( + email: another_email, + fields: { name: 'Jane' }, + list_ids_included: [1, 2, 3], + list_ids_excluded: [] + ) + end + end + + it 'works with method chaining on same email' do + request + .add_to_lists(email: test_email, list_ids: [1, 2]) + .add_to_lists(email: test_email, list_ids: [3, 4]) + + expect(request.as_json.first[:list_ids_included]).to eq([1, 2, 3, 4]) + end + end + + describe '#remove_from_lists' do + include_examples 'supports method chaining', :remove_from_lists, email: 'test@example.com', list_ids: [5, 6] + + context 'when contact does not exist' do + include_examples 'creates contact when not exists', :remove_from_lists, { + email: 'bob.smith@example.com', + fields: {}, + list_ids_included: [], + list_ids_excluded: [5, 6, 7] + }, email: 'bob.smith@example.com', list_ids: [5, 6, 7] + end + + context 'when contact already exists' do # rubocop:disable RSpec/MultipleMemoizedHelpers + let(:bob_email) { 'bob.smith@example.com' } + + before { request.upsert(email: bob_email, fields: { name: 'Bob' }) } + + it 'adds new list IDs to existing exclusions' do + request.remove_from_lists(email: bob_email, list_ids: [5, 6]) + result = request.remove_from_lists(email: bob_email, list_ids: [7, 8]) + + expect(result).to eq(request) + expect(request.as_json).to contain_exactly( + email: bob_email, + fields: { name: 'Bob' }, + list_ids_included: [], + list_ids_excluded: [5, 6, 7, 8] + ) + end + + it 'prevents duplicate list IDs in exclusions' do + request.remove_from_lists(email: bob_email, list_ids: [5, 6]) + result = request.remove_from_lists(email: bob_email, list_ids: [6, 7]) + + expect(result).to eq(request) + expect(request.as_json).to contain_exactly( + email: bob_email, + fields: { name: 'Bob' }, + list_ids_included: [], + list_ids_excluded: [5, 6, 7] + ) + end + end + + it 'works with method chaining on same email' do + request + .remove_from_lists(email: test_email, list_ids: [5, 6]) + .remove_from_lists(email: test_email, list_ids: [7, 8]) + + expect(request.as_json.first[:list_ids_excluded]).to eq([5, 6, 7, 8]) + end + end + + describe 'serialization methods' do + describe '#as_json' do + context 'when no contacts added' do + it 'returns an empty array' do + expect(request.as_json).to eq([]) + end + end + + context 'when contacts are added' do + before do + request + .upsert(email: test_email, fields: { name: 'John' }) + .add_to_lists(email: test_email, list_ids: [1]) + .upsert(email: another_email, fields: { name: 'Jane' }) + .remove_from_lists(email: another_email, list_ids: [2]) + end + + it 'returns array of contact data hashes' do + result = request.as_json + + expect(result).to contain_exactly( + { + email: test_email, + fields: { name: 'John' }, + list_ids_included: [1], + list_ids_excluded: [] + }, + { + email: another_email, + fields: { name: 'Jane' }, + list_ids_included: [], + list_ids_excluded: [2] + } + ) + end + end + end + + describe '#to_a' do + it 'is an alias for #as_json' do + request.upsert(email: 'test@example.com', fields: { name: 'Test' }) + + expect(request.to_a).to eq(request.as_json) + end + end + end +end