attio-rails 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,15 +1,19 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Attio
2
4
  module Rails
3
5
  class Railtie < ::Rails::Railtie
4
6
  initializer "attio.configure_rails_initialization" do
5
7
  ActiveSupport.on_load(:active_record) do
6
- require 'attio/rails/concerns/syncable'
8
+ require "attio/rails/concerns/syncable"
7
9
  end
8
10
  end
9
11
 
12
+ # :nocov:
10
13
  generators do
11
- require 'generators/attio/install/install_generator'
14
+ require "generators/attio/install/install_generator"
12
15
  end
16
+ # :nocov:
13
17
  end
14
18
  end
15
- end
19
+ end
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attio
4
+ module Rails
5
+ module RSpec
6
+ # RSpec helper methods for testing Attio Rails integration
7
+ #
8
+ # @example Including in your specs
9
+ # RSpec.configure do |config|
10
+ # config.include Attio::Rails::RSpec::Helpers
11
+ # end
12
+ #
13
+ # @example Basic usage
14
+ # it 'syncs to Attio' do
15
+ # stub_attio_client
16
+ #
17
+ # user = User.create!(email: 'test@example.com')
18
+ # expect(user.attio_record_id).to eq('attio-test-id')
19
+ # end
20
+ #
21
+ # @example Testing with expectations
22
+ # it 'sends correct attributes' do
23
+ # expect_attio_sync(
24
+ # object: 'people',
25
+ # attributes: { email: 'test@example.com' }
26
+ # ) do
27
+ # User.create!(email: 'test@example.com')
28
+ # end
29
+ # end
30
+ module Helpers
31
+ # Stub the Attio client and records API
32
+ #
33
+ # @return [Hash{Symbol => RSpec::Mocks::Double}] Hash with :client and :records doubles
34
+ #
35
+ # @example
36
+ # stubs = stub_attio_client
37
+ # allow(stubs[:records]).to receive(:create).and_return(response)
38
+ def stub_attio_client
39
+ client = instance_double(Attio::Client)
40
+ records = instance_double(Attio::Resources::Records)
41
+
42
+ allow(Attio::Rails).to receive(:client).and_return(client)
43
+ allow(client).to receive(:records).and_return(records)
44
+
45
+ { client: client, records: records }
46
+ end
47
+
48
+ # Stub Attio create operations
49
+ #
50
+ # @param response [Hash] Custom response to return (default: { "data" => { "id" => "attio-test-id" } })
51
+ # @return [Hash{Symbol => RSpec::Mocks::Double}] Hash with :client and :records doubles
52
+ #
53
+ # @example
54
+ # stub_attio_create
55
+ # User.create!(email: 'test@example.com')
56
+ #
57
+ # @example With custom response
58
+ # stub_attio_create('data' => { 'id' => 'custom-id' })
59
+ def stub_attio_create(response = { "data" => { "id" => "attio-test-id" } })
60
+ stubs = stub_attio_client
61
+ allow(stubs[:records]).to receive(:create).and_return(response)
62
+ stubs
63
+ end
64
+
65
+ # Stub Attio update operations
66
+ #
67
+ # @param response [Hash] Custom response to return
68
+ # @return [Hash{Symbol => RSpec::Mocks::Double}] Hash with :client and :records doubles
69
+ #
70
+ # @example
71
+ # stub_attio_update
72
+ # user.update!(name: 'New Name')
73
+ def stub_attio_update(response = { "data" => { "id" => "attio-test-id" } })
74
+ stubs = stub_attio_client
75
+ allow(stubs[:records]).to receive(:update).and_return(response)
76
+ stubs
77
+ end
78
+
79
+ # Stub Attio delete operations
80
+ #
81
+ # @param response [Hash] Custom response to return
82
+ # @return [Hash{Symbol => RSpec::Mocks::Double}] Hash with :client and :records doubles
83
+ #
84
+ # @example
85
+ # stub_attio_delete
86
+ # user.destroy!
87
+ def stub_attio_delete(response = { "data" => { "deleted" => true } })
88
+ stubs = stub_attio_client
89
+ allow(stubs[:records]).to receive(:delete).and_return(response)
90
+ stubs
91
+ end
92
+
93
+ # Expect a sync to Attio with specific parameters
94
+ #
95
+ # @param object [String] Expected Attio object type
96
+ # @param attributes [Hash, nil] Expected attributes (nil to match any)
97
+ # @yield Block to execute that should trigger the sync
98
+ # @return [Hash{Symbol => RSpec::Mocks::Double}] Hash with :client and :records doubles
99
+ #
100
+ # @example
101
+ # expect_attio_sync(object: 'people', attributes: { email: 'test@example.com' }) do
102
+ # User.create!(email: 'test@example.com')
103
+ # end
104
+ def expect_attio_sync(object:, attributes: nil)
105
+ stubs = stub_attio_client
106
+
107
+ if attributes
108
+ expect(stubs[:records]).to receive(:create).with(
109
+ object: object,
110
+ data: { values: attributes }
111
+ ).and_return({ "data" => { "id" => "attio-test-id" } })
112
+ else
113
+ expect(stubs[:records]).to receive(:create).with(
114
+ hash_including(object: object)
115
+ ).and_return({ "data" => { "id" => "attio-test-id" } })
116
+ end
117
+
118
+ yield if block_given?
119
+
120
+ stubs
121
+ end
122
+
123
+ # Expect no sync to Attio
124
+ #
125
+ # @yield Block to execute that should not trigger any sync
126
+ # @return [Hash{Symbol => RSpec::Mocks::Double}] Hash with :client and :records doubles
127
+ #
128
+ # @example
129
+ # expect_no_attio_sync do
130
+ # with_attio_sync_disabled do
131
+ # User.create!(email: 'test@example.com')
132
+ # end
133
+ # end
134
+ def expect_no_attio_sync
135
+ stubs = stub_attio_client
136
+
137
+ expect(stubs[:records]).not_to receive(:create)
138
+ expect(stubs[:records]).not_to receive(:update)
139
+
140
+ yield if block_given?
141
+
142
+ stubs
143
+ end
144
+
145
+ # Temporarily disable Attio syncing
146
+ #
147
+ # @yield Block to execute with syncing disabled
148
+ #
149
+ # @example
150
+ # with_attio_sync_disabled do
151
+ # User.create!(email: 'test@example.com') # Won't sync
152
+ # end
153
+ def with_attio_sync_disabled
154
+ original_value = Attio::Rails.configuration.sync_enabled
155
+ Attio::Rails.configure { |c| c.sync_enabled = false }
156
+
157
+ yield
158
+ ensure
159
+ Attio::Rails.configure { |c| c.sync_enabled = original_value }
160
+ end
161
+
162
+ # Temporarily enable background sync
163
+ #
164
+ # @yield Block to execute with background sync enabled
165
+ #
166
+ # @example
167
+ # with_attio_background_sync do
168
+ # User.create!(email: 'test@example.com') # Will sync in background
169
+ # end
170
+ # expect(attio_sync_jobs.size).to eq(1)
171
+ def with_attio_background_sync
172
+ original_value = Attio::Rails.configuration.background_sync
173
+ Attio::Rails.configure { |c| c.background_sync = true }
174
+
175
+ yield
176
+ ensure
177
+ Attio::Rails.configure { |c| c.background_sync = original_value }
178
+ end
179
+
180
+ # Get all enqueued AttioSyncJob jobs
181
+ #
182
+ # @return [Array<Hash>] Array of enqueued job hashes
183
+ #
184
+ # @example
185
+ # User.create!(email: 'test@example.com')
186
+ # expect(attio_sync_jobs.size).to eq(1)
187
+ # expect(attio_sync_jobs.first[:args]).to include('model_name' => 'User')
188
+ def attio_sync_jobs
189
+ ActiveJob::Base.queue_adapter.enqueued_jobs.select do |job|
190
+ job[:job] == AttioSyncJob
191
+ end
192
+ end
193
+
194
+ # Clear all enqueued AttioSyncJob jobs
195
+ #
196
+ # @return [Array<Hash>] The deleted jobs
197
+ #
198
+ # @example
199
+ # clear_attio_sync_jobs
200
+ # expect(attio_sync_jobs).to be_empty
201
+ def clear_attio_sync_jobs
202
+ ActiveJob::Base.queue_adapter.enqueued_jobs.delete_if do |job|
203
+ job[:job] == AttioSyncJob
204
+ end
205
+ end
206
+ end
207
+ end
208
+ end
209
+ end
@@ -0,0 +1,145 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Attio
4
+ module Rails
5
+ module RSpec
6
+ module Matchers
7
+ ::RSpec::Matchers.define :sync_to_attio do |expected|
8
+ match do |actual|
9
+ return false unless actual.class.respond_to?(:attio_object_type)
10
+
11
+ if expected
12
+ actual.class.attio_object_type == expected[:object] || expected[:object_type]
13
+ else
14
+ actual.class.attio_object_type.present?
15
+ end
16
+ end
17
+
18
+ failure_message do |actual|
19
+ if actual.class.respond_to?(:attio_object_type)
20
+ expected_obj = expected[:object] || expected[:object_type]
21
+ actual_obj = actual.class.attio_object_type
22
+ "expected #{actual.class} to sync to Attio object '#{expected_obj}' but syncs to '#{actual_obj}'"
23
+ else
24
+ "expected #{actual.class} to include Attio::Rails::Concerns::Syncable"
25
+ end
26
+ end
27
+
28
+ failure_message_when_negated do |actual|
29
+ "expected #{actual.class} not to sync to Attio"
30
+ end
31
+
32
+ description do
33
+ "sync to Attio"
34
+ end
35
+ end
36
+
37
+ ::RSpec::Matchers.define :have_attio_attribute do |attio_attr|
38
+ match do |actual|
39
+ return false unless actual.class.respond_to?(:attio_attribute_mapping)
40
+
41
+ mapping = actual.class.attio_attribute_mapping
42
+ if @mapped_to
43
+ mapping[attio_attr] == @mapped_to
44
+ else
45
+ mapping.key?(attio_attr)
46
+ end
47
+ end
48
+
49
+ chain :mapped_to do |local_attr|
50
+ @mapped_to = local_attr
51
+ end
52
+
53
+ failure_message do |actual|
54
+ if actual.class.respond_to?(:attio_attribute_mapping)
55
+ mapping = actual.class.attio_attribute_mapping
56
+ if @mapped_to
57
+ actual_mapping = mapping[attio_attr]
58
+ "expected #{actual.class} to map Attio attribute '#{attio_attr}' to '#{@mapped_to}' " \
59
+ "but it maps to '#{actual_mapping}'"
60
+ else
61
+ available_attrs = mapping.keys.join(", ")
62
+ "expected #{actual.class} to have Attio attribute '#{attio_attr}' but has #{available_attrs}"
63
+ end
64
+ else
65
+ "expected #{actual.class} to include Attio::Rails::Concerns::Syncable"
66
+ end
67
+ end
68
+
69
+ description do
70
+ if @mapped_to
71
+ "have Attio attribute '#{attio_attr}' mapped to '#{@mapped_to}'"
72
+ else
73
+ "have Attio attribute '#{attio_attr}'"
74
+ end
75
+ end
76
+ end
77
+
78
+ ::RSpec::Matchers.define :enqueue_attio_sync_job do # rubocop:disable Metrics/BlockLength
79
+ supports_block_expectations
80
+
81
+ match do |block|
82
+ initial_jobs = attio_sync_jobs.dup
83
+ block.call
84
+ new_jobs = attio_sync_jobs - initial_jobs
85
+ @actual_count = new_jobs.size
86
+
87
+ if @expected_count
88
+ @actual_count == @expected_count
89
+ elsif @expected_action
90
+ new_jobs.any? { |job| job[:args].first["action"]["value"] == @expected_action.to_s }
91
+ else
92
+ @actual_count > 0
93
+ end
94
+ end
95
+
96
+ chain :with_action do |action|
97
+ @expected_action = action
98
+ end
99
+
100
+ chain :exactly do |count|
101
+ @expected_count = count
102
+ end
103
+
104
+ failure_message do
105
+ build_failure_message
106
+ end
107
+
108
+ failure_message_when_negated do
109
+ "expected not to enqueue AttioSyncJob but #{@actual_count} were enqueued"
110
+ end
111
+
112
+ description do
113
+ build_description
114
+ end
115
+
116
+ private def build_failure_message
117
+ if @expected_count
118
+ "expected to enqueue #{@expected_count} AttioSyncJob(s) but enqueued #{@actual_count}"
119
+ elsif @expected_action
120
+ "expected to enqueue AttioSyncJob with action '#{@expected_action}'"
121
+ else
122
+ "expected to enqueue AttioSyncJob but none were enqueued"
123
+ end
124
+ end
125
+
126
+ private def build_description
127
+ if @expected_count
128
+ "enqueue #{@expected_count} AttioSyncJob(s)"
129
+ elsif @expected_action
130
+ "enqueue AttioSyncJob with action '#{@expected_action}'"
131
+ else
132
+ "enqueue AttioSyncJob"
133
+ end
134
+ end
135
+
136
+ private def attio_sync_jobs
137
+ ActiveJob::Base.queue_adapter.enqueued_jobs.select do |job|
138
+ job[:job] == AttioSyncJob
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "attio/rails/rspec/helpers"
4
+ require "attio/rails/rspec/matchers"
5
+
6
+ RSpec.configure do |config|
7
+ config.include Attio::Rails::RSpec::Helpers, type: :model
8
+ config.include Attio::Rails::RSpec::Matchers, type: :model
9
+ end
@@ -1,5 +1,7 @@
1
+ # frozen_string_literal: true
2
+
1
3
  module Attio
