discourse-shared-edits/spec/models/shared_edit_revision_spec.rb
2026-03-13 12:46:27 +00:00

762 lines
25 KiB
Ruby

# frozen_string_literal: true
RSpec.describe SharedEditRevision do
before do
unless ActiveRecord::Base.connection.data_source_exists?(:shared_edit_revisions)
MigrateSharedEdits.new.up
ResizeSharedEditColumns.new.up
end
end
def latest_state(post)
SharedEditRevision.where(post_id: post.id).order("version desc").limit(1).pluck(:raw).first
end
def fake_edit(post, user_id, new_text)
state = latest_state(post)
update = DiscourseSharedEdits::Yjs.update_from_state(state, new_text)
SharedEditRevision.revise!(
post_id: post.id,
user_id: user_id,
client_id: user_id,
update: update,
)
end
describe ".init!" do
fab!(:post)
it "creates an initial revision with the post content" do
SharedEditRevision.init!(post)
revision = SharedEditRevision.find_by(post_id: post.id)
expect(revision).to be_present
expect(revision.version).to eq(1)
expect(revision.client_id).to eq("system")
expect(revision.user_id).to eq(Discourse.system_user.id)
expect(DiscourseSharedEdits::Yjs.text_from_state(revision.raw)).to eq(post.raw)
end
it "does not create duplicate revisions if already initialized" do
SharedEditRevision.init!(post)
SharedEditRevision.init!(post)
expect(SharedEditRevision.where(post_id: post.id).count).to eq(1)
end
end
describe ".toggle_shared_edits!" do
fab!(:post)
it "enables shared edits and creates initial revision" do
SharedEditRevision.toggle_shared_edits!(post.id, true)
post.reload
expect(post.custom_fields[DiscourseSharedEdits::SHARED_EDITS_ENABLED]).to eq(true)
expect(SharedEditRevision.where(post_id: post.id).count).to eq(1)
end
it "disables shared edits and removes revisions" do
SharedEditRevision.toggle_shared_edits!(post.id, true)
SharedEditRevision.toggle_shared_edits!(post.id, false)
post.reload
expect(post.custom_fields[DiscourseSharedEdits::SHARED_EDITS_ENABLED]).to be_nil
expect(SharedEditRevision.where(post_id: post.id).count).to eq(0)
end
it "commits pending changes when disabling" do
SharedEditRevision.toggle_shared_edits!(post.id, true)
user = Fabricate(:user)
new_raw = "#{post.raw} edited content"
fake_edit(post, user.id, new_raw)
SharedEditRevision.toggle_shared_edits!(post.id, false)
post.reload
expect(post.raw).to eq(new_raw)
end
end
describe ".latest_raw" do
fab!(:post)
it "returns nil when no revisions exist" do
result = SharedEditRevision.latest_raw(post.id)
expect(result).to be_nil
end
it "returns the version and text of the latest revision" do
SharedEditRevision.init!(post)
user = Fabricate(:user)
new_text = "Updated content"
fake_edit(post, user.id, new_text)
version, text = SharedEditRevision.latest_raw(post.id)
expect(version).to eq(2)
expect(text).to eq(new_text)
end
end
describe ".revise!" do
fab!(:post)
fab!(:user)
before { SharedEditRevision.init!(post) }
it "raises when shared edits not initialized" do
SharedEditRevision.where(post_id: post.id).delete_all
expect {
SharedEditRevision.revise!(
post_id: post.id,
user_id: user.id,
client_id: "test",
update: "some_update",
)
}.to raise_error(DiscourseSharedEdits::StateValidator::SharedEditsNotInitializedError)
end
it "publishes to message bus" do
messages =
MessageBus.track_publish("/shared_edits/#{post.id}") do
fake_edit(post, user.id, "new content")
end
expect(messages.length).to eq(1)
expect(messages.first.data[:version]).to eq(2)
expect(messages.first.data[:user_id]).to eq(user.id)
end
it "includes cursor metadata when supplied" do
cursor_payload = { "start" => "encoded-start", "end" => "encoded-end" }
state = latest_state(post)
update = DiscourseSharedEdits::Yjs.update_from_state(state, "cursor content")
messages =
MessageBus.track_publish("/shared_edits/#{post.id}") do
SharedEditRevision.revise!(
post_id: post.id,
user_id: user.id,
client_id: "cursor-client",
update: update,
cursor: cursor_payload,
)
end
expect(messages.length).to eq(1)
expect(messages.first.data[:cursor]).to eq(cursor_payload)
end
end
describe ".commit!" do
fab!(:post) { Fabricate(:post, raw: "Original content that is long enough") }
it "returns nil when no revisions exist" do
result = SharedEditRevision.commit!(post.id)
expect(result).to be_nil
end
it "does nothing when already committed" do
SharedEditRevision.init!(post)
user = Fabricate(:user)
fake_edit(post, user.id, "Modified content that is long enough")
SharedEditRevision.commit!(post.id)
post.reload
original_raw = post.raw
revision_count = PostRevision.where(post: post).count
SharedEditRevision.commit!(post.id)
expect(PostRevision.where(post: post).count).to eq(revision_count)
expect(post.reload.raw).to eq(original_raw)
end
it "does not apply to post when apply_to_post is false" do
SharedEditRevision.init!(post)
user = Fabricate(:user)
fake_edit(post, user.id, "Modified content that is long enough")
SharedEditRevision.commit!(post.id, apply_to_post: false)
expect(post.reload.raw).to eq("Original content that is long enough")
end
it "removes revisions older than the compaction window" do
SharedEditRevision.init!(post)
user = Fabricate(:user)
fake_edit(post, user.id, "recent change")
fake_edit(post, user.id, "another change")
stale_revision = SharedEditRevision.find_by(post_id: post.id, version: 2)
stale_revision.update_column(:updated_at, 2.minutes.ago)
SharedEditRevision.commit!(post.id)
expect(SharedEditRevision.exists?(stale_revision.id)).to eq(false)
expect(SharedEditRevision.where(post_id: post.id).order("version desc").first.version).to eq(
3,
)
end
it "caps stored revisions to the configured history limit" do
SharedEditRevision.init!(post)
user = Fabricate(:user)
(SharedEditRevision::MAX_HISTORY_COUNT + 10).times do |i|
fake_edit(post, user.id, "change #{i}")
end
SharedEditRevision.commit!(post.id)
expect(SharedEditRevision.where(post_id: post.id).count).to be <=
SharedEditRevision::MAX_HISTORY_COUNT
end
it "does not commit content exceeding max_post_length even if state was tampered" do
SharedEditRevision.init!(post)
original_raw = post.raw
old_max = SiteSetting.max_post_length
SiteSetting.max_post_length = 100
long_text = "x" * 200
latest = SharedEditRevision.where(post_id: post.id).order("version desc").first
latest.update_column(:raw, DiscourseSharedEdits::Yjs.state_from_text(long_text)[:state])
result = SharedEditRevision.commit!(post.id)
expect(result).to be_nil
expect(post.reload.raw).to eq(original_raw)
ensure
SiteSetting.max_post_length = old_max
end
it "maintains valid ydoc state after compaction" do
SharedEditRevision.init!(post)
user = Fabricate(:user)
5.times { |i| fake_edit(post, user.id, "Content version #{i + 1}") }
SharedEditRevision
.where(post_id: post.id)
.where("version < ?", 5)
.update_all(updated_at: 2.minutes.ago)
SharedEditRevision.commit!(post.id)
latest = SharedEditRevision.where(post_id: post.id).order("version desc").first
validation = DiscourseSharedEdits::StateValidator.validate_state(latest.raw)
expect(validation[:valid]).to eq(true)
expect(validation[:text]).to be_present
end
it "skips compaction when latest state is corrupted" do
SharedEditRevision.init!(post)
user = Fabricate(:user)
fake_edit(post, user.id, "Valid content here")
old_revision = SharedEditRevision.find_by(post_id: post.id, version: 1)
old_revision.update_column(:updated_at, 2.minutes.ago)
latest = SharedEditRevision.where(post_id: post.id).order("version desc").first
latest.update_column(:raw, Base64.strict_encode64("corrupted data"))
initial_count = SharedEditRevision.where(post_id: post.id).count
SharedEditRevision.commit!(post.id, apply_to_post: false)
expect(SharedEditRevision.where(post_id: post.id).count).to eq(initial_count)
end
describe "compaction strategy" do
fab!(:user)
it "nulls out raw for intermediate revisions but keeps latest and last committed" do
SharedEditRevision.init!(post)
# Create several edits
5.times { |i| fake_edit(post, user.id, "Edit #{i + 1}") }
# Commit to create a committed revision
SharedEditRevision.commit!(post.id)
# Make more edits after the commit
3.times { |i| fake_edit(post, user.id, "Post-commit edit #{i + 1}") }
# Trigger another commit which runs compaction
SharedEditRevision.commit!(post.id)
revisions = SharedEditRevision.where(post_id: post.id).order("version asc")
latest = revisions.last
last_committed =
SharedEditRevision
.where(post_id: post.id)
.where.not(post_revision_id: nil)
.order("version desc")
.first
# Latest should have raw
expect(latest.raw).to be_present
# Last committed should have raw
expect(last_committed.raw).to be_present
# Other revisions should have raw nulled out (except if they are latest or last_committed)
intermediate =
revisions.reject { |r| r.id == latest.id || r.id == last_committed.id }.select(&:raw)
expect(intermediate).to be_empty
end
it "allows recovery from post raw when all intermediate raws are null" do
SharedEditRevision.init!(post)
5.times { |i| fake_edit(post, user.id, "Edit #{i + 1}") }
SharedEditRevision.commit!(post.id)
# Manually null out some raws to simulate compaction state
SharedEditRevision.where(post_id: post.id).where("version < ?", 5).update_all(raw: nil)
# Recovery should still work
recovery = DiscourseSharedEdits::StateValidator.recover_from_post_raw(post.id, force: true)
expect(recovery[:success]).to eq(true)
# Can continue editing after recovery
fake_edit(post, user.id, "Post-recovery edit")
version, text = SharedEditRevision.latest_raw(post.id)
expect(text).to eq("Post-recovery edit")
end
it "keeps exactly two full snapshots after commit: latest and last committed" do
SharedEditRevision.init!(post)
10.times { |i| fake_edit(post, user.id, "Edit #{i + 1}") }
SharedEditRevision.commit!(post.id)
revisions_with_raw = SharedEditRevision.where(post_id: post.id).where.not(raw: nil).count
expect(revisions_with_raw).to be <= 2
end
end
it "preserves ability to continue editing after compaction" do
SharedEditRevision.init!(post)
user = Fabricate(:user)
5.times { |i| fake_edit(post, user.id, "Edit #{i + 1}") }
SharedEditRevision
.where(post_id: post.id)
.where("version < ?", 5)
.update_all(updated_at: 2.minutes.ago)
SharedEditRevision.commit!(post.id)
fake_edit(post, user.id, "Post-compaction edit works")
version, text = SharedEditRevision.latest_raw(post.id)
expect(text).to eq("Post-compaction edit works")
expect(version).to be > 5
end
end
it "can resolve complex edits and notify" do
raw = <<~RAW
0123456
0123456
0123456
RAW
user1 = Fabricate(:user)
user2 = Fabricate(:user)
user3 = Fabricate(:user)
post = Fabricate(:post, raw: raw)
SharedEditRevision.init!(post)
text_after_user1 = <<~RAW
0123456
mister
0123456
RAW
text_after_user2 = <<~RAW
Hello
mister
0123456
RAW
final_text = <<~RAW
Hello
mister
world
RAW
messages =
MessageBus.track_publish("/shared_edits/#{post.id}") do
fake_edit(post, user1.id, text_after_user1)
end
expect(messages.length).to eq(1)
expect(messages.first.data[:version]).to eq(2)
expect(messages.first.data[:update]).to be_present
SharedEditRevision.commit!(post.id)
post.reload
expect(post.raw.strip).to eq(text_after_user1.strip)
fake_edit(post, user2.id, text_after_user2)
fake_edit(post, user3.id, final_text)
SharedEditRevision.commit!(post.id)
post.reload
expect(post.raw.strip).to eq(final_text.strip)
rev = post.revisions.order(:number).first
reason = rev.modifications["edit_reason"][1].to_s
expect(reason).to include(user1.username)
expect(reason).to include(user2.username)
expect(reason).to include(user3.username)
edit_rev = SharedEditRevision.where(post_id: post.id).order("version desc").first
expect(edit_rev.post_revision_id).to eq(rev.id)
end
it "does not update the post if validation fails" do
user = Fabricate(:admin)
post = Fabricate(:post, user: user, raw: "Hello world")
SharedEditRevision.init!(post)
SharedEditRevision.revise!(
post_id: post.id,
user_id: user.id,
client_id: user.id,
update: DiscourseSharedEdits::Yjs.update_from_text_change("Hello world", "Test")[:update],
)
expect(post.reload.raw).to eq("Hello world")
end
describe ".revise! version conflict handling" do
fab!(:user)
fab!(:post) { Fabricate(:post, user: user, raw: "Hello world") }
before { SharedEditRevision.init!(post) }
it "retries on version conflict and succeeds" do
state = SharedEditRevision.where(post_id: post.id).order("version desc").first.raw
update = DiscourseSharedEdits::Yjs.update_from_state(state, "Hello world updated")
call_count = 0
allow(SharedEditRevision).to receive(:create!).and_wrap_original do |method, **args|
call_count += 1
if call_count == 1
raise ActiveRecord::RecordNotUnique.new("duplicate key value")
else
method.call(**args)
end
end
result =
SharedEditRevision.revise!(
post_id: post.id,
user_id: user.id,
client_id: "test-client",
update: update,
)
expect(result).to be_present
expect(call_count).to eq(2)
end
it "raises after max retries exhausted" do
state = SharedEditRevision.where(post_id: post.id).order("version desc").first.raw
update = DiscourseSharedEdits::Yjs.update_from_state(state, "Hello world updated")
allow(SharedEditRevision).to receive(:create!).and_raise(
ActiveRecord::RecordNotUnique.new("duplicate key value"),
)
expect {
SharedEditRevision.revise!(
post_id: post.id,
user_id: user.id,
client_id: "test-client",
update: update,
)
}.to raise_error(ActiveRecord::RecordNotUnique)
end
it "handles concurrent revisions gracefully" do
state = SharedEditRevision.where(post_id: post.id).order("version desc").first.raw
update1 = DiscourseSharedEdits::Yjs.update_from_state(state, "Change from client 1")
update2 = DiscourseSharedEdits::Yjs.update_from_state(state, "Change from client 2")
# Simulate two concurrent requests by having one succeed normally
result1 =
SharedEditRevision.revise!(
post_id: post.id,
user_id: user.id,
client_id: "client-1",
update: update1,
)
# The second one should also succeed (applying to the new state)
result2 =
SharedEditRevision.revise!(
post_id: post.id,
user_id: user.id,
client_id: "client-2",
update: update2,
)
expect(result1.first).to eq(2)
expect(result2.first).to eq(3)
# Both changes should be in the final state (Yjs merges them)
final_version, final_text = SharedEditRevision.latest_raw(post.id)
expect(final_version).to eq(3)
expect(final_text).to be_present
end
end
describe "state snapshotting" do
fab!(:post) { Fabricate(:post, raw: "Original content that is long enough") }
fab!(:user)
# Use a lower threshold for testing since creating actual 100KB+ bloated states is slow
around do |example|
stub_const(DiscourseSharedEdits::StateValidator, "SNAPSHOT_THRESHOLD_BYTES", 100) do
example.run
end
end
it "triggers snapshot when state exceeds threshold during commit" do
SharedEditRevision.init!(post)
# Make edits to create a state that will exceed the lowered threshold
5.times { |i| fake_edit(post, user.id, "Content version #{i + 1} with some more text") }
latest = SharedEditRevision.where(post_id: post.id).order("version desc").first
original_size = Base64.decode64(latest.raw).bytesize
expect(original_size).to be > 100 # Should exceed our lowered threshold
# Trigger compaction via commit
SharedEditRevision.commit!(post.id, apply_to_post: false)
# Reload latest and check size was reduced (fresh state has less metadata)
latest.reload
new_size = Base64.decode64(latest.raw).bytesize
# The snapshotted state may or may not be smaller depending on recent updates
# but it should still be valid
validation = DiscourseSharedEdits::StateValidator.validate_state(latest.raw)
expect(validation[:valid]).to eq(true)
end
it "preserves text content after snapshot" do
SharedEditRevision.init!(post)
# Create some edits
5.times { |i| fake_edit(post, user.id, "Content version #{i + 1} with extra words") }
# Trigger commit which should snapshot
SharedEditRevision.commit!(post.id, apply_to_post: false)
# Verify text is preserved (should be the last edit)
latest = SharedEditRevision.where(post_id: post.id).order("version desc").first
text = DiscourseSharedEdits::Yjs.text_from_state(latest.raw)
expect(text).to eq("Content version 5 with extra words")
end
it "allows continued editing after snapshot" do
SharedEditRevision.init!(post)
# Make edits to trigger snapshot threshold
5.times { |i| fake_edit(post, user.id, "Edit #{i + 1} with padding content") }
# Trigger snapshot
SharedEditRevision.commit!(post.id, apply_to_post: false)
# Create a new edit
fake_edit(post, user.id, "Post-snapshot edit works")
# Verify the edit was applied
version, text = SharedEditRevision.latest_raw(post.id)
expect(text).to eq("Post-snapshot edit works")
end
it "preserves recent updates during snapshot" do
SharedEditRevision.init!(post)
# Make some recent edits (these should be preserved)
3.times { |i| fake_edit(post, user.id, "Recent edit #{i + 1} padding") }
recent_count =
SharedEditRevision
.where(post_id: post.id)
.where.not(revision: ["", nil])
.where("created_at > ?", SharedEditRevision::MESSAGE_BUS_MAX_BACKLOG_AGE.seconds.ago)
.count
expect(recent_count).to eq(3)
# Trigger snapshot
SharedEditRevision.commit!(post.id, apply_to_post: false)
# State should still be valid
latest = SharedEditRevision.where(post_id: post.id).order("version desc").first
validation = DiscourseSharedEdits::StateValidator.validate_state(latest.raw)
expect(validation[:valid]).to eq(true)
end
it "broadcasts resync when history exceeds message bus limits" do
# Use a very high threshold so test doesn't run in the around block
stub_const(DiscourseSharedEdits::StateValidator, "SNAPSHOT_THRESHOLD_BYTES", 100.megabytes) do
# Lower the message bus limit temporarily for faster testing
stub_const(SharedEditRevision, "MESSAGE_BUS_MAX_BACKLOG_SIZE", 5) do
SharedEditRevision.init!(post)
# Create more than the lowered MESSAGE_BUS_MAX_BACKLOG_SIZE revisions
10.times { |i| fake_edit(post, user.id, "Edit #{i + 1}") }
# Force snapshot by mocking should_snapshot? to return true
allow(DiscourseSharedEdits::StateValidator).to receive(:should_snapshot?).and_return(true)
# Track message bus publications - need apply_to_post: true (default)
# for compact_history! to be called
messages =
MessageBus.track_publish("/shared_edits/#{post.id}") do
SharedEditRevision.commit!(post.id)
end
# Should have a resync message
resync_messages = messages.select { |m| m.data[:action] == "resync" }
expect(resync_messages).not_to be_empty
end
end
end
it "broadcasts resync when snapshot skips corrupted recent revisions" do
stub_const(DiscourseSharedEdits::StateValidator, "SNAPSHOT_THRESHOLD_BYTES", 100.megabytes) do
SharedEditRevision.init!(post)
3.times { |i| fake_edit(post, user.id, "Edit #{i + 1}") }
corrupted_revision =
SharedEditRevision
.where(post_id: post.id)
.where.not(revision: ["", nil])
.order("version asc")
.first
corrupted_revision.update_column(:revision, "not-base64")
allow(DiscourseSharedEdits::StateValidator).to receive(:should_snapshot?).and_return(true)
messages =
MessageBus.track_publish("/shared_edits/#{post.id}") do
SharedEditRevision.commit!(post.id)
end
resync_messages = messages.select { |m| m.data[:action] == "resync" }
expect(resync_messages).not_to be_empty
end
end
it "does not trigger snapshot when state is below threshold" do
# Use a very high threshold so no snapshot occurs
stub_const(DiscourseSharedEdits::StateValidator, "SNAPSHOT_THRESHOLD_BYTES", 100.megabytes) do
SharedEditRevision.init!(post)
latest = SharedEditRevision.where(post_id: post.id).order("version desc").first
original_raw = latest.raw
# Commit should not change the raw since it's below threshold
SharedEditRevision.commit!(post.id, apply_to_post: false)
latest.reload
# The raw content should be the same (not snapshotted)
expect(latest.raw).to eq(original_raw)
end
end
end
describe "editor username custom field" do
fab!(:post) { Fabricate(:post, raw: "Original content that is long enough") }
fab!(:user1, :user) { Fabricate(:user, username: "editor_one") }
fab!(:user2, :user) { Fabricate(:user, username: "editor_two") }
it "stores editor usernames in custom field after commit" do
SharedEditRevision.toggle_shared_edits!(post.id, true)
fake_edit(post, user1.id, "Edit by user one long enough")
SharedEditRevision.commit!(post.id)
post.reload
stored = post.custom_fields["shared_edits_editor_usernames"]
expect(stored).to include("editor_one")
end
it "accumulates usernames across multiple commits" do
SharedEditRevision.toggle_shared_edits!(post.id, true)
fake_edit(post, user1.id, "Edit by user one long enough")
SharedEditRevision.commit!(post.id)
fake_edit(post, user2.id, "Edit by user two long enough")
SharedEditRevision.commit!(post.id)
post.reload
stored = post.custom_fields["shared_edits_editor_usernames"]
expect(stored).to include("editor_one")
expect(stored).to include("editor_two")
end
it "cleans up custom field when disabling shared edits" do
SharedEditRevision.toggle_shared_edits!(post.id, true)
fake_edit(post, user1.id, "Edit by user one long enough")
SharedEditRevision.commit!(post.id)
SharedEditRevision.toggle_shared_edits!(post.id, false)
post.reload
expect(post.custom_fields["shared_edits_editor_usernames"]).to be_blank
end
end
describe ".ensure_will_commit" do
fab!(:post)
let(:redis) { Discourse.redis }
let(:key) { SharedEditRevision.will_commit_key(post.id) }
before { redis.del(key) }
it "schedules a job and sets a key if not already scheduled" do
expect(redis.get(key)).to be_nil
expect { SharedEditRevision.ensure_will_commit(post.id) }.to change(
Jobs::CommitSharedRevision.jobs,
:size,
).by(1)
expect(redis.get(key)).to eq("1")
expect(redis.ttl(key)).to be_within(5).of(60)
end
it "does not schedule a job if already scheduled" do
redis.setex(key, 60, "1")
expect { SharedEditRevision.ensure_will_commit(post.id) }.not_to change(
Jobs::CommitSharedRevision.jobs,
:size,
)
end
end
end