attio-rails 0.1.2 → 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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9f87465ccc673d7f6303bb1f2452c34a04e9eeb8ae2a8dafc5eb10782d1b088e
4
- data.tar.gz: 5528020745e15f607aed52d10fdd029614ef47a91604bd4b112c12ab2d5469a8
3
+ metadata.gz: 5a8102663f11e79d1701d5535c84d290bbe469eecff5dc9dc184cd32d3cbb6f1
4
+ data.tar.gz: 9c44c1dfec3c6ac14507073dadafad96d2c8778f7243476324244757a13da768
5
5
  SHA512:
6
- metadata.gz: 499bc5887d50c6b9dfc1e8941cd3b9c22779b81e2d4d716c05bdd9a96afb0af845c7b1a1cb21b0ff28a49430e451b74b8d3e6d085d4f173bcd69ce7234224f33
7
- data.tar.gz: 02dd1be6cb69a0689b2de7e62c590380b8a3be7eca40795af4b1df3f477dcf47fd99523eb5dc631a45910771eed515dfaad5dfdd87aa2d9fad5e70053ff73f40
6
+ metadata.gz: 8b36523aa233614f1d9ce43ad20b18080c7459f6bda2e2da67114b926a2dfa10a60cf569e9866ca86a644032daf8c27b6050bd7e99e57366202b5ef8e74255e9
7
+ data.tar.gz: db16d544bcdd62cd6f9f7434c203d252b15eeafb2541a84d57b1196901f3c0ea6a3ac8682b2fc903a38cd8b978e6458208a3380b262374417bc252e2dbf7faf0
@@ -1,4 +1,4 @@
1
- name: Release
1
+ name: Build and Publish
2
2
 
3
3
  on:
4
4
  push:
@@ -6,7 +6,7 @@ on:
6
6
  - 'v*.*.*'
7
7
 
8
8
  jobs:
9
- release:
9
+ build-and-publish:
10
10
  runs-on: ubuntu-latest
11
11
 
12
12
  permissions:
@@ -30,7 +30,7 @@ jobs:
30
30
  mkdir -p ~/.gem
31
31
  cat > ~/.gem/credentials << EOF
32
32
  ---
33
- :rubygems_api_key: ${{ secrets.RUBYGEMS_API_KEY }}
33
+ :rubygems_api_key: ${{ secrets.RUBYGEMS_AUTH_TOKEN }}
34
34
  EOF
35
35
  chmod 0600 ~/.gem/credentials
36
36
 
@@ -58,13 +58,3 @@ jobs:
58
58
  env:
59
59
  GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
60
60
 
61
- - name: Update GitHub Pages with documentation
62
- run: |
63
- git config --local user.email "action@github.com"
64
- git config --local user.name "GitHub Action"
65
- git checkout --orphan gh-pages
66
- git rm -rf .
67
- cp -r docs/* .
68
- git add -A
69
- git commit -m "Update documentation for ${{ github.ref_name }}"
70
- git push origin gh-pages --force
@@ -48,7 +48,7 @@ jobs:
48
48
 
49
49
  - name: Upload coverage to Codecov
50
50
  if: matrix.ruby-version == '3.4' && matrix.rails-version == '8.0'
51
- uses: codecov/codecov-action@v4
51
+ uses: codecov/codecov-action@v5
52
52
  with:
53
53
  files: ./coverage/coverage.xml,./coverage/.resultset.json
54
54
  flags: unittests
@@ -2,7 +2,7 @@ name: Documentation
2
2
 
3
3
  on:
4
4
  push:
5
- branches: [ main ]
5
+ branches: [ master ]
6
6
  workflow_dispatch:
7
7
 
8
8
  permissions:
@@ -28,7 +28,7 @@ jobs:
28
28
  bundler-cache: true
29
29
 
30
30
  - name: Setup Pages
31
- uses: actions/configure-pages@v4
31
+ uses: actions/configure-pages@v5
32
32
 
33
33
  - name: Generate documentation
34
34
  run: |
data/CHANGELOG.md CHANGED
@@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [0.2.0] - 2025-01-11
11
+
12
+ ### Added
13
+ - **BatchSync** class for efficient bulk synchronization operations
14
+ - **ActiveJob integration** with dedicated `AttioSyncJob` for background processing
15
+ - **Callbacks support** - `before_attio_sync` and `after_attio_sync` hooks
16
+ - **Transform support** - Custom attribute transformation before syncing
17
+ - **Error handlers** - Configurable error handling with `:on_error` option
18
+ - **RSpec test helpers** - Comprehensive testing utilities for Attio operations
19
+ - **Concepts documentation** - Detailed architecture guide with Mermaid diagrams
20
+ - **Configuration enhancements**:
21
+ - `queue` option for ActiveJob queue configuration
22
+ - `raise_on_missing_record` option for missing record behavior
23
+ - **100% test coverage** with comprehensive test suite
24
+
25
+ ### Changed
26
+ - Enhanced `Syncable` concern with callbacks and transforms
27
+ - Improved error handling with environment-specific behavior
28
+ - Renamed GitHub Actions workflow from `release.yml` to `build-and-publish.yml`
29
+ - Updated README with comprehensive examples and usage patterns
30
+
31
+ ### Fixed
32
+ - RuboCop linting issues for better code quality
33
+ - Test coverage gaps - achieved 100% coverage
34
+
35
+ ## [0.1.2] - 2025-01-11
36
+
37
+ ### Changed
38
+ - Updated attio dependency to 0.1.3
39
+ - Applied Stripe's RuboCop configuration
40
+ - Achieved 100% test coverage
41
+
42
+ ### Fixed
43
+ - All RuboCop offenses auto-corrected
44
+ - Test failures resolved
45
+
10
46
  ## [0.1.1] - 2025-01-11
11
47
 
12
48
  ### Added
@@ -44,5 +80,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
44
80
  - Batch operations support
45
81
  - Custom field transformations
46
82
 
47
- [Unreleased]: https://github.com/idl3/attio-rails/compare/v0.1.0...HEAD
83
+ [Unreleased]: https://github.com/idl3/attio-rails/compare/v0.2.0...HEAD
84
+ [0.2.0]: https://github.com/idl3/attio-rails/compare/v0.1.2...v0.2.0
85
+ [0.1.2]: https://github.com/idl3/attio-rails/compare/v0.1.1...v0.1.2
86
+ [0.1.1]: https://github.com/idl3/attio-rails/compare/v0.1.0...v0.1.1
48
87
  [0.1.0]: https://github.com/idl3/attio-rails/releases/tag/v0.1.0
data/CONCEPTS.md ADDED
@@ -0,0 +1,448 @@
1
+ # Attio Rails - Concepts & Architecture
2
+
3
+ ## Table of Contents
4
+ - [Core Concepts](#core-concepts)
5
+ - [Architecture Overview](#architecture-overview)
6
+ - [Sync Flow](#sync-flow)
7
+ - [Batch Processing](#batch-processing)
8
+ - [Error Handling & Retry Strategy](#error-handling--retry-strategy)
9
+ - [Testing Strategy](#testing-strategy)
10
+
11
+ ## Core Concepts
12
+
13
+ ### 1. ActiveRecord Integration Pattern
14
+
15
+ The gem uses the **Concern pattern** to mix functionality into ActiveRecord models. This provides a clean, Rails-idiomatic interface:
16
+
17
+ ```mermaid
18
+ graph TD
19
+ A[ActiveRecord Model] -->|includes| B[Attio::Rails::Concerns::Syncable]
20
+ B -->|provides| C[Sync Methods]
21
+ B -->|provides| D[Callbacks]
22
+ B -->|provides| E[Attribute Mapping]
23
+
24
+ C --> F[sync_to_attio_now]
25
+ C --> G[sync_to_attio_later]
26
+ C --> H[remove_from_attio]
27
+
28
+ D --> I[before_attio_sync]
29
+ D --> J[after_attio_sync]
30
+ D --> K[ActiveRecord Callbacks]
31
+ ```
32
+
33
+ ### 2. Attribute Mapping System
34
+
35
+ The attribute mapping system supports multiple mapping strategies:
36
+
37
+ ```mermaid
38
+ graph LR
39
+ A[Model Attributes] --> B{Mapping Type}
40
+ B -->|Symbol| C[Method Call]
41
+ B -->|String| D[Method Call]
42
+ B -->|Proc/Lambda| E[Dynamic Evaluation]
43
+ B -->|Static Value| F[Direct Value]
44
+
45
+ C --> G[Attio Attributes]
46
+ D --> G
47
+ E --> G
48
+ F --> G
49
+ ```
50
+
51
+ **Example mappings:**
52
+ ```ruby
53
+ {
54
+ email: :email_address, # Symbol -> calls model.email_address
55
+ name: "full_name", # String -> calls model.full_name
56
+ type: "customer", # Static -> always "customer"
57
+ count: ->(m) { m.items.count } # Lambda -> evaluated dynamically
58
+ }
59
+ ```
60
+
61
+ ## Architecture Overview
62
+
63
+ ### System Components
64
+
65
+ ```mermaid
66
+ graph TB
67
+ subgraph "Rails Application"
68
+ A[ActiveRecord Model]
69
+ B[ActiveJob Queue]
70
+ end
71
+
72
+ subgraph "Attio Rails Gem"
73
+ C[Syncable Concern]
74
+ D[AttioSyncJob]
75
+ E[BatchSync]
76
+ F[Configuration]
77
+ end
78
+
79
+ subgraph "External"
80
+ G[Attio API]
81
+ end
82
+
83
+ A -->|includes| C
84
+ C -->|enqueues| D
85
+ C -->|uses| E
86
+ D -->|processes| B
87
+ E -->|bulk operations| G
88
+ D -->|sync/delete| G
89
+ C -->|immediate sync| G
90
+ F -->|configures| C
91
+ F -->|configures| D
92
+ ```
93
+
94
+ ## Sync Flow
95
+
96
+ ### Automatic Sync Lifecycle
97
+
98
+ ```mermaid
99
+ sequenceDiagram
100
+ participant User
101
+ participant Model
102
+ participant Syncable
103
+ participant AttioSyncJob
104
+ participant AttioAPI
105
+
106
+ User->>Model: create/update/destroy
107
+ Model->>Syncable: after_commit callback
108
+
109
+ alt Sync Enabled & Conditions Met
110
+ alt Background Sync
111
+ Syncable->>AttioSyncJob: enqueue job
112
+ AttioSyncJob-->>AttioSyncJob: process async
113
+ AttioSyncJob->>AttioAPI: sync data
114
+ AttioAPI-->>AttioSyncJob: response
115
+ AttioSyncJob->>Model: update attio_record_id
116
+ else Immediate Sync
117
+ Syncable->>AttioAPI: sync data
118
+ AttioAPI-->>Syncable: response
119
+ Syncable->>Model: update attio_record_id
120
+ end
121
+ else Sync Disabled or Conditions Not Met
122
+ Syncable-->>Model: skip sync
123
+ end
124
+ ```
125
+
126
+ ### Manual Sync Options
127
+
128
+ ```mermaid
129
+ graph TD
130
+ A[Manual Sync Trigger] --> B{Sync Method}
131
+
132
+ B -->|sync_to_attio_now| C[Immediate Sync]
133
+ B -->|sync_to_attio_later| D[Background Job]
134
+ B -->|sync_to_attio| E{Config Check}
135
+
136
+ E -->|background_sync=true| D
137
+ E -->|background_sync=false| C
138
+
139
+ C --> F[Direct API Call]
140
+ D --> G[Enqueue AttioSyncJob]
141
+
142
+ F --> H[Attio API]
143
+ G --> I[Job Queue]
144
+ I --> H
145
+ ```
146
+
147
+ ## Batch Processing
148
+
149
+ ### BatchSync Flow
150
+
151
+ ```mermaid
152
+ flowchart TD
153
+ A[BatchSync.perform] --> B[Initialize Results Hash]
154
+ B --> C[Process in Batches]
155
+
156
+ C --> D{Async Mode?}
157
+
158
+ D -->|Yes| E[Enqueue Batch]
159
+ D -->|No| F[Sync Batch]
160
+
161
+ E --> G[For Each Record]
162
+ G --> H[Enqueue AttioSyncJob]
163
+
164
+ F --> I[For Each Record]
165
+ I --> J{Has attio_record_id?}
166
+
167
+ J -->|Yes| K[Update Record]
168
+ J -->|No| L[Create Record]
169
+
170
+ K --> M[API Call]
171
+ L --> M
172
+
173
+ M --> N{Success?}
174
+ N -->|Yes| O[Add to success array]
175
+ N -->|No| P[Add to failed array]
176
+
177
+ H --> Q[Add to success array]
178
+
179
+ O --> R[Return Results]
180
+ P --> R
181
+ Q --> R
182
+ ```
183
+
184
+ ### Batch Processing Strategies
185
+
186
+ ```mermaid
187
+ graph LR
188
+ A[Large Dataset] --> B[find_in_batches]
189
+ B --> C[Batch 1]
190
+ B --> D[Batch 2]
191
+ B --> E[Batch N]
192
+
193
+ C --> F{Processing Mode}
194
+ D --> F
195
+ E --> F
196
+
197
+ F -->|Async| G[Job Queue]
198
+ F -->|Sync| H[Direct Processing]
199
+
200
+ G --> I[Parallel Processing]
201
+ H --> J[Sequential Processing]
202
+ ```
203
+
204
+ ## Error Handling & Retry Strategy
205
+
206
+ ### Error Flow
207
+
208
+ ```mermaid
209
+ flowchart TD
210
+ A[Sync Operation] --> B{Success?}
211
+
212
+ B -->|Yes| C[Update Local Record]
213
+ B -->|No| D[Error Occurred]
214
+
215
+ D --> E{Has Error Handler?}
216
+
217
+ E -->|Yes| F[Call Error Handler]
218
+ E -->|No| G{Environment?}
219
+
220
+ F --> H[Custom Logic]
221
+
222
+ G -->|Development| I[Raise Error]
223
+ G -->|Production| J[Log Error]
224
+
225
+ H --> K{In Background Job?}
226
+ K -->|Yes| L[Retry Logic]
227
+ K -->|No| M[Complete]
228
+
229
+ L --> N{Retry Attempts < 3?}
230
+ N -->|Yes| O[Wait & Retry]
231
+ N -->|No| P[Dead Letter Queue]
232
+
233
+ O --> A
234
+ ```
235
+
236
+ ### Retry Strategy with ActiveJob
237
+
238
+ ```mermaid
239
+ graph TD
240
+ A[Job Fails] --> B[Retry Mechanism]
241
+ B --> C{Attempt 1}
242
+ C -->|Fails| D[Wait 3 seconds]
243
+ D --> E{Attempt 2}
244
+ E -->|Fails| F[Wait 18 seconds]
245
+ F --> G{Attempt 3}
246
+ G -->|Fails| H[Job Failed]
247
+ G -->|Success| I[Complete]
248
+ E -->|Success| I
249
+ C -->|Success| I
250
+
251
+ style H fill:#f96
252
+ style I fill:#9f6
253
+ ```
254
+
255
+ ## Testing Strategy
256
+
257
+ ### Test Double Architecture
258
+
259
+ ```mermaid
260
+ graph TD
261
+ A[RSpec Test] --> B[Test Helpers]
262
+
263
+ B --> C[stub_attio_client]
264
+ C --> D[Mock Client]
265
+ C --> E[Mock Records API]
266
+
267
+ B --> F[expect_attio_sync]
268
+ F --> G[Expectation Setup]
269
+
270
+ B --> H[with_attio_sync_disabled]
271
+ H --> I[Temporary Config Change]
272
+
273
+ D --> J[No Real API Calls]
274
+ E --> J
275
+
276
+ J --> K[Fast Tests]
277
+ J --> L[Predictable Results]
278
+ ```
279
+
280
+ ### Testing Layers
281
+
282
+ ```mermaid
283
+ graph TB
284
+ subgraph "Unit Tests"
285
+ A[Model Tests]
286
+ B[Concern Tests]
287
+ C[Job Tests]
288
+ end
289
+
290
+ subgraph "Integration Tests"
291
+ D[Sync Flow Tests]
292
+ E[Batch Processing Tests]
293
+ end
294
+
295
+ subgraph "Test Helpers"
296
+ F[Stubbed Client]
297
+ G[Job Assertions]
298
+ H[Config Helpers]
299
+ end
300
+
301
+ A --> F
302
+ B --> F
303
+ C --> G
304
+ D --> F
305
+ D --> G
306
+ E --> F
307
+ E --> H
308
+ ```
309
+
310
+ ## Configuration Cascade
311
+
312
+ ### Configuration Priority
313
+
314
+ ```mermaid
315
+ graph TD
316
+ A[Configuration Sources] --> B[Environment Variables]
317
+ A --> C[Initializer Config]
318
+ A --> D[Model-level Options]
319
+ A --> E[Method-level Options]
320
+
321
+ B --> F{Priority}
322
+ C --> F
323
+ D --> F
324
+ E --> F
325
+
326
+ F --> G[Final Configuration]
327
+
328
+ style E fill:#9f6
329
+ style D fill:#af9
330
+ style C fill:#cf9
331
+ style B fill:#ff9
332
+ ```
333
+
334
+ Priority order (highest to lowest):
335
+ 1. Method-level options (e.g., `sync_to_attio_now(force: true)`)
336
+ 2. Model-level options (e.g., `syncs_with_attio 'people', if: :active?`)
337
+ 3. Initializer configuration (e.g., `config.background_sync = true`)
338
+ 4. Environment variables (e.g., `ATTIO_API_KEY`)
339
+
340
+ ## Data Flow Transformations
341
+
342
+ ### Transform Pipeline
343
+
344
+ ```mermaid
345
+ graph LR
346
+ A[Raw Model Data] --> B[Attribute Mapping]
347
+ B --> C[Transform Function]
348
+ C --> D[Validated Data]
349
+ D --> E[API Payload]
350
+
351
+ B -.->|Example| B1[email: :work_email]
352
+ C -.->|Example| C1[Add computed fields]
353
+ D -.->|Example| D1[Remove nil values]
354
+ E -.->|Example| E1[JSON structure]
355
+ ```
356
+
357
+ ### Callback Chain
358
+
359
+ ```mermaid
360
+ sequenceDiagram
361
+ participant Model
362
+ participant Callbacks
363
+ participant Sync
364
+ participant API
365
+
366
+ Model->>Callbacks: before_attio_sync
367
+ Callbacks->>Callbacks: prepare_data
368
+ Callbacks->>Sync: proceed with sync
369
+ Sync->>Sync: transform_attributes
370
+ Sync->>API: send data
371
+ API-->>Sync: response
372
+ Sync->>Callbacks: after_attio_sync
373
+ Callbacks->>Callbacks: log_sync
374
+ Callbacks->>Model: complete
375
+ ```
376
+
377
+ ## Performance Considerations
378
+
379
+ ### Optimization Strategies
380
+
381
+ ```mermaid
382
+ graph TD
383
+ A[Performance Optimizations] --> B[Batch Processing]
384
+ A --> C[Background Jobs]
385
+ A --> D[Connection Pooling]
386
+ A --> E[Smart Retries]
387
+
388
+ B --> F[Reduce API Calls]
389
+ C --> G[Non-blocking Operations]
390
+ D --> H[Reuse Connections]
391
+ E --> I[Exponential Backoff]
392
+
393
+ F --> J[Better Performance]
394
+ G --> J
395
+ H --> J
396
+ I --> J
397
+ ```
398
+
399
+ ### Load Distribution
400
+
401
+ ```mermaid
402
+ graph LR
403
+ A[100 Records to Sync] --> B[BatchSync]
404
+ B --> C[Batch 1: Records 1-25]
405
+ B --> D[Batch 2: Records 26-50]
406
+ B --> E[Batch 3: Records 51-75]
407
+ B --> F[Batch 4: Records 76-100]
408
+
409
+ C --> G[Job Queue]
410
+ D --> G
411
+ E --> G
412
+ F --> G
413
+
414
+ G --> H[Worker 1]
415
+ G --> I[Worker 2]
416
+ G --> J[Worker N]
417
+
418
+ style G fill:#9cf
419
+ ```
420
+
421
+ ## Best Practices
422
+
423
+ ### Recommended Patterns
424
+
425
+ 1. **Use Background Sync for Production**
426
+ - Prevents blocking web requests
427
+ - Provides automatic retry on failure
428
+ - Better user experience
429
+
430
+ 2. **Implement Error Handlers**
431
+ - Log errors to monitoring services
432
+ - Gracefully handle API downtime
433
+ - Notify administrators of issues
434
+
435
+ 3. **Optimize Attribute Mapping**
436
+ - Only sync necessary fields
437
+ - Use transforms to reduce payload size
438
+ - Cache computed values when possible
439
+
440
+ 4. **Test Thoroughly**
441
+ - Use provided test helpers
442
+ - Mock external API calls
443
+ - Test error scenarios
444
+
445
+ 5. **Monitor Performance**
446
+ - Track sync success rates
447
+ - Monitor job queue depth
448
+ - Alert on repeated failures
data/Gemfile CHANGED
@@ -17,7 +17,7 @@ group :development, :test do
17
17
  gem "rspec-rails", "~> 6.0"
18
18
  gem "rubocop", "~> 1.50"
19
19
  gem "rubocop-rails", "~> 2.19"
20
- gem "rubocop-rspec", "~> 2.19"
20
+ gem "rubocop-rspec", "~> 3.6"
21
21
  gem "simplecov", "~> 0.22"
22
22
  gem "simplecov-console", "~> 0.9"
23
23
  gem "sqlite3", "~> 1.4"
@@ -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
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Attio
4
4
  module Rails
5
- VERSION = "0.1.2"
5
+ VERSION = "0.2.0"
6
6
  end
7
7
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: attio-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ernest Sim
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2025-08-11 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: attio
@@ -59,9 +60,9 @@ extra_rdoc_files: []
59
60
  files:
60
61
  - ".codecov.yml"
61
62
  - ".github/dependabot.yml"
63
+ - ".github/workflows/build-and-publish.yml"
62
64
  - ".github/workflows/ci.yml"
63
65
  - ".github/workflows/docs.yml"
64
- - ".github/workflows/release.yml"
65
66
  - ".gitignore"
66
67
  - ".rspec"
67
68
  - ".rubocop.yml"
@@ -69,6 +70,7 @@ files:
69
70
  - ".yardopts"
70
71
  - CHANGELOG.md
71
72
  - CODE_OF_CONDUCT.md
73
+ - CONCEPTS.md
72
74
  - CONTRIBUTING.md
73
75
  - Gemfile
74
76
  - LICENSE.txt
@@ -83,6 +85,9 @@ files:
83
85
  - lib/attio/rails/concerns/syncable.rb
84
86
  - lib/attio/rails/configuration.rb
85
87
  - lib/attio/rails/railtie.rb
88
+ - lib/attio/rails/rspec.rb
89
+ - lib/attio/rails/rspec/helpers.rb
90
+ - lib/attio/rails/rspec/matchers.rb
86
91
  - lib/attio/rails/version.rb
87
92
  - lib/generators/attio/install/install_generator.rb
88
93
  - lib/generators/attio/install/templates/README.md
@@ -99,6 +104,7 @@ metadata:
99
104
  source_code_uri: https://github.com/idl3/attio-rails
100
105
  changelog_uri: https://github.com/idl3/attio-rails/blob/main/CHANGELOG.md
101
106
  documentation_uri: https://idl3.github.io/attio-rails
107
+ post_install_message:
102
108
  rdoc_options: []
103
109
  require_paths:
104
110
  - lib
@@ -113,7 +119,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
113
119
  - !ruby/object:Gem::Version
114
120
  version: '0'
115
121
  requirements: []
116
- rubygems_version: 3.6.9
122
+ rubygems_version: 3.4.19
123
+ signing_key:
117
124
  specification_version: 4
118
125
  summary: Rails integration for the Attio API client
119
126
  test_files: []