historiographer 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,19 @@
1
+ class CreateSafePosts < ActiveRecord::Migration[5.1]
2
+ def change
3
+ create_table :safe_posts do |t|
4
+ t.string :title, null: false
5
+ t.text :body, null: false
6
+ t.integer :author_id, null: false
7
+ t.boolean :enabled, default: false
8
+ t.datetime :live_at
9
+ t.datetime :deleted_at
10
+
11
+ t.timestamps
12
+ end
13
+
14
+ add_index :safe_posts, :author_id
15
+ add_index :safe_posts, :enabled
16
+ add_index :safe_posts, :live_at
17
+ add_index :safe_posts, :deleted_at
18
+ end
19
+ end
@@ -0,0 +1,9 @@
1
+ require "historiographer/postgres_migration"
2
+
3
+ class CreateSafePostHistories < ActiveRecord::Migration[5.1]
4
+ def change
5
+ create_table :safe_post_histories do |t|
6
+ t.histories
7
+ end
8
+ end
9
+ end
data/spec/db/schema.rb ADDED
@@ -0,0 +1,121 @@
1
+ # This file is auto-generated from the current state of the database. Instead
2
+ # of editing this file, please use the migrations feature of Active Record to
3
+ # incrementally modify your database, and then regenerate this schema definition.
4
+ #
5
+ # Note that this schema.rb definition is the authoritative source for your
6
+ # database schema. If you need to create the application database on another
7
+ # system, you should be using db:schema:load, not running all the migrations
8
+ # from scratch. The latter is a flawed and unsustainable approach (the more migrations
9
+ # you'll amass, the slower it'll run and the greater likelihood for issues).
10
+ #
11
+ # It's strongly recommended that you check this file into your version control system.
12
+
13
+ ActiveRecord::Schema.define(version: 2017_10_11_194715) do
14
+
15
+ create_table "author_histories", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
16
+ t.integer "author_id", null: false
17
+ t.string "full_name", null: false
18
+ t.text "bio"
19
+ t.datetime "deleted_at"
20
+ t.datetime "created_at", null: false
21
+ t.datetime "updated_at", null: false
22
+ t.datetime "history_started_at", null: false
23
+ t.datetime "history_ended_at"
24
+ t.integer "history_user_id"
25
+ t.index ["author_id"], name: "index_author_histories_on_author_id"
26
+ t.index ["deleted_at"], name: "index_author_histories_on_deleted_at"
27
+ t.index ["history_ended_at"], name: "index_author_histories_on_history_ended_at"
28
+ t.index ["history_started_at"], name: "index_author_histories_on_history_started_at"
29
+ t.index ["history_user_id"], name: "index_author_histories_on_history_user_id"
30
+ end
31
+
32
+ create_table "authors", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
33
+ t.string "full_name", null: false
34
+ t.text "bio"
35
+ t.datetime "deleted_at"
36
+ t.datetime "created_at", null: false
37
+ t.datetime "updated_at", null: false
38
+ t.index ["deleted_at"], name: "index_authors_on_deleted_at"
39
+ end
40
+
41
+ create_table "post_histories", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
42
+ t.integer "post_id", null: false
43
+ t.string "title", null: false
44
+ t.text "body", null: false
45
+ t.integer "author_id", null: false
46
+ t.boolean "enabled", default: false
47
+ t.datetime "live_at"
48
+ t.datetime "deleted_at"
49
+ t.datetime "created_at", null: false
50
+ t.datetime "updated_at", null: false
51
+ t.datetime "history_started_at", null: false
52
+ t.datetime "history_ended_at"
53
+ t.integer "history_user_id"
54
+ t.index ["author_id"], name: "index_post_histories_on_author_id"
55
+ t.index ["deleted_at"], name: "index_post_histories_on_deleted_at"
56
+ t.index ["enabled"], name: "index_post_histories_on_enabled"
57
+ t.index ["history_ended_at"], name: "index_post_histories_on_history_ended_at"
58
+ t.index ["history_started_at"], name: "index_post_histories_on_history_started_at"
59
+ t.index ["history_user_id"], name: "index_post_histories_on_history_user_id"
60
+ t.index ["live_at"], name: "index_post_histories_on_live_at"
61
+ t.index ["post_id"], name: "index_post_histories_on_post_id"
62
+ end
63
+
64
+ create_table "posts", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
65
+ t.string "title", null: false
66
+ t.text "body", null: false
67
+ t.integer "author_id", null: false
68
+ t.boolean "enabled", default: false
69
+ t.datetime "live_at"
70
+ t.datetime "deleted_at"
71
+ t.datetime "created_at", null: false
72
+ t.datetime "updated_at", null: false
73
+ t.index ["author_id"], name: "index_posts_on_author_id"
74
+ t.index ["deleted_at"], name: "index_posts_on_deleted_at"
75
+ t.index ["enabled"], name: "index_posts_on_enabled"
76
+ t.index ["live_at"], name: "index_posts_on_live_at"
77
+ end
78
+
79
+ create_table "safe_post_histories", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
80
+ t.integer "safe_post_id", null: false
81
+ t.string "title", null: false
82
+ t.text "body", null: false
83
+ t.integer "author_id", null: false
84
+ t.boolean "enabled", default: false
85
+ t.datetime "live_at"
86
+ t.datetime "deleted_at"
87
+ t.datetime "created_at", null: false
88
+ t.datetime "updated_at", null: false
89
+ t.datetime "history_started_at", null: false
90
+ t.datetime "history_ended_at"
91
+ t.integer "history_user_id"
92
+ t.index ["author_id"], name: "index_safe_post_histories_on_author_id"
93
+ t.index ["deleted_at"], name: "index_safe_post_histories_on_deleted_at"
94
+ t.index ["enabled"], name: "index_safe_post_histories_on_enabled"
95
+ t.index ["history_ended_at"], name: "index_safe_post_histories_on_history_ended_at"
96
+ t.index ["history_started_at"], name: "index_safe_post_histories_on_history_started_at"
97
+ t.index ["history_user_id"], name: "index_safe_post_histories_on_history_user_id"
98
+ t.index ["live_at"], name: "index_safe_post_histories_on_live_at"
99
+ t.index ["safe_post_id"], name: "index_safe_post_histories_on_safe_post_id"
100
+ end
101
+
102
+ create_table "safe_posts", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
103
+ t.string "title", null: false
104
+ t.text "body", null: false
105
+ t.integer "author_id", null: false
106
+ t.boolean "enabled", default: false
107
+ t.datetime "live_at"
108
+ t.datetime "deleted_at"
109
+ t.datetime "created_at", null: false
110
+ t.datetime "updated_at", null: false
111
+ t.index ["author_id"], name: "index_safe_posts_on_author_id"
112
+ t.index ["deleted_at"], name: "index_safe_posts_on_deleted_at"
113
+ t.index ["enabled"], name: "index_safe_posts_on_enabled"
114
+ t.index ["live_at"], name: "index_safe_posts_on_live_at"
115
+ end
116
+
117
+ create_table "users", options: "ENGINE=InnoDB DEFAULT CHARSET=utf8", force: :cascade do |t|
118
+ t.string "name"
119
+ end
120
+
121
+ end
data/spec/examples.txt ADDED
@@ -0,0 +1,21 @@
1
+ example_id | status | run_time |
2
+ --------------------------------------- | ------ | --------------- |
3
+ ./spec/historiographer_spec.rb[1:1:1] | passed | 0.00794 seconds |
4
+ ./spec/historiographer_spec.rb[1:1:2] | passed | 0.04572 seconds |
5
+ ./spec/historiographer_spec.rb[1:1:3] | passed | 0.03308 seconds |
6
+ ./spec/historiographer_spec.rb[1:2:1] | passed | 0.0742 seconds |
7
+ ./spec/historiographer_spec.rb[1:2:2] | passed | 0.00138 seconds |
8
+ ./spec/historiographer_spec.rb[1:2:3:1] | passed | 0.01369 seconds |
9
+ ./spec/historiographer_spec.rb[1:2:3:2] | passed | 0.059 seconds |
10
+ ./spec/historiographer_spec.rb[1:2:3:3] | passed | 0.00794 seconds |
11
+ ./spec/historiographer_spec.rb[1:2:4] | passed | 0.00348 seconds |
12
+ ./spec/historiographer_spec.rb[1:2:5] | passed | 0.03144 seconds |
13
+ ./spec/historiographer_spec.rb[1:2:6] | passed | 0.01239 seconds |
14
+ ./spec/historiographer_spec.rb[1:3:1] | passed | 0.04853 seconds |
15
+ ./spec/historiographer_spec.rb[1:4:1] | passed | 0.03917 seconds |
16
+ ./spec/historiographer_spec.rb[1:5:1] | passed | 0.03402 seconds |
17
+ ./spec/historiographer_spec.rb[1:5:2] | passed | 0.02494 seconds |
18
+ ./spec/historiographer_spec.rb[1:6:1] | passed | 0.0287 seconds |
19
+ ./spec/historiographer_spec.rb[1:7:1] | passed | 0.07329 seconds |
20
+ ./spec/historiographer_spec.rb[1:7:2] | passed | 0.01293 seconds |
21
+ ./spec/historiographer_spec.rb[1:8:1] | passed | 0.08211 seconds |
@@ -0,0 +1,395 @@
1
+ require "spec_helper"
2
+
3
+ class Post < ActiveRecord::Base
4
+ include Historiographer
5
+ end
6
+
7
+ class PostHistory < ActiveRecord::Base
8
+ end
9
+
10
+ class SafePost < ActiveRecord::Base
11
+ include Historiographer::Safe
12
+ end
13
+
14
+ class SafePostHistory < ActiveRecord::Base
15
+ end
16
+
17
+ class Author < ActiveRecord::Base
18
+ include Historiographer
19
+ end
20
+
21
+ class AuthorHistory < ActiveRecord::Base
22
+ end
23
+
24
+ class User < ActiveRecord::Base
25
+ end
26
+
27
+ describe Historiographer do
28
+ before(:all) do
29
+ @now = Timecop.freeze
30
+ end
31
+
32
+ after(:all) do
33
+ Timecop.return
34
+ end
35
+
36
+ let(:username) { "Test User" }
37
+
38
+ let(:user) do
39
+ User.create(name: username)
40
+ end
41
+
42
+ let(:create_post) do
43
+ Post.create(
44
+ title: "Post 1",
45
+ body: "Great post",
46
+ author_id: 1,
47
+ history_user_id: user.id
48
+ )
49
+ end
50
+
51
+ let(:create_author) do
52
+ Author.create(
53
+ full_name: "Breezy",
54
+ history_user_id: user.id
55
+ )
56
+ end
57
+
58
+ describe "History counting" do
59
+ it "creates history on creation of primary model record" do
60
+ expect {
61
+ create_post
62
+ }.to change {
63
+ PostHistory.count
64
+ }.by 1
65
+ end
66
+
67
+ it "appends new history on update" do
68
+ post = create_post
69
+
70
+ expect {
71
+ post.update(title: "Better Title")
72
+ }.to change {
73
+ PostHistory.count
74
+ }.by 1
75
+ end
76
+
77
+ it "does not append new history if nothing has changed" do
78
+ post = create_post
79
+
80
+ expect {
81
+ post.update(title: post.title)
82
+ }.to_not change {
83
+ PostHistory.count
84
+ }
85
+ end
86
+ end
87
+
88
+ describe "History recording" do
89
+ it "records all fields from the parent" do
90
+ post = create_post
91
+ post_history = post.histories.first
92
+
93
+ expect(post_history.title).to eq post.title
94
+ expect(post_history.body).to eq post.body
95
+ expect(post_history.author_id).to eq post.author_id
96
+ expect(post_history.post_id).to eq post.id
97
+ expect(post_history.history_started_at.to_s).to eq @now.in_time_zone(Historiographer::UTC).to_s
98
+ expect(post_history.history_ended_at).to be_nil
99
+ expect(post_history.history_user_id).to eq user.id
100
+
101
+ post.update(title: "Better title")
102
+ post_histories = post.histories.reload.order("id asc")
103
+ first_history = post_histories.first
104
+ second_history = post_histories.second
105
+
106
+ expect(first_history.history_ended_at.to_s).to eq @now.in_time_zone(Historiographer::UTC).to_s
107
+ expect(second_history.history_ended_at).to be_nil
108
+ end
109
+
110
+ it "cannot create without history_user_id" do
111
+ post = Post.create(
112
+ title: "Post 1",
113
+ body: "Great post",
114
+ author_id: 1,
115
+ )
116
+ expect(post.errors.to_h).to eq({ :history_user_id => "must be an integer" })
117
+
118
+ expect {
119
+ post.send(:record_history)
120
+ }.to raise_error(
121
+ Historiographer::HistoryUserIdMissingError
122
+ )
123
+ end
124
+
125
+ context "When Safe mode" do
126
+ it "creates history without history_user_id" do
127
+ 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")
128
+
129
+ post = SafePost.create(
130
+ title: "Post 1",
131
+ body: "Great post",
132
+ author_id: 1,
133
+ )
134
+ expect(post.errors.to_h.keys).to be_empty
135
+ expect(post).to be_persisted
136
+ expect(post.histories.count).to eq 1
137
+ expect(post.histories.first.history_user_id).to be_nil
138
+ end
139
+
140
+ it "creates history with history_user_id" do
141
+ expect(Rollbar).to_not receive(:error)
142
+
143
+ post = SafePost.create(
144
+ title: "Post 1",
145
+ body: "Great post",
146
+ author_id: 1,
147
+ history_user_id: user.id
148
+ )
149
+ expect(post.errors.to_h.keys).to be_empty
150
+ expect(post).to be_persisted
151
+ expect(post.histories.count).to eq 1
152
+ expect(post.histories.first.history_user_id).to eq user.id
153
+ end
154
+
155
+ it "skips history creation if desired" do
156
+ post = SafePost.new(
157
+ title: "Post 1",
158
+ body: "Great post",
159
+ author_id: 1
160
+ )
161
+
162
+ post.save_without_history
163
+ expect(post).to be_persisted
164
+ expect(post.histories.count).to eq 0
165
+ end
166
+ end
167
+
168
+ it "can override without history_user_id" do
169
+ expect {
170
+ post = Post.new(
171
+ title: "Post 1",
172
+ body: "Great post",
173
+ author_id: 1,
174
+ )
175
+
176
+ post.save_without_history
177
+ }.to_not raise_error
178
+ end
179
+
180
+ it "can override without history_user_id" do
181
+ expect {
182
+ post = Post.new(
183
+ title: "Post 1",
184
+ body: "Great post",
185
+ author_id: 1,
186
+ )
187
+
188
+ post.save_without_history!
189
+ }.to_not raise_error
190
+ end
191
+
192
+ it "does not record histories when main model fails to save" do
193
+ class Post
194
+ after_save :raise_error, prepend: true
195
+
196
+ def raise_error
197
+ raise "Oh no, db issue!"
198
+ end
199
+ end
200
+
201
+ expect { create_post }.to raise_error
202
+ expect(Post.count).to be 0
203
+ expect(PostHistory.count).to be 0
204
+
205
+ Post.skip_callback(:save, :after, :raise_error)
206
+ end
207
+ end
208
+
209
+ describe "Scopes" do
210
+ it "finds current histories" do
211
+ post1 = create_post
212
+ post1.update(title: "Better title")
213
+
214
+ post2 = create_post
215
+ post2.update(title: "Better title")
216
+
217
+ expect(PostHistory.current.pluck(:title)).to all eq "Better title"
218
+ expect(post1.current_history.title).to eq "Better title"
219
+ end
220
+ end
221
+
222
+ describe "Associations" do
223
+ it "names associated records" do
224
+ post1 = create_post
225
+ expect(post1.histories.first).to be_a(PostHistory)
226
+
227
+ expect(post1.histories.first.post).to be(post1)
228
+
229
+ author1 = create_author
230
+ expect(author1.histories.first).to be_a(AuthorHistory)
231
+
232
+ expect(author1.histories.first.author).to be(author1)
233
+ end
234
+ end
235
+
236
+ describe "Histories" do
237
+ it "does not allow direct updates of histories" do
238
+ post1 = create_post
239
+ hist1 = post1.histories.first
240
+
241
+ expect(hist1.update(title: "A different title")).to be false
242
+ expect(hist1.reload.title).to eq post1.title
243
+
244
+ expect(hist1.update!(title: "A different title")).to be false
245
+ expect(hist1.reload.title).to eq post1.title
246
+
247
+ hist1.title = "A different title"
248
+ expect(hist1.save).to be false
249
+ expect(hist1.reload.title).to eq post1.title
250
+
251
+ hist1.title = "A different title"
252
+ expect(hist1.save!).to be false
253
+ expect(hist1.reload.title).to eq post1.title
254
+ end
255
+
256
+ it "does not allow destroys of histories" do
257
+ post1 = create_post
258
+ hist1 = post1.histories.first
259
+ original_history_count = post1.histories.count
260
+
261
+ expect(hist1.destroy).to be false
262
+ expect(hist1.destroy!).to be false
263
+
264
+ expect(post1.histories.count).to be original_history_count
265
+ end
266
+ end
267
+
268
+ describe "Deletion" do
269
+ it "records deleted_at on primary and history if you use acts_as_paranoid" do
270
+ class Post
271
+ acts_as_paranoid
272
+ end
273
+
274
+ post = create_post
275
+
276
+ expect {
277
+ post.destroy
278
+ }.to change {
279
+ PostHistory.count
280
+ }.by 1
281
+
282
+ expect(Post.unscoped.where.not(deleted_at: nil).count).to eq 1
283
+ expect(Post.unscoped.where(deleted_at: nil).count).to eq 0
284
+ expect(PostHistory.where.not(deleted_at: nil).count).to eq 1
285
+ expect(PostHistory.where(deleted_at: nil).count).to eq 1
286
+ end
287
+ end
288
+
289
+ describe "Scopes" do
290
+ it "finds current" do
291
+ post = create_post
292
+ post.update(title: "New Title")
293
+ post.update(title: "New Title 2")
294
+
295
+ expect(PostHistory.current.count).to be 1
296
+ end
297
+
298
+ it "finds current even when the db is updated in an invalid way" do
299
+ postgresql = <<-SQL
300
+ INSERT INTO post_histories (
301
+ title,
302
+ body,
303
+ post_id,
304
+ author_id,
305
+ history_started_at,
306
+ history_ended_at
307
+ ) VALUES (
308
+ 'Post 1',
309
+ 'Text',
310
+ 1,
311
+ 1,
312
+ now() - INTERVAL '1 day',
313
+ NULL
314
+ ), (
315
+ 'Post 1',
316
+ 'Different text',
317
+ 1,
318
+ 1,
319
+ now() - INTERVAL '12 hours',
320
+ NULL
321
+ ), (
322
+ 'Post 1',
323
+ 'Even more different text',
324
+ 1,
325
+ 1,
326
+ now() - INTERVAL '12 hours',
327
+ NULL
328
+ )
329
+ SQL
330
+
331
+ mysql = <<-SQL
332
+ INSERT INTO post_histories (
333
+ title,
334
+ body,
335
+ post_id,
336
+ author_id,
337
+ created_at,
338
+ updated_at,
339
+ history_started_at,
340
+ history_ended_at
341
+ ) VALUES (
342
+ 'Post 1',
343
+ 'Text',
344
+ 1,
345
+ 1,
346
+ now(),
347
+ now(),
348
+ now() - INTERVAL 1 day,
349
+ NULL
350
+ ), (
351
+ 'Post 1',
352
+ 'Different text',
353
+ 1,
354
+ 1,
355
+ now(),
356
+ now(),
357
+ now() - INTERVAL 12 hour,
358
+ NULL
359
+ ), (
360
+ 'Post 1',
361
+ 'Even more different text',
362
+ 1,
363
+ 1,
364
+ now(),
365
+ now(),
366
+ now() - INTERVAL 12 hour,
367
+ NULL
368
+ )
369
+ SQL
370
+
371
+ sql = nil
372
+ case PostHistory.connection.instance_variable_get(:@config)[:adapter]
373
+ when "mysql2"
374
+ sql = mysql
375
+ when "postgresql"
376
+ sql = postgresql
377
+ end
378
+
379
+ PostHistory.connection.execute(sql)
380
+
381
+ expect(PostHistory.current.count).to be 1
382
+ expect(PostHistory.current.first.body).to eq "Even more different text"
383
+ end
384
+ end
385
+
386
+ describe "User associations" do
387
+ it "links to user" do
388
+ post = create_post
389
+ author = create_author
390
+
391
+ expect(post.current_history.user.name).to eq username
392
+ expect(author.current_history.user.name).to eq username
393
+ end
394
+ end
395
+ end
@@ -0,0 +1,40 @@
1
+ ENV["HISTORIOGRAPHY_ENV"] = "test"
2
+
3
+ require_relative "../init.rb"
4
+ require "ostruct"
5
+
6
+ RSpec.configure do |config|
7
+ config.expect_with :rspec do |expectations|
8
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
9
+ end
10
+
11
+ config.mock_with :rspec do |mocks|
12
+ mocks.verify_partial_doubles = true
13
+ end
14
+
15
+ config.filter_run :focus
16
+ config.run_all_when_everything_filtered = true
17
+
18
+ config.example_status_persistence_file_path = "spec/examples.txt"
19
+
20
+ if config.files_to_run.one?
21
+ config.default_formatter = 'doc'
22
+ end
23
+
24
+ config.profile_examples = 10
25
+
26
+ config.order = :random
27
+
28
+ Kernel.srand config.seed
29
+
30
+ config.before(:suite) do
31
+ DatabaseCleaner.strategy = :transaction
32
+ DatabaseCleaner.clean_with(:truncation)
33
+ end
34
+
35
+ config.around(:each) do |example|
36
+ DatabaseCleaner.cleaning do
37
+ example.run
38
+ end
39
+ end
40
+ end