From 30a1b4828a94f196e89fef98bc8d798dd614dbe4 Mon Sep 17 00:00:00 2001 From: DualFroz Date: Tue, 1 Sep 2026 16:54:16 +0200 Subject: [PATCH] feat: grow blog_demo --- blog_demo.rb | 173 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/blog_demo.rb b/blog_demo.rb index 9a3f871..f6874aa 100644 --- a/blog_demo.rb +++ b/blog_demo.rb @@ -683,6 +683,179 @@ Net::HTTP.start('127.0.0.1', port) do |http| noop_cookie = cookie_from(noop) cookie = noop_cookie unless noop_cookie.empty? noop_show = http.get(post_path, 'Cookie' => cookie) + unless noop_show.body.include?('Nothing changed') || noop_show.body.include?('Nic sie nie zmienilo') + raise 'submitting identical values should report that Prism::Orm::Dirty saw zero real changes' + end + puts "POST #{post_path} with identical values -> Post#saved_changes was empty, notice said nothing changed" + + # Prism::Orm::Locking: two "browser tabs" open the same edit form (each + # capturing the lock_version the row had at that moment), tab A saves + # first and bumps it, then tab B - still holding the OLD lock_version in + # its hidden field - submits on top of that. Store#update's version check + # runs inside the same PStore transaction as the merge, so tab B's write + # cannot silently clobber tab A's: it must come back as a 409 with the + # conflict notice, and tab A's title must survive untouched. + tab_a_edit = http.get("#{post_path}/edit", 'Cookie' => cookie) + tab_b_edit = http.get("#{post_path}/edit", 'Cookie' => cookie) + tab_a_lock_version = tab_a_edit.body[/name="post\[lock_version\]"[^>]*value="([^"]*)"/, 1] + tab_b_lock_version = tab_b_edit.body[/name="post\[lock_version\]"[^>]*value="([^"]*)"/, 1] + raise 'edit form should carry the current lock_version in a hidden field' if tab_a_lock_version.to_s.empty? + raise 'both tabs should have observed the same lock_version before either saved' unless tab_a_lock_version == tab_b_lock_version + + tab_a_save_body = URI.encode_www_form( + 'post[title]' => 'Hello from Prism (tab A won)', 'post[body]' => 'Saved first.', + 'post[lock_version]' => tab_a_lock_version, '_method' => 'PATCH', 'authenticity_token' => token + ) + tab_a_save = http.post(post_path, tab_a_save_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) + raise "expected tab A's save to succeed, got #{tab_a_save.code}" unless tab_a_save.code == '302' + tab_a_cookie = cookie_from(tab_a_save) + cookie = tab_a_cookie unless tab_a_cookie.empty? + puts "POST #{post_path}/edit tab A (lock_version=#{tab_a_lock_version}) -> #{tab_a_save.code}, saved first" + + tab_b_save_body = URI.encode_www_form( + 'post[title]' => 'Hello from Prism (tab B lost)', 'post[body]' => 'Submitted stale, should be rejected.', + 'post[lock_version]' => tab_b_lock_version, '_method' => 'PATCH', 'authenticity_token' => token + ) + tab_b_save = http.post(post_path, tab_b_save_body, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) + unless tab_b_save.code == '409' + raise "expected tab B's stale save to be rejected with 409, got #{tab_b_save.code}" + end + unless tab_b_save.body.include?('Someone else changed this post') || tab_b_save.body.include?('Ktos inny zmienil ten wpis') + raise 'the 409 response should render the stale-conflict notice' + end + puts "POST #{post_path}/edit tab B (stale lock_version=#{tab_b_lock_version}) -> #{tab_b_save.code} (expected 409, StaleObjectError caught)" + + after_conflict = http.get(post_path, 'Cookie' => cookie) + raise "tab B's rejected write must not have overwritten tab A's title" unless after_conflict.body.include?('Hello from Prism (tab A won)') + raise "tab B's rejected write must not have leaked through" if after_conflict.body.include?('tab B lost') + puts "GET #{post_path} -> tab A's title survives untouched, Prism::Orm::Locking prevented the lost update" + + puts '-' * 40 + puts 'Post.accepts_nested_attributes_for :comments - the author moderates a comment from the POST edit form ' \ + 'itself, not that comment\'s own /comments/:id route' + moderation_comment_body = URI.encode_www_form( + 'comment[author]' => 'Someone to moderate', 'comment[body]' => 'unmoderated text', 'authenticity_token' => token + ) + moderation_comment = http.post("/posts/#{post_id}/comments", moderation_comment_body, + 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) + raise "expected the extra comment to redirect, got #{moderation_comment.code}" unless moderation_comment.code == '302' + moderation_comment_cookie = cookie_from(moderation_comment) + cookie = moderation_comment_cookie unless moderation_comment_cookie.empty? + + before_moderation_home = http.get('/', 'Cookie' => cookie) + unless before_moderation_home.body.include?('2 comment(s)') + raise 'expected comments_count to read 2 before the moderation scene (the tab-A comment plus this new one)' + end + puts "POST /posts/#{post_id}/comments -> #{moderation_comment.code} (a second comment to moderate through the post form)" + + moderation_edit_page = http.get("#{post_path}/edit", 'Cookie' => cookie) + moderation_index = nil + moderation_edit_page.body.scan(/name="post\[comments_attributes\]\[(\d+)\]\[id\]"[^>]*value="(\d+)"/) do |index, id| + moderation_index = index if id.to_i != comment_id.to_i + end + raise 'edit form should render a nested field for the comment to be moderated' unless moderation_index + raise 'moderation nested field should carry the unmoderated body' unless moderation_edit_page.body.include?('unmoderated text') + puts "GET #{post_path}/edit -> FormBuilder#fields_for rendered comments_attributes[#{moderation_index}] for the new comment" + + moderation_lock_version = moderation_edit_page.body[/name="post\[lock_version\]"[^>]*value="([^"]*)"/, 1] + moderate_body_edit = URI.encode_www_form( + 'post[title]' => 'Hello from Prism (tab A won)', 'post[body]' => 'Saved first.', + 'post[lock_version]' => moderation_lock_version, + "post[comments_attributes][#{moderation_index}][id]" => moderation_edit_page.body[/name="post\[comments_attributes\]\[#{moderation_index}\]\[id\]"[^>]*value="(\d+)"/, 1], + "post[comments_attributes][#{moderation_index}][body]" => 'Edited by the post author, not the commenter', + '_method' => 'PATCH', 'authenticity_token' => token + ) + moderate_edit = http.post(post_path, moderate_body_edit, 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) + raise "expected the moderation edit to redirect, got #{moderate_edit.code}" unless moderate_edit.code == '302' + moderate_edit_cookie = cookie_from(moderate_edit) + cookie = moderate_edit_cookie unless moderate_edit_cookie.empty? + puts "POST #{post_path} with comments_attributes[#{moderation_index}][body] -> #{moderate_edit.code}" + + moderated_show = http.get(post_path, 'Cookie' => cookie) + unless moderated_show.body.include?('Edited by the post author, not the commenter') + raise 'expected the comment moderated through the post edit form to show its new body on the post page' + end + raise 'the other comment must not have been touched by this moderation edit' unless moderated_show.body.include?('Nice post, tab A won!') + puts "GET #{post_path} -> the moderated comment shows its new body, the untouched comment (tab A/B locking scene) is unaffected" + + moderation_edit_page_2 = http.get("#{post_path}/edit", 'Cookie' => cookie) + moderation_lock_version_2 = moderation_edit_page_2.body[/name="post\[lock_version\]"[^>]*value="([^"]*)"/, 1] + destroy_via_moderation_body = URI.encode_www_form( + 'post[title]' => 'Hello from Prism (tab A won)', 'post[body]' => 'Saved first.', + 'post[lock_version]' => moderation_lock_version_2, + "post[comments_attributes][#{moderation_index}][id]" => moderation_edit_page.body[/name="post\[comments_attributes\]\[#{moderation_index}\]\[id\]"[^>]*value="(\d+)"/, 1], + "post[comments_attributes][#{moderation_index}][_destroy]" => '1', + '_method' => 'PATCH', 'authenticity_token' => token + ) + destroy_via_moderation = http.post(post_path, destroy_via_moderation_body, + 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) + raise "expected the moderation destroy to redirect, got #{destroy_via_moderation.code}" unless destroy_via_moderation.code == '302' + moderation_destroy_cookie = cookie_from(destroy_via_moderation) + cookie = moderation_destroy_cookie unless moderation_destroy_cookie.empty? + puts "POST #{post_path} with comments_attributes[#{moderation_index}][_destroy]=1 -> #{destroy_via_moderation.code} " \ + '(comment removed through accepts_nested_attributes_for, not its own /comments/:id route)' + + after_moderation_destroy = http.get('/', 'Cookie' => cookie) + unless after_moderation_destroy.body.include?('1 comment(s)') + raise 'expected comments_count to drop back to 1 - counter_cache after_destroy still fires when a comment ' \ + 'is destroyed through nested attributes, not just through CommentsController#destroy' + end + puts "GET / -> post listing shows '1 comment(s)' again (counter_cache after_destroy ran for the nested-attributes destroy too)" + + puts '-' * 40 + puts 'CommentsController#index - comments now get their own paginated URL, not just whatever fits ' \ + 'inline on posts#show' + 11.times do |i| + filler_comment_body = URI.encode_www_form( + 'comment[author]' => 'Pager', 'comment[body]' => "Filler comment #{i + 1}", 'authenticity_token' => token + ) + filler_comment = http.post("/posts/#{post_id}/comments", filler_comment_body, + 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) + raise "expected filler comment #{i + 1} to redirect, got #{filler_comment.code}" unless filler_comment.code == '302' + end + puts 'created 11 filler comments (12 total: the surviving tab-A comment plus these) to exercise ' \ + 'Orm::Pagination on comments#index' + + comments_page_one = http.get("/posts/#{post_id}/comments", 'Cookie' => cookie) + comments_page_two = http.get("/posts/#{post_id}/comments?page=2", 'Cookie' => cookie) + puts "GET /posts/#{post_id}/comments -> #{comments_page_one.code}, GET ...?page=2 -> #{comments_page_two.code}" + raise 'comments page 1 should render pagination nav links' unless comments_page_one.body.include?('class="pagination"') + unless comments_page_one.body.scan('class="comment"').size == 10 + raise 'comments page 1 should show exactly 10 comments (Orm::Pagination default per-page)' + end + raise 'comments page 2 should show the remaining 2 comments' unless comments_page_two.body.scan('class="comment"').size == 2 + + comments_etag = comments_page_one['etag'] + raise 'comments#index should set an ETag now that it uses fresh_when' unless comments_etag + puts "GET /posts/#{post_id}/comments -> ETag=#{comments_etag}" + + conditional_comments = http.get("/posts/#{post_id}/comments", 'Cookie' => cookie, 'If-None-Match' => comments_etag) + puts "GET /posts/#{post_id}/comments with If-None-Match: #{comments_etag} -> #{conditional_comments.code}" + unless conditional_comments.code == '304' + raise "expected a fresh conditional GET on comments#index to return 304, got #{conditional_comments.code}" + end + raise '304 response on comments#index must not carry a body' unless conditional_comments.body.to_s.empty? + + one_more_comment_body = URI.encode_www_form( + 'comment[author]' => 'Pager', 'comment[body]' => 'One more filler comment', 'authenticity_token' => token + ) + one_more_comment = http.post("/posts/#{post_id}/comments", one_more_comment_body, + 'Content-Type' => 'application/x-www-form-urlencoded', 'Cookie' => cookie) + raise "expected the extra filler comment to redirect, got #{one_more_comment.code}" unless one_more_comment.code == '302' + + now_stale_comments = http.get("/posts/#{post_id}/comments", 'Cookie' => cookie, 'If-None-Match' => comments_etag) + puts "GET /posts/#{post_id}/comments with the OLD If-None-Match after a new comment -> #{now_stale_comments.code}" + unless now_stale_comments.code == '200' + raise "expected the stale conditional GET on comments#index to return 200, got #{now_stale_comments.code}" + end + raise 'the fresh comments#index render must carry a NEW ETag, not the stale one' if now_stale_comments['etag'] == comments_etag + + comments_json = http.get("/posts/#{post_id}/comments.json", 'Cookie' => cookie) + parsed_comments_json = JSON.parse(comments_json.body) + puts "GET /posts/#{post_id}/comments.json -> #{comments_json.code} (#{parsed_comments_json.size} comments on this page)" + raise 'comments#index.json should return only the current page (10), not every comment' unless parsed_comments_json.size == 10 + unless parsed_comments_json.first.keys.sort == %w[author body id post_id status] + raise 'comments#index.json entries should carry exactly id/author/body/post_id/status' end server.stop