From 190daaa8a0bb79e750894b83e31b9d99f7d900a4 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 16 Jul 2026 03:30:01 +0200 Subject: Add a reader for the action log at admin/log NodeActionsController#index lists entries newest-first, filterable by node_id or user_id -- the two zoom shapes the log was designed around. Rendering goes through NodeActionsHelper.action_summary, which builds one sentence per entry from metadata alone, so entries referencing deleted users or nodes render from their snapshots; live associations only upgrade names to links. Unknown verbs degrade to a generic sentence rather than an error, since the log outlives its vocabulary. The helper is the escaping boundary: every metadata value passes through h() before assembly. Actor names link to the log's own user zoom rather than the unused users page -- inspecting a suspicious user's other actions is the intended workflow. Publish entries with a translation_diff expose a collapsed per-locale change table linking out to the revision itself. Sentences live in en.yml/de.yml following the existing widget-string convention. nodes#show links to its node's zoomed log. --- test/controllers/node_actions_controller_test.rb | 51 ++++++++++++++ test/models/helpers/node_actions_helper_test.rb | 85 ++++++++++++++++++++++++ 2 files changed, 136 insertions(+) create mode 100644 test/controllers/node_actions_controller_test.rb create mode 100644 test/models/helpers/node_actions_helper_test.rb (limited to 'test') diff --git a/test/controllers/node_actions_controller_test.rb b/test/controllers/node_actions_controller_test.rb new file mode 100644 index 0000000..8bc68ec --- /dev/null +++ b/test/controllers/node_actions_controller_test.rb @@ -0,0 +1,51 @@ +require 'test_helper' + +class NodeActionsControllerTest < ActionController::TestCase + + def setup + login_as :quentin + end + + def logged_node slug, title + node = Node.root.children.create!(:slug => slug) + Globalize.with_locale(I18n.default_locale) { node.draft.update!(:title => title) } + node + end + + test "index renders" do + get :index + assert_response :success + end + + test "index filters by node" do + node_a = logged_node("log_filter_a", "Alpha Subject") + node_b = logged_node("log_filter_b", "Beta Subject") + NodeAction.record!(:node => node_a, :action => "create", :user => users(:quentin)) + NodeAction.record!(:node => node_b, :action => "create", :user => users(:quentin)) + + get :index, params: { :node_id => node_a.id } + + assert_response :success + assert_includes @response.body, "Alpha Subject" + assert_not_includes @response.body, "Beta Subject" + end + + test "index filters by user" do + node = logged_node("log_filter_user", "Gamma Subject") + NodeAction.record!(:node => node, :action => "create", :user => users(:quentin)) + + get :index, params: { :user_id => users(:quentin).id } + assert_includes @response.body, "Gamma Subject" + + other = User.where.not(:id => users(:quentin).id).first + get :index, params: { :user_id => other.id } + assert_not_includes @response.body, "Gamma Subject" + end + + test "index requires login" do + session[:user_id] = nil + @controller.instance_variable_set(:@current_user, nil) + get :index + assert_response :redirect + end +end diff --git a/test/models/helpers/node_actions_helper_test.rb b/test/models/helpers/node_actions_helper_test.rb new file mode 100644 index 0000000..93bbdf6 --- /dev/null +++ b/test/models/helpers/node_actions_helper_test.rb @@ -0,0 +1,85 @@ +require 'test_helper' + +class NodeActionsHelperTest < ActionView::TestCase + def setup + @original_locale = I18n.locale + I18n.locale = :en + end + + def default_url_options + { :locale => nil } + end + + def teardown + I18n.locale = @original_locale + end + + def entry action, metadata = {}, node: nil, user: nil, page: nil + NodeAction.create!(:node => node, :user => user, :page => page, + :action => action, :occurred_at => Time.now, + :metadata => { "username" => "quentin", + "human_readable_node_name" => "Subject" }.merge(metadata)) + end + + test "publish renders the title pair" do + out = action_summary(entry("publish", + { "via" => "draft", "title" => { "from" => "Old", "to" => "New" } })) + + assert_includes out, "quentin" + assert_includes out, "Old" + end + + test "first publish uses its own sentence" do + out = action_summary(entry("publish", + { "via" => "draft", "title" => { "from" => nil, "to" => "New" } })) + + assert_includes out, "for the first time" + end + + test "rollback publishes get the rollback sentence" do + out = action_summary(entry("publish", + { "via" => "revision", "title" => { "from" => "Now", "to" => "Then" } })) + + assert_includes out, "rolled" + end + + test "move renders the path pair" do + out = action_summary(entry("move", + { "path" => { "from" => "a/b", "to" => "a/c" } })) + + assert_includes out, "a/b" + assert_includes out, "a/c" + end + + test "unknown verbs degrade to a generic sentence, never an error" do + out = action_summary(entry("frobnicate")) + + assert_includes out, "frobnicate" + assert_includes out, "quentin" + end + + test "dead references render as plain names from metadata, no links" do + out = action_summary(entry("publish", + { "title" => { "from" => "Old", "to" => "New" } })) + + assert_not_includes out, " "bold", + "title" => { "from" => "", "to" => "x" } })) + + assert_not_includes out, "