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.
- checksums.yaml +4 -4
- data/.document +5 -0
- data/.rspec +1 -0
- data/.ruby-version +1 -0
- data/.standalone_migrations +6 -0
- data/Gemfile +33 -0
- data/Gemfile.lock +341 -0
- data/Guardfile +4 -0
- data/README.md +0 -168
- data/Rakefile +54 -0
- data/VERSION +1 -0
- data/historiographer-4.1.12.gem +0 -0
- data/historiographer-4.1.13.gem +0 -0
- data/historiographer-4.1.14.gem +0 -0
- data/historiographer.gemspec +136 -0
- data/init.rb +18 -0
- data/instructions/implementation.md +282 -0
- data/instructions/todo.md +96 -0
- data/lib/historiographer/history.rb +1 -20
- data/lib/historiographer/version.rb +1 -1
- data/lib/historiographer.rb +27 -14
- data/spec/db/database.yml +27 -0
- data/spec/db/migrate/20161121212228_create_posts.rb +19 -0
- data/spec/db/migrate/20161121212229_create_post_histories.rb +10 -0
- data/spec/db/migrate/20161121212230_create_authors.rb +13 -0
- data/spec/db/migrate/20161121212231_create_author_histories.rb +10 -0
- data/spec/db/migrate/20161121212232_create_users.rb +9 -0
- data/spec/db/migrate/20171011194624_create_safe_posts.rb +19 -0
- data/spec/db/migrate/20171011194715_create_safe_post_histories.rb +9 -0
- data/spec/db/migrate/20191024142304_create_thing_with_compound_index.rb +10 -0
- data/spec/db/migrate/20191024142352_create_thing_with_compound_index_history.rb +11 -0
- data/spec/db/migrate/20191024203106_create_thing_without_history.rb +7 -0
- data/spec/db/migrate/20221018204220_create_silent_posts.rb +21 -0
- data/spec/db/migrate/20221018204255_create_silent_post_histories.rb +9 -0
- data/spec/db/migrate/20241109182017_create_comments.rb +13 -0
- data/spec/db/migrate/20241109182020_create_comment_histories.rb +9 -0
- data/spec/db/migrate/20241119000000_create_datasets.rb +17 -0
- data/spec/db/migrate/2025082100000_create_projects.rb +14 -0
- data/spec/db/migrate/2025082100001_create_project_files.rb +18 -0
- data/spec/db/schema.rb +352 -0
- data/spec/factories/post.rb +7 -0
- data/spec/historiographer_spec.rb +920 -0
- data/spec/models/application_record.rb +3 -0
- data/spec/models/author.rb +5 -0
- data/spec/models/author_history.rb +4 -0
- data/spec/models/comment.rb +5 -0
- data/spec/models/comment_history.rb +5 -0
- data/spec/models/easy_ml/column.rb +6 -0
- data/spec/models/easy_ml/column_history.rb +6 -0
- data/spec/models/post.rb +45 -0
- data/spec/models/post_history.rb +8 -0
- data/spec/models/project.rb +4 -0
- data/spec/models/project_file.rb +5 -0
- data/spec/models/project_file_history.rb +4 -0
- data/spec/models/project_history.rb +4 -0
- data/spec/models/safe_post.rb +5 -0
- data/spec/models/safe_post_history.rb +5 -0
- data/spec/models/silent_post.rb +3 -0
- data/spec/models/silent_post_history.rb +4 -0
- data/spec/models/thing_with_compound_index.rb +3 -0
- data/spec/models/thing_with_compound_index_history.rb +4 -0
- data/spec/models/thing_without_history.rb +2 -0
- data/spec/models/user.rb +2 -0
- data/spec/spec_helper.rb +105 -0
- 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
|