2
4
  module Rails
3
- VERSION = "0.1.1"
5
+ VERSION = "0.2.0"
4
6
  end
5
7
  end
data/lib/attio/rails.rb CHANGED
@@ -1,6 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require "attio"
2
4
  require "rails"
3
5
  require "active_support"
6
+ require "logger"
4
7
 
5
8
  require "attio/rails/version"
6
9
  require "attio/rails/configuration"
@@ -1,29 +1,31 @@
1
- require 'rails/generators/base'
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
2
4
 
3
5
  module Attio
4
6
  module Generators
5
7
  class InstallGenerator < Rails::Generators::Base
6
- source_root File.expand_path('templates', __dir__)
7
-
8
+ source_root File.expand_path("templates", __dir__)
9
+
8
10
  class_option :skip_job, type: :boolean, default: false, desc: "Skip creating the sync job"
9
11
  class_option :skip_migration, type: :boolean, default: false, desc: "Skip creating the migration"
10
12
 
11
13
  def check_requirements
12
- unless defined?(ActiveJob)
13
- say "Warning: ActiveJob is not available. Skipping job creation.", :yellow
14
- @skip_job = true
15
- end
14
+ return if defined?(ActiveJob)
15
+
16
+ say "Warning: ActiveJob is not available. Skipping job creation.", :yellow
17
+ @skip_job = true
16
18
  end
17
19
 
18
20
  def create_initializer
19
- template 'attio.rb', 'config/initializers/attio.rb'
21
+ template "attio.rb", "config/initializers/attio.rb"
20
22
  end
21
23
 
22
24
  def create_migration
23
25
  return if options[:skip_migration]
24
-
26
+
25
27
  if defined?(ActiveRecord)
26
- migration_template 'migration.rb', 'db/migrate/add_attio_record_id_to_tables.rb'
28
+ migration_template "migration.rb.erb", "db/migrate/add_attio_record_id_to_tables.rb"
27
29
  else
28
30
  say "ActiveRecord not detected. Skipping migration.", :yellow
29
31
  end
@@ -31,35 +33,35 @@ module Attio
31
33
 
32
34
  def create_sync_job
33
35
  return if options[:skip_job] || @skip_job
34
-
35
- template 'attio_sync_job.rb', 'app/jobs/attio_sync_job.rb'
36
+
37
+ template "attio_sync_job.rb", "app/jobs/attio_sync_job.rb"
36
38
  end
37
39
 
38
40
  def add_to_gemfile
41
+ return if gemfile_contains?("attio-rails")
42
+
39
43
  gem_group :production do
40
- gem 'attio-rails'
41
- end unless gemfile_contains?('attio-rails')
44
+ gem "attio-rails"
45
+ end
42
46
  end
43
47
 
44
48
  def display_readme
45
- readme 'README.md'
49
+ readme "README.md"
46
50
  end
47
51
 
48
- private
49
-
50
- def gemfile_contains?(gem_name)
51
- File.read('Gemfile').include?(gem_name)
52
- rescue
52
+ private def gemfile_contains?(gem_name)
53
+ File.read("Gemfile").include?(gem_name)
54
+ rescue StandardError
53
55
  false
54
56
  end
55
57
 
56
- def rails_version
58
+ private def rails_version
57
59
  Rails::VERSION::STRING
58
60
  end
59
61
 
60
- def migration_version
62
+ private def migration_version
61
63
  "[#{Rails::VERSION::MAJOR}.#{Rails::VERSION::MINOR}]"
62
64
  end
63
65
  end
64
66
  end
65
- end
67
+ end
@@ -1,7 +1,9 @@
1
+ # frozen_string_literal: true
2
+
1
3
  Attio::Rails.configure do |config|
2
4
  # Set your Attio API key
3
- config.api_key = ENV['ATTIO_API_KEY']
4
-
5
+ config.api_key = ENV.fetch("ATTIO_API_KEY", nil)
6
+
5
7
  # Optional: Set a default workspace ID
6
8
  # config.default_workspace_id = 'your-workspace-id'
7
- end
9
+ end
@@ -1,15 +1,17 @@
1
+ # frozen_string_literal: true
2
+
1
3
  class AttioSyncJob < ApplicationJob
2
4
  queue_as :low
3
-
5
+
4
6
  retry_on Attio::RateLimitError, wait: 1.minute, attempts: 3
5
7
  retry_on Attio::ServerError, wait: :exponentially_longer, attempts: 5
6
8
  discard_on ActiveJob::DeserializationError
7
9
 
8
10
  def perform(model_name:, model_id:, action:, attio_record_id: nil)
9
11
  return unless Attio::Rails.sync_enabled?
10
-
12
+
11
13
  model_class = model_name.constantize
12
-
14
+
13
15
  case action
14
16
  when :sync
15
17
  sync_record(model_class, model_id)
@@ -30,19 +32,17 @@ class AttioSyncJob < ApplicationJob
30
32
  raise e
31
33
  end
32
34
 
33
- private
34
-
35
- def sync_record(model_class, model_id)
35
+ private def sync_record(model_class, model_id)
36
36
  model = model_class.find_by(id: model_id)
37
37
  return unless model
38
-
38
+
39
39
  # Check if model should still be synced
40
40
  return unless model.should_sync_to_attio?
41
-
41
+
42
42
  model.sync_to_attio_now
43
43
  end
44
44
 
45
- def delete_record(model_class, model_id, attio_record_id)
45
+ private def delete_record(model_class, model_id, attio_record_id)
46
46
  return unless attio_record_id.present?
47
47
 
48
48
  # Model might already be deleted, so we work with the class
@@ -54,10 +54,10 @@ class AttioSyncJob < ApplicationJob
54
54
  object: object_type,
55
55
  id: attio_record_id
56
56
  )
57
-
57
+
58
58
  Attio::Rails.logger.info "Deleted Attio record #{attio_record_id} for #{model_class.name}##{model_id}"
59
59
  rescue Attio::NotFoundError
60
60
  # Record already deleted in Attio, nothing to do
61
61
  Attio::Rails.logger.info "Attio record #{attio_record_id} already deleted"
62
62
  end
63
- end
63
+ end
@@ -1,4 +1,4 @@
1
- class AddAttioRecordIdToTables < ActiveRecord::Migration<%= migration_version %>
1
+ class AddAttioRecordIdToTables < ActiveRecord::Migration<%= migration_version %> # rubocop:disable Rails/Migration
2
2
  def change
3
3
  # Add attio_record_id to each table that needs to sync with Attio
4
4
  # Example:
data/test_basic.rb CHANGED
@@ -1,26 +1,27 @@
1
1
  #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
2
3
 
3
4
  # Test without loading external dependencies
4
- $LOAD_PATH.unshift(File.expand_path('../lib', __FILE__))
5
- $LOAD_PATH.unshift(File.expand_path('../../attio/lib', __FILE__))
5
+ $LOAD_PATH.unshift(File.expand_path("lib", __dir__))
6
+ $LOAD_PATH.unshift(File.expand_path("../attio/lib", __dir__))
6
7
 
7
8
  puts "Testing Attio Rails gem structure..."
8
9
 
9
10
  # Test version file
10
- require 'attio/rails/version'
11
+ require "attio/rails/version"
11
12
  puts "✓ Version loaded: #{Attio::Rails::VERSION}"
12
13
 
13
14
  # Test that all files exist
14
15
  files_to_check = [
15
- 'lib/attio/rails.rb',
16
- 'lib/attio/rails/configuration.rb',
17
- 'lib/attio/rails/concerns/syncable.rb',
18
- 'lib/attio/rails/railtie.rb',
19
- 'lib/generators/attio/install/install_generator.rb',
20
- 'lib/generators/attio/install/templates/attio.rb',
21
- 'lib/generators/attio/install/templates/attio_sync_job.rb',
22
- 'lib/generators/attio/install/templates/migration.rb',
23
- 'lib/generators/attio/install/templates/README.md'
16
+ "lib/attio/rails.rb",
17
+ "lib/attio/rails/configuration.rb",
18
+ "lib/attio/rails/concerns/syncable.rb",
19
+ "lib/attio/rails/railtie.rb",
20
+ "lib/generators/attio/install/install_generator.rb",
21
+ "lib/generators/attio/install/templates/attio.rb",
22
+ "lib/generators/attio/install/templates/attio_sync_job.rb",
23
+ "lib/generators/attio/install/templates/migration.rb",
24
+ "lib/generators/attio/install/templates/README.md",
24
25
  ]
25
26
 
26
27
  files_to_check.each do |file|
@@ -32,8 +33,8 @@ files_to_check.each do |file|
32
33
  end
33
34
 
34
35
  # Test spec files exist
35
- spec_files = Dir.glob('spec/**/*_spec.rb')
36
+ spec_files = Dir.glob("spec/**/*_spec.rb")
36
37
  puts "\nFound #{spec_files.length} spec files:"
37
38
  spec_files.each { |f| puts " - #{f}" }
38
39
 
39
- puts "\nBasic structure test completed!"
40
+ puts "\nBasic structure test completed!"