historiographer 4.1.14 → 4.3.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.
Files changed (65) hide show
  1. checksums.yaml +4 -4
  2. data/.document +5 -0
  3. data/.rspec +1 -0
  4. data/.ruby-version +1 -0
  5. data/.standalone_migrations +6 -0
  6. data/Gemfile +33 -0
  7. data/Gemfile.lock +341 -0
  8. data/Guardfile +4 -0
  9. data/README.md +0 -168
  10. data/Rakefile +54 -0
  11. data/VERSION +1 -0
  12. data/historiographer-4.1.12.gem +0 -0
  13. data/historiographer-4.1.13.gem +0 -0
  14. data/historiographer-4.1.14.gem +0 -0
  15. data/historiographer.gemspec +136 -0
  16. data/init.rb +18 -0
  17. data/instructions/implementation.md +282 -0
  18. data/instructions/todo.md +96 -0
  19. data/lib/historiographer/history.rb +1 -20
  20. data/lib/historiographer/version.rb +1 -1
  21. data/lib/historiographer.rb +27 -14
  22. data/spec/db/database.yml +27 -0
  23. data/spec/db/migrate/20161121212228_create_posts.rb +19 -0
  24. data/spec/db/migrate/20161121212229_create_post_histories.rb +10 -0
  25. data/spec/db/migrate/20161121212230_create_authors.rb +13 -0
  26. data/spec/db/migrate/20161121212231_create_author_histories.rb +10 -0
  27. data/spec/db/migrate/20161121212232_create_users.rb +9 -0
  28. data/spec/db/migrate/20171011194624_create_safe_posts.rb +19 -0
  29. data/spec/db/migrate/20171011194715_create_safe_post_histories.rb +9 -0
  30. data/spec/db/migrate/20191024142304_create_thing_with_compound_index.rb +10 -0
  31. data/spec/db/migrate/20191024142352_create_thing_with_compound_index_history.rb +11 -0
  32. data/spec/db/migrate/20191024203106_create_thing_without_history.rb +7 -0
  33. data/spec/db/migrate/20221018204220_create_silent_posts.rb +21 -0
  34. data/spec/db/migrate/20221018204255_create_silent_post_histories.rb +9 -0
  35. data/spec/db/migrate/20241109182017_create_comments.rb +13 -0
  36. data/spec/db/migrate/20241109182020_create_comment_histories.rb +9 -0
  37. data/spec/db/migrate/20241119000000_create_datasets.rb +17 -0
  38. data/spec/db/migrate/2025082100000_create_projects.rb +14 -0
  39. data/spec/db/migrate/2025082100001_create_project_files.rb +18 -0
  40. data/spec/db/schema.rb +352 -0
  41. data/spec/factories/post.rb +7 -0
  42. data/spec/historiographer_spec.rb +920 -0
  43. data/spec/models/application_record.rb +3 -0
  44. data/spec/models/author.rb +5 -0
  45. data/spec/models/author_history.rb +4 -0
  46. data/spec/models/comment.rb +5 -0
  47. data/spec/models/comment_history.rb +5 -0
  48. data/spec/models/easy_ml/column.rb +6 -0
  49. data/spec/models/easy_ml/column_history.rb +6 -0
  50. data/spec/models/post.rb +45 -0
  51. data/spec/models/post_history.rb +8 -0
  52. data/spec/models/project.rb +4 -0
  53. data/spec/models/project_file.rb +5 -0
  54. data/spec/models/project_file_history.rb +4 -0
  55. data/spec/models/project_history.rb +4 -0
  56. data/spec/models/safe_post.rb +5 -0
  57. data/spec/models/safe_post_history.rb +5 -0
  58. data/spec/models/silent_post.rb +3 -0
  59. data/spec/models/silent_post_history.rb +4 -0
  60. data/spec/models/thing_with_compound_index.rb +3 -0
  61. data/spec/models/thing_with_compound_index_history.rb +4 -0
  62. data/spec/models/thing_without_history.rb +2 -0
  63. data/spec/models/user.rb +2 -0
  64. data/spec/spec_helper.rb +105 -0
  65. metadata +62 -31
@@ -0,0 +1,920 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'spec_helper'
4
+
5
+ # Helper method to handle Rails error expectations
6
+ def expect_rails_errors(errors, expected_errors)
7
+ actual_errors = errors.respond_to?(:to_hash) ? errors.to_hash : errors.to_h
8
+ # Ensure all error messages are arrays for compatibility
9
+ actual_errors.each { |key, value| actual_errors[key] = Array(value) }
10
+ expected_errors.each { |key, value| expected_errors[key] = Array(value) }
11
+ expect(actual_errors).to eq(expected_errors)
12
+ end
13
+
14
+ describe Historiographer do
15
+ before(:each) do
16
+ @now = Timecop.freeze
17
+ end
18
+ after(:each) do
19
+ Timecop.return
20
+ end
21
+
22
+ before(:all) do
23
+ Historiographer::Configuration.mode = :histories
24
+ end
25
+
26
+ after(:all) do
27
+ Timecop.return
28
+ end
29
+
30
+ let(:username) { 'Test User' }
31
+
32
+ let(:user) do
33
+ User.create(name: username)
34
+ end
35
+
36
+ let(:create_post) do
37
+ Post.create(
38
+ title: 'Post 1',
39
+ body: 'Great post',
40
+ author_id: 1,
41
+ history_user_id: user.id
42
+ )
43
+ end
44
+
45
+ let(:create_author) do
46
+ Author.create(
47
+ full_name: 'Breezy',
48
+ history_user_id: user.id
49
+ )
50
+ end
51
+
52
+ before(:each) do
53
+ Historiographer::Configuration.mode = :histories
54
+ end
55
+
56
+ describe 'History counting' do
57
+ it 'creates history on creation of primary model record' do
58
+ expect do
59
+ create_post
60
+ end.to change {
61
+ PostHistory.count
62
+ }.by 1
63
+ end
64
+
65
+ it 'appends new history on update' do
66
+ post = create_post
67
+ expect do
68
+ post.update(title: 'Better Title')
69
+ end.to change {
70
+ PostHistory.count
71
+ }.by 1
72
+ end
73
+
74
+ it 'does not append new history if nothing has changed' do
75
+ post = create_post
76
+
77
+ expect do
78
+ post.update(title: post.title)
79
+ end.to_not change {
80
+ PostHistory.count
81
+ }
82
+ end
83
+ end
84
+
85
+ describe 'History recording' do
86
+
87
+ it 'records all fields from the parent' do
88
+ post = create_post
89
+ post_history = post.histories.first
90
+
91
+ expect(post_history.title).to eq post.title
92
+ expect(post_history.body).to eq post.body
93
+ expect(post_history.author_id).to eq post.author_id
94
+ expect(post_history.post_id).to eq post.id
95
+ expect(post_history.history_started_at).to be_within(1.second).of(@now.in_time_zone(Historiographer::UTC))
96
+ expect(post_history.history_ended_at).to be_nil
97
+ expect(post_history.history_user_id).to eq user.id
98
+
99
+ post.update(title: 'Better title')
100
+ post_histories = post.histories.reload.order('id asc')
101
+ first_history = post_histories.first
102
+ second_history = post_histories.second
103
+
104
+ expect(first_history.history_ended_at).to be_within(1.second).of(@now.in_time_zone(Historiographer::UTC))
105
+ expect(second_history.history_ended_at).to be_nil
106
+ end
107
+
108
+ it 'cannot create without history_user_id' do
109
+ post = Post.create(
110
+ title: 'Post 1',
111
+ body: 'Great post',
112
+ author_id: 1
113
+ )
114
+
115
+ # Use the helper method for error expectation
116
+ expect_rails_errors(post.errors, history_user_id: ['must be an integer'])
117
+
118
+ expect do
119
+ post.send(:record_history)
120
+ end.to raise_error(
121
+ Historiographer::HistoryUserIdMissingError
122
+ )
123
+ end
124
+
125
+ context 'When directly hitting the database via SQL' do
126
+ context '#update_all' do
127
+ it 'still updates histories' do
128
+ FactoryBot.create_list(:post, 3, history_user_id: 1)
129
+
130
+ posts = Post.all
131
+ expect(posts.count).to eq 3
132
+ expect(PostHistory.count).to eq 3
133
+ expect(posts.map(&:histories).map(&:count)).to all (eq 1)
134
+
135
+ posts.update_all(title: 'My New Post Title', history_user_id: 1)
136
+
137
+ expect(PostHistory.count).to eq 6
138
+ expect(PostHistory.current.count).to eq 3
139
+ expect(posts.map(&:histories).map(&:count)).to all(eq 2)
140
+ expect(posts.map(&:current_history).map(&:title)).to all (eq 'My New Post Title')
141
+ expect(Post.all).to respond_to :has_histories?
142
+
143
+ # It can update by sub-query
144
+ Post.where(id: [posts.first.id, posts.last.id]).update_all(title: "Brett's Post", history_user_id: 1)
145
+ posts = Post.all.reload.order(:id)
146
+ expect(posts.first.histories.count).to eq 3
147
+ expect(posts.second.histories.count).to eq 2
148
+ expect(posts.third.histories.count).to eq 3
149
+ expect(posts.first.title).to eq "Brett's Post"
150
+ expect(posts.second.title).to eq 'My New Post Title'
151
+ expect(posts.third.title).to eq "Brett's Post"
152
+ expect(posts.first.current_history.title).to eq "Brett's Post"
153
+ expect(posts.second.current_history.title).to eq 'My New Post Title'
154
+ expect(posts.third.current_history.title).to eq "Brett's Post"
155
+
156
+ # It does not update histories if nothing changed
157
+ Post.all.update_all(title: "Brett's Post", history_user_id: 1)
158
+ posts = Post.all.reload.order(:id)
159
+ expect(posts.map(&:histories).map(&:count)).to all(eq 3)
160
+
161
+ posts.update_all_without_history(title: 'Untracked')
162
+ expect(posts.first.histories.count).to eq 3
163
+ expect(posts.second.histories.count).to eq 3
164
+ expect(posts.third.histories.count).to eq 3
165
+
166
+ thing1 = ThingWithoutHistory.create(name: 'Thing 1')
167
+ thing2 = ThingWithoutHistory.create(name: 'Thing 2')
168
+
169
+ ThingWithoutHistory.all.update_all(name: 'Thing 3')
170
+ end
171
+
172
+ it 'respects safety' do
173
+ FactoryBot.create_list(:post, 3, history_user_id: 1)
174
+
175
+ posts = Post.all
176
+ expect(posts.count).to eq 3
177
+ expect(PostHistory.count).to eq 3
178
+ expect(posts.map(&:histories).map(&:count)).to all (eq 1)
179
+
180
+ expect do
181
+ posts.update_all(title: 'My New Post Title')
182
+ end.to raise_error
183
+
184
+ posts.reload.map(&:title).each do |title|
185
+ expect(title).to_not eq 'My New Post Title'
186
+ end
187
+
188
+ SafePost.create(
189
+ title: 'Post 1',
190
+ body: 'Great post',
191
+ author_id: 1
192
+ )
193
+
194
+ safe_posts = SafePost.all
195
+
196
+ expect do
197
+ safe_posts.update_all(title: 'New One')
198
+ end.to_not raise_error
199
+
200
+ expect(safe_posts.map(&:title)).to all(eq 'New One')
201
+ end
202
+ end
203
+
204
+ context '#delete_all' do
205
+ it 'includes histories when not paranoid' do
206
+ Timecop.freeze
207
+ authors = 3.times.map do
208
+ Author.create(full_name: 'Brett', history_user_id: 1)
209
+ end
210
+ Author.delete_all(history_user_id: 1)
211
+ expect(AuthorHistory.count).to eq 3
212
+ expect(AuthorHistory.current.count).to eq 0
213
+ expect(AuthorHistory.where.not(history_ended_at: nil).count).to eq 3
214
+ expect(Author.count).to eq 0
215
+ Timecop.return
216
+ end
217
+
218
+ it 'includes histories when paranoid' do
219
+ Timecop.freeze
220
+ posts = FactoryBot.create_list(:post, 3, history_user_id: 1)
221
+ Post.delete_all(history_user_id: 1)
222
+ expect(PostHistory.unscoped.count).to eq 6
223
+ expect(PostHistory.unscoped.current.count).to eq 3
224
+ expect(PostHistory.unscoped.current.map(&:deleted_at)).to all(eq Time.now)
225
+ expect(PostHistory.unscoped.current.map(&:history_user_id)).to all(eq 1)
226
+ expect(PostHistory.unscoped.where(deleted_at: nil).where.not(history_ended_at: nil).count).to eq 3
227
+ expect(PostHistory.unscoped.where(history_ended_at: nil).count).to eq 3
228
+ expect(Post.count).to eq 0
229
+ Timecop.return
230
+ end
231
+
232
+ it 'allows delete_all_without_history' do
233
+ authors = 3.times.map do
234
+ Author.create(full_name: 'Brett', history_user_id: 1)
235
+ end
236
+ Author.all.delete_all_without_history
237
+ expect(AuthorHistory.current.count).to eq 3
238
+ expect(Author.count).to eq 0
239
+ end
240
+ end
241
+
242
+ context '#destroy_all' do
243
+ it 'includes histories' do
244
+ Timecop.freeze
245
+ posts = FactoryBot.create_list(:post, 3, history_user_id: 1)
246
+ Post.destroy_all(history_user_id: 1)
247
+ expect(PostHistory.unscoped.count).to eq 6
248
+ expect(PostHistory.unscoped.current.count).to eq 3
249
+ expect(PostHistory.unscoped.current.map(&:deleted_at)).to all(eq Time.now)
250
+ expect(PostHistory.unscoped.current.map(&:history_user_id)).to all(eq 1)
251
+ expect(PostHistory.unscoped.where(deleted_at: nil).where.not(history_ended_at: nil).count).to eq 3
252
+ expect(PostHistory.unscoped.where(history_ended_at: nil).count).to eq 3
253
+ expect(Post.count).to eq 0
254
+ Timecop.return
255
+ end
256
+
257
+ it 'destroys without histories' do
258
+ Timecop.freeze
259
+ posts = FactoryBot.create_list(:post, 3, history_user_id: 1)
260
+ Post.all.destroy_all_without_history
261
+ expect(PostHistory.count).to eq 3
262
+ expect(PostHistory.current.count).to eq 3
263
+ expect(Post.count).to eq 0
264
+ Timecop.return
265
+ end
266
+ end
267
+ end
268
+
269
+ context 'When Safe mode' do
270
+ it 'creates history without history_user_id' do
271
+ expect(Rollbar).to receive(:error).with('history_user_id must be passed in order to save record with histories! If you are in a context with no history_user_id, explicitly call #save_without_history')
272
+
273
+ post = SafePost.create(
274
+ title: 'Post 1',
275
+ body: 'Great post',
276
+ author_id: 1
277
+ )
278
+ expect_rails_errors(post.errors, {})
279
+ expect(post).to be_persisted
280
+ expect(post.histories.count).to eq 1
281
+ expect(post.histories.first.history_user_id).to be_nil
282
+ end
283
+
284
+ it 'creates history with history_user_id' do
285
+ expect(Rollbar).to_not receive(:error)
286
+
287
+ post = SafePost.create(
288
+ title: 'Post 1',
289
+ body: 'Great post',
290
+ author_id: 1,
291
+ history_user_id: user.id
292
+ )
293
+ expect_rails_errors(post.errors, {})
294
+ expect(post).to be_persisted
295
+ expect(post.histories.count).to eq 1
296
+ expect(post.histories.first.history_user_id).to eq user.id
297
+ end
298
+
299
+ it 'skips history creation if desired' do
300
+ post = SafePost.new(
301
+ title: 'Post 1',
302
+ body: 'Great post',
303
+ author_id: 1
304
+ )
305
+
306
+ post.save_without_history
307
+ expect(post).to be_persisted
308
+ expect(post.histories.count).to eq 0
309
+ end
310
+ end
311
+
312
+ context 'When Silent mode' do
313
+ it 'creates history without history_user_id' do
314
+ expect(Rollbar).to_not receive(:error)
315
+
316
+ post = SilentPost.create(
317
+ title: 'Post 1',
318
+ body: 'Great post',
319
+ author_id: 1
320
+ )
321
+
322
+ expect_rails_errors(post.errors, {})
323
+ expect(post).to be_persisted
324
+ expect(post.histories.count).to eq 1
325
+ expect(post.histories.first.history_user_id).to be_nil
326
+
327
+ post.update(title: 'New Title')
328
+ post.reload
329
+ expect(post.title).to eq 'New Title' # No error was raised
330
+ end
331
+
332
+ it 'creates history with history_user_id' do
333
+ expect(Rollbar).to_not receive(:error)
334
+
335
+ post = SilentPost.create(
336
+ title: 'Post 1',
337
+ body: 'Great post',
338
+ author_id: 1,
339
+ history_user_id: user.id
340
+ )
341
+ expect_rails_errors(post.errors, {})
342
+ expect(post).to be_persisted
343
+ expect(post.histories.count).to eq 1
344
+ expect(post.histories.first.history_user_id).to eq user.id
345
+ end
346
+
347
+ it 'skips history creation if desired' do
348
+ post = SilentPost.new(
349
+ title: 'Post 1',
350
+ body: 'Great post',
351
+ author_id: 1
352
+ )
353
+
354
+ post.save_without_history
355
+ expect(post).to be_persisted
356
+ expect(post.histories.count).to eq 0
357
+ end
358
+ end
359
+ it 'can override without history_user_id' do
360
+ expect do
361
+ post = Post.new(
362
+ title: 'Post 1',
363
+ body: 'Great post',
364
+ author_id: 1
365
+ )
366
+
367
+ post.save_without_history
368
+ end.to_not raise_error
369
+ end
370
+
371
+ it 'can override without history_user_id' do
372
+ expect do
373
+ post = Post.new(
374
+ title: 'Post 1',
375
+ body: 'Great post',
376
+ author_id: 1
377
+ )
378
+
379
+ post.save_without_history!
380
+ end.to_not raise_error
381
+ end
382
+
383
+ it 'does not record histories when main model fails to save' do
384
+ class Post
385
+ after_save :raise_error, prepend: true
386
+
387
+ def raise_error
388
+ raise 'Oh no, db issue!'
389
+ end
390
+ end
391
+
392
+ expect { create_post }.to raise_error
393
+ expect(Post.count).to be 0
394
+ expect(PostHistory.count).to be 0
395
+
396
+ Post.skip_callback(:save, :after, :raise_error)
397
+ end
398
+ end
399
+
400
+
401
+ describe 'Scopes' do
402
+ it 'finds current histories' do
403
+ post1 = create_post
404
+ post1.update(title: 'Better title')
405
+
406
+ post2 = create_post
407
+ post2.update(title: 'Better title')
408
+
409
+ expect(PostHistory.current.pluck(:title)).to all eq 'Better title'
410
+ expect(post1.current_history.title).to eq 'Better title'
411
+ end
412
+ end
413
+
414
+ describe 'Associations' do
415
+ it 'names associated records' do
416
+ post1 = create_post
417
+ expect(post1.histories.first).to be_a(PostHistory)
418
+
419
+ expect(post1.histories.first.post).to eq(post1)
420
+
421
+ author1 = create_author
422
+ expect(author1.histories.first).to be_a(AuthorHistory)
423
+
424
+ expect(author1.histories.first.author).to eq(author1)
425
+ end
426
+ end
427
+
428
+ describe 'Histories' do
429
+ it 'does not allow direct updates of histories' do
430
+ post1 = create_post
431
+ hist1 = post1.histories.first
432
+
433
+ expect(hist1.update(title: 'A different title')).to be false
434
+ expect(hist1.reload.title).to eq post1.title
435
+
436
+ expect(hist1.update!(title: 'A different title')).to be false
437
+ expect(hist1.reload.title).to eq post1.title
438
+
439
+ hist1.title = 'A different title'
440
+ expect(hist1.save).to be false
441
+ expect(hist1.reload.title).to eq post1.title
442
+
443
+ hist1.title = 'A different title'
444
+ expect(hist1.save!).to be false
445
+ expect(hist1.reload.title).to eq post1.title
446
+ end
447
+
448
+ it 'does not allow destroys of histories' do
449
+ post1 = create_post
450
+ hist1 = post1.histories.first
451
+ original_history_count = post1.histories.count
452
+
453
+ expect(hist1.destroy).to be false
454
+ expect(hist1.destroy!).to be false
455
+
456
+ expect(post1.histories.count).to be original_history_count
457
+ end
458
+ end
459
+
460
+ describe 'Deletion' do
461
+ it 'records deleted_at and history_user_id on primary and history if you use acts_as_paranoid' do
462
+ post = Post.create(
463
+ title: 'Post 1',
464
+ body: 'Great post',
465
+ author_id: 1,
466
+ history_user_id: user.id
467
+ )
468
+
469
+ expect do
470
+ post.destroy(history_user_id: 2)
471
+ end.to change {
472
+ PostHistory.unscoped.count
473
+ }.by 1
474
+
475
+ expect(Post.unscoped.where.not(deleted_at: nil).count).to eq 1
476
+ expect(Post.unscoped.where(deleted_at: nil).count).to eq 0
477
+ expect(PostHistory.unscoped.where.not(deleted_at: nil).count).to eq 1
478
+ expect(PostHistory.unscoped.last.history_user_id).to eq 2
479
+ end
480
+
481
+ it 'works with Historiographer::Safe' do
482
+ post = SafePost.create(title: 'HELLO', body: 'YO', author_id: 1)
483
+
484
+ expect do
485
+ post.destroy
486
+ end.to_not raise_error
487
+
488
+ expect(SafePost.unscoped.count).to eq 1
489
+ expect(post.deleted_at).to_not be_nil
490
+ expect(SafePostHistory.unscoped.count).to eq 2
491
+ expect(SafePostHistory.unscoped.current.last.deleted_at).to eq post.deleted_at
492
+
493
+ post2 = SafePost.create(title: 'HELLO', body: 'YO', author_id: 1)
494
+
495
+ expect do
496
+ post2.destroy!
497
+ end.to_not raise_error
498
+
499
+ expect(SafePost.count).to eq 0
500
+ expect(post2.deleted_at).to_not be_nil
501
+ expect(SafePostHistory.unscoped.count).to eq 4
502
+ expect(SafePostHistory.unscoped.current.where(safe_post_id: post2.id).last.deleted_at).to eq post2.deleted_at
503
+ end
504
+ end
505
+
506
+ describe 'Empty insertion handling' do
507
+ it 'handles duplicate history gracefully by returning existing record' do
508
+ # Create post without history tracking to avoid initial history
509
+ post = Post.new(
510
+ title: 'Post 1',
511
+ body: 'Great post',
512
+ author_id: 1,
513
+ history_user_id: user.id
514
+ )
515
+ post.save_without_history
516
+
517
+ # Freeze time to ensure same timestamp
518
+ Timecop.freeze do
519
+ # Create a history record with current timestamp
520
+ now = Historiographer::UTC.now
521
+ attrs = post.send(:history_attrs, now: now)
522
+ existing_history = PostHistory.create!(attrs)
523
+
524
+ # Mock insert_all to return empty result (simulating duplicate constraint)
525
+ empty_result = double('result')
526
+ allow(empty_result).to receive(:rows).and_return([])
527
+
528
+ allow(PostHistory).to receive(:insert_all).and_return(empty_result)
529
+
530
+ # The method should find and return the existing history
531
+ allow(Rails.logger).to receive(:warn).with(/Duplicate history detected/) if Rails.logger
532
+ result = post.send(:record_history)
533
+ expect(result.id).to eq(existing_history.id)
534
+ expect(result.post_id).to eq(post.id)
535
+ end
536
+ end
537
+
538
+ it 'raises error when insert fails and no existing record found' do
539
+ post = create_post
540
+
541
+ # Mock insert_all to return an empty result
542
+ empty_result = double('result')
543
+ allow(empty_result).to receive(:rows).and_return([])
544
+
545
+ allow(PostHistory).to receive(:insert_all).and_return(empty_result)
546
+
547
+ # Mock the where clause for finding existing history to return nothing
548
+ # We need to be specific about the where clause we're mocking
549
+ original_where = PostHistory.method(:where)
550
+ allow(PostHistory).to receive(:where) do |*args|
551
+ # Check if this is the specific query for finding duplicates
552
+ # The foreign key is "post_id" (string) and we're checking for history_started_at
553
+ if args.first.is_a?(Hash) && args.first.keys.include?("post_id") && args.first.keys.include?(:history_started_at)
554
+ # Return a double that returns nil when .first is called
555
+ double('where').tap { |d| allow(d).to receive(:first).and_return(nil) }
556
+ else
557
+ # For all other queries, use the original behavior
558
+ original_where.call(*args)
559
+ end
560
+ end
561
+
562
+ # This should raise a meaningful error
563
+ expect {
564
+ post.send(:record_history)
565
+ }.to raise_error(Historiographer::HistoryInsertionError, /Failed to insert history record.*no existing history was found/)
566
+ end
567
+
568
+ it 'provides meaningful error when insertion fails' do
569
+ post = create_post
570
+
571
+ # Mock insert_all to simulate a database-level failure
572
+ # This could happen due to various reasons:
573
+ # - Database is read-only
574
+ # - Connection issues
575
+ # - Constraint violations that prevent insertion
576
+ allow(PostHistory).to receive(:insert_all).and_raise(ActiveRecord::StatementInvalid, "PG::ReadOnlySqlTransaction: ERROR: cannot execute INSERT in a read-only transaction")
577
+
578
+ expect {
579
+ post.send(:record_history)
580
+ }.to raise_error(ActiveRecord::StatementInvalid)
581
+ end
582
+
583
+ it 'successfully inserts history when everything is valid' do
584
+ post = create_post
585
+
586
+ # Clear existing histories
587
+ PostHistory.where(post_id: post.id).destroy_all
588
+
589
+ # Record a new history
590
+ history = post.send(:record_history)
591
+
592
+ expect(history).to be_a(PostHistory)
593
+ expect(history).to be_persisted
594
+ expect(history.post_id).to eq(post.id)
595
+ expect(history.title).to eq(post.title)
596
+ expect(history.body).to eq(post.body)
597
+ end
598
+
599
+ it 'handles race conditions by returning existing history' do
600
+ post = create_post
601
+
602
+ # Simulate a race condition where the same history_started_at timestamp is used
603
+ now = Time.now
604
+ allow(Historiographer::UTC).to receive(:now).and_return(now)
605
+
606
+ # First process creates history
607
+ history1 = post.histories.last
608
+
609
+ # Second process tries to create history with same timestamp
610
+ # This would normally cause insert_all to return empty rows
611
+ history2 = post.send(:record_history)
612
+
613
+ # Should handle gracefully
614
+ expect(history2).to be_a(PostHistory)
615
+ end
616
+ end
617
+
618
+ describe 'Scopes' do
619
+ it 'finds current' do
620
+ post = create_post
621
+ post.update(title: 'New Title')
622
+ post.update(title: 'New Title 2')
623
+
624
+ expect(PostHistory.current.count).to be 1
625
+ end
626
+ end
627
+
628
+ describe 'User associations' do
629
+ it 'links to user' do
630
+ post = create_post
631
+ author = create_author
632
+
633
+ expect(post.current_history.user.name).to eq username
634
+ expect(author.current_history.user.name).to eq username
635
+ end
636
+ end
637
+
638
+ describe 'Migrations with compound indexes' do
639
+ it 'supports renaming compound indexes and migrating them to history tables' do
640
+ indices_sql = "
641
+ SELECT
642
+ DISTINCT(
643
+ ARRAY_TO_STRING(ARRAY(
644
+ SELECT pg_get_indexdef(idx.indexrelid, k + 1, true)
645
+ FROM generate_subscripts(idx.indkey, 1) as k
646
+ ORDER BY k
647
+ ), ',')
648
+ ) as indkey_names
649
+ FROM pg_class t,
650
+ pg_class i,
651
+ pg_index idx,
652
+ pg_attribute a,
653
+ pg_am am
654
+ WHERE t.oid = idx.indrelid
655
+ AND i.oid = idx.indexrelid
656
+ AND a.attrelid = t.oid
657
+ AND a.attnum = ANY(idx.indkey)
658
+ AND t.relkind = 'r'
659
+ AND t.relname = ?;
660
+ "
661
+
662
+ indices_query_array = [indices_sql, :thing_with_compound_index_histories]
663
+ indices_sanitized_query = ThingWithCompoundIndexHistory.send(:sanitize_sql_array, indices_query_array)
664
+
665
+ indexes = ThingWithCompoundIndexHistory.connection.execute(indices_sanitized_query).to_a.map(&:values).flatten.map { |i| i.split(',') }
666
+
667
+ expect(indexes).to include(['history_started_at'])
668
+ expect(indexes).to include(['history_ended_at'])
669
+ expect(indexes).to include(['history_user_id'])
670
+ expect(indexes).to include(['id'])
671
+ expect(indexes).to include(%w[key value])
672
+ expect(indexes).to include(['thing_with_compound_index_id'])
673
+ end
674
+ end
675
+
676
+ describe 'Reified Histories' do
677
+ let(:post) { create_post }
678
+ let(:post_history) { post.histories.first }
679
+ let(:author) { Author.create(full_name: 'Commenter Jones', history_user_id: user.id) }
680
+ let(:comment) { Comment.create(post: post, author: author, history_user_id: user.id) }
681
+
682
+ it 'responds to methods defined on the original class' do
683
+ expect(post_history).to respond_to(:summary)
684
+ expect(post_history.summary).to eq('This is a summary of the post.')
685
+ end
686
+
687
+ it 'behaves like the original class for attribute methods' do
688
+ expect(post_history.title).to eq(post.title)
689
+ expect(post_history.body).to eq(post.body)
690
+ end
691
+
692
+ it 'supports custom instance methods' do
693
+ expect(post_history).to respond_to(:formatted_title)
694
+ expect(post_history.formatted_title).to eq("Title: #{post.title}")
695
+ end
696
+
697
+ it "does not do things histories shouldn't do" do
698
+ post_history.update(title: "new title")
699
+ expect(post_history.reload.title).to eq "Post 1"
700
+
701
+ post_history.destroy
702
+ expect(post_history.reload.title).to eq "Post 1"
703
+ end
704
+ end
705
+
706
+ describe 'Snapshots' do
707
+ let(:post) { create_post }
708
+ let(:author) { Author.create(full_name: 'Commenter Jones', history_user_id: user.id) }
709
+ let(:comment) { Comment.create(body: "Mean comment! I hate you!", post: post, author: author, history_user_id: user.id) }
710
+
711
+ it 'creates a snapshot of the post and its associations' do
712
+ # Take a snapshot
713
+ comment # Make sure all records are created
714
+ post.snapshot
715
+
716
+ # Verify snapshot
717
+ snapshot_post = PostHistory.where.not(snapshot_id: nil).last
718
+ expect(snapshot_post.title).to eq post.title
719
+ expect(snapshot_post.formatted_title).to eq post.formatted_title
720
+
721
+ snapshot_comment = snapshot_post.comments.first
722
+ expect(snapshot_comment.body).to eq comment.body
723
+ expect(snapshot_comment.post_id).to eq post.id
724
+ expect(snapshot_comment.class.name.to_s).to eq "CommentHistory"
725
+
726
+ snapshot_author = snapshot_comment.author
727
+ expect(snapshot_author.full_name).to eq author.full_name
728
+ expect(snapshot_author.class.name.to_s).to eq "AuthorHistory"
729
+
730
+ # Snapshots do not allow change
731
+ expect(snapshot_post.update(title: "My title")).to eq false
732
+ expect(snapshot_post.reload.title).to eq post.title
733
+ end
734
+
735
+ it "allows override of methods on history class" do
736
+ post.snapshot
737
+ expect(post.latest_snapshot.locked_value).to eq "My Great Post v100"
738
+ expect(post.locked_value).to eq "My Great Post v1"
739
+
740
+ expect(post.complex_lookup).to eq "Here is a complicated value, it is: My Great Post v1 And another: Title: Post 1"
741
+ expect(post.latest_snapshot.complex_lookup).to eq "Here is a complicated value, it is: My Great Post v100 And another: Title: Post 1"
742
+ end
743
+
744
+ it "returns the latest snapshot" do
745
+ Timecop.freeze(Time.now)
746
+ # Take a snapshot
747
+ comment # Make sure all records are created
748
+ post.snapshot(history_user_id: user.id)
749
+ comment.destroy(history_user_id: user.id)
750
+ post.comments.create!(post: post, author: author, history_user_id: user.id, body: "Sorry man, didn't mean to post that")
751
+
752
+ expect(PostHistory.count).to eq 1
753
+ expect(CommentHistory.count).to eq 2
754
+ expect(AuthorHistory.count).to eq 1
755
+
756
+ Timecop.freeze(Time.now + 5.minutes)
757
+ post.snapshot(history_user_id: user.id)
758
+
759
+ expect(PostHistory.count).to eq 2
760
+ expect(CommentHistory.count).to eq 2
761
+ expect(AuthorHistory.count).to eq 2
762
+
763
+ # Verify snapshot
764
+ snapshot_post = post.latest_snapshot
765
+ expect(snapshot_post.title).to eq post.title
766
+ expect(snapshot_post.formatted_title).to eq post.formatted_title
767
+
768
+ snapshot_comment = snapshot_post.comments.first
769
+ expect(snapshot_post.comments.count).to eq 1
770
+ expect(snapshot_comment.body).to eq "Sorry man, didn't mean to post that"
771
+ expect(snapshot_comment.post_id).to eq post.id
772
+ expect(snapshot_comment.class.name.to_s).to eq "CommentHistory"
773
+
774
+ snapshot_author = snapshot_comment.author
775
+ expect(snapshot_author.full_name).to eq author.full_name
776
+ expect(snapshot_author.class.name.to_s).to eq "AuthorHistory"
777
+
778
+ # Snapshots do not allow change
779
+ expect(snapshot_post.update(title: "My title")).to eq false
780
+ expect(snapshot_post.reload.title).to eq post.title
781
+
782
+ Timecop.return
783
+ end
784
+
785
+ it "uses snapshot_only mode" do
786
+ Historiographer::Configuration.mode = :snapshot_only
787
+
788
+ comment # Make sure all records are created
789
+ post
790
+ expect(PostHistory.count).to eq 0
791
+ expect(CommentHistory.count).to eq 0
792
+ expect(AuthorHistory.count).to eq 0
793
+
794
+ post.snapshot
795
+ expect(PostHistory.count).to eq 1
796
+ expect(CommentHistory.count).to eq 1
797
+ expect(AuthorHistory.count).to eq 1
798
+
799
+ comment.destroy(history_user_id: user.id)
800
+ post.comments.create!(post: post, author: author, history_user_id: user.id, body: "Sorry man, didn't mean to post that")
801
+
802
+ expect(PostHistory.count).to eq 1
803
+ expect(CommentHistory.count).to eq 1
804
+ expect(AuthorHistory.count).to eq 1
805
+
806
+ Timecop.freeze(Time.now + 5.minutes)
807
+ post.snapshot
808
+
809
+ expect(PostHistory.count).to eq 2
810
+ expect(CommentHistory.count).to eq 2
811
+ expect(AuthorHistory.count).to eq 2
812
+ end
813
+
814
+ it "runs callbacks at the appropriate time" do
815
+ comment
816
+ post.snapshot # 1 comment
817
+ comment2 = comment.dup
818
+ comment2.body = "Hello there"
819
+ comment2.save
820
+
821
+ post.reload
822
+ expect(post.comment_count).to eq 2
823
+ expect(post.latest_snapshot.comment_count).to eq 1
824
+ end
825
+
826
+ it "doesn't explode" do
827
+ project = Project.create(name: "test_project")
828
+ project_file = ProjectFile.create(project: project, name: "test_file", content: "Hello world")
829
+
830
+ original_snapshot = project.snapshot
831
+
832
+ project_file.update(content: "Goodnight moon")
833
+ new_snapshot = project.snapshot
834
+
835
+ expect(original_snapshot.files.map(&:class)).to eq [ProjectFileHistory]
836
+ expect(new_snapshot.files.map(&:class)).to eq [ProjectFileHistory]
837
+
838
+ expect(new_snapshot.files.first.content).to eq "Goodnight moon"
839
+ expect(original_snapshot.files.first.content).to eq "Hello world"
840
+ end
841
+ end
842
+
843
+
844
+ describe 'Class-level mode setting' do
845
+ before(:each) do
846
+ Historiographer::Configuration.mode = :histories
847
+ end
848
+
849
+ it "uses class-level snapshot_only mode" do
850
+ class Post < ApplicationRecord
851
+ historiographer_mode :snapshot_only
852
+ end
853
+
854
+ author = Author.create(full_name: 'Commenter Jones', history_user_id: user.id)
855
+ post = Post.create(title: 'Snapshot Only Post', body: 'Test', author_id: 1, history_user_id: user.id)
856
+ comment = Comment.create(post: post, author: author, history_user_id: user.id, body: "Initial comment")
857
+
858
+ expect(PostHistory.count).to eq 0
859
+ expect(CommentHistory.count).to eq 1 # Comment still uses default :histories mode
860
+
861
+ post.snapshot
862
+ expect(PostHistory.count).to eq 1
863
+ expect(CommentHistory.count).to eq 1
864
+
865
+ post.update(title: 'Updated Snapshot Only Post', history_user_id: user.id)
866
+ expect(PostHistory.count).to eq 1 # No new history created for update
867
+ expect(CommentHistory.count).to eq 1
868
+
869
+ Timecop.freeze(Time.now + 5.minutes)
870
+ post.snapshot
871
+
872
+ expect(PostHistory.count).to eq 2
873
+ expect(CommentHistory.count).to eq 2 # Comment creates a new history
874
+
875
+ Timecop.return
876
+
877
+ class Post < ApplicationRecord
878
+ historiographer_mode nil
879
+ end
880
+ end
881
+ end
882
+
883
+ describe 'Moduleized Classes' do
884
+ let(:user) { User.create(name: 'Test User') }
885
+ let(:column) do
886
+ EasyML::Column.create(
887
+ name: 'feature_1',
888
+ data_type: 'numeric',
889
+ history_user_id: user.id
890
+ )
891
+ end
892
+
893
+ it 'maintains proper namespacing in history class' do
894
+ expect(column).to be_a(EasyML::Column)
895
+ expect(column.histories.first).to be_a(EasyML::ColumnHistory)
896
+ expect(EasyML::Column.history_class).to eq(EasyML::ColumnHistory)
897
+ end
898
+
899
+ it 'establishes correct foreign key for history association' do
900
+ col_history = column.histories.first
901
+ expect(col_history.class.history_foreign_key).to eq('column_id')
902
+ expect(col_history).to be_a(EasyML::ColumnHistory)
903
+ end
904
+
905
+
906
+ it 'uses correct table names' do
907
+ expect(EasyML::Column.table_name).to eq('easy_ml_columns')
908
+ expect(EasyML::ColumnHistory.table_name).to eq('easy_ml_column_histories')
909
+ end
910
+
911
+ it 'creates and updates history records properly' do
912
+ original_name = column.name
913
+ column.update(name: 'feature_2', history_user_id: user.id)
914
+
915
+ expect(column.histories.count).to eq(2)
916
+ expect(column.histories.first.name).to eq(original_name)
917
+ expect(column.histories.last.name).to eq('feature_2')
918
+ end
919
+ end
920
+ end