From 4bc393887b7495f6a8a460d9c24a32d240524aae Mon Sep 17 00:00:00 2001 From: Runar Ingebrigtsen Date: Sat, 31 Jan 2026 15:46:40 +0100 Subject: [PATCH] 96.99% test coverage --- Gemfile | 3 +- Gemfile.lock | 71 +-- config/environments/test.rb | 2 +- test/application_system_test_case.rb | 15 + .../admin/invitations_controller_test.rb | 400 +++++++++++++++++ .../admin/requests_controller_test.rb | 6 +- .../admin/users_controller_test.rb | 90 ++++ test/controllers/entries_controller_test.rb | 176 ++++++++ .../invitations_controller_test.rb | 302 +++++++++++++ .../password_resets_controller_test.rb | 196 ++++++++ test/helpers/entries_helper_test.rb | 210 +++++++++ test/integration/authentication_flow_test.rb | 317 +++++++++++++ test/integration/entry_request_flow_test.rb | 2 +- test/integration/search_performance_test.rb | 129 ++++++ test/mailers/password_reset_mailer_test.rb | 23 + test/models/user_test.rb | 418 ++++++++++++++++++ test/system/admin_workflow_test.rb | 299 +++++++++++++ test/system/contributor_workflow_test.rb | 185 ++++++++ test/system/public_browsing_test.rb | 114 +++++ test/test_helper.rb | 3 + 20 files changed, 2899 insertions(+), 62 deletions(-) create mode 100644 test/application_system_test_case.rb create mode 100644 test/controllers/entries_controller_test.rb create mode 100644 test/controllers/password_resets_controller_test.rb create mode 100644 test/helpers/entries_helper_test.rb create mode 100644 test/integration/authentication_flow_test.rb create mode 100644 test/integration/search_performance_test.rb create mode 100644 test/mailers/password_reset_mailer_test.rb create mode 100644 test/system/admin_workflow_test.rb create mode 100644 test/system/contributor_workflow_test.rb create mode 100644 test/system/public_browsing_test.rb diff --git a/Gemfile b/Gemfile index bc9b587..8b9174e 100644 --- a/Gemfile +++ b/Gemfile @@ -61,11 +61,12 @@ end group :development do # Use console on exceptions pages [https://github.com/rails/web-console] gem "web-console" - gem "mailcatcher" end group :test do # Use system testing [https://guides.rubyonrails.org/testing.html#system-testing] + gem "benchmark", require: false + gem "simplecov", require: false gem "capybara" gem "selenium-webdriver" end diff --git a/Gemfile.lock b/Gemfile.lock index b306590..f218697 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -81,6 +81,7 @@ GEM base64 (0.3.0) bcrypt (3.1.21) bcrypt_pbkdf (1.1.2) + benchmark (0.5.0) bigdecimal (4.0.1) bindex (0.8.1) bootsnap (1.21.1) @@ -112,11 +113,11 @@ GEM connection_pool (3.0.2) crass (1.0.6) csv (3.3.5) - daemons (1.4.1) date (3.5.1) debug (1.11.1) irb (~> 1.10) reline (>= 0.3.8) + docile (1.4.1) dotenv (3.2.0) drb (2.2.3) ed25519 (1.4.0) @@ -124,17 +125,12 @@ GEM erubi (1.13.1) et-orbi (1.4.0) tzinfo - eventmachine (1.2.7) ffi (1.17.3-x86_64-linux-gnu) fugit (1.12.1) et-orbi (~> 1.4) raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) - haml (7.2.0) - temple (>= 0.8.2) - thor - tilt htmlentities (4.4.2) i18n (1.14.8) concurrent-ruby (~> 1.0) @@ -177,16 +173,6 @@ GEM net-imap net-pop net-smtp - mailcatcher (0.2.4) - eventmachine - haml - i18n - json - mail - sinatra - skinny (>= 0.1.2) - sqlite3-ruby - thin marcel (1.1.0) matrix (0.4.3) mini_magick (5.3.1) @@ -195,8 +181,6 @@ GEM minitest (6.0.1) prism (~> 1.5) msgpack (1.8.0) - mustermann (3.0.4) - ruby2_keywords (~> 0.0.1) net-imap (0.6.2) date net-protocol @@ -236,10 +220,6 @@ GEM raabro (1.4.0) racc (1.8.1) rack (3.2.4) - rack-protection (4.2.1) - base64 (>= 0.1.0) - logger (>= 1.6.0) - rack (>= 3.0.0, < 4) rack-session (2.1.1) base64 (>= 0.1.0) rack (>= 3.0.0) @@ -325,7 +305,6 @@ GEM ruby-vips (2.3.0) ffi (~> 1.12) logger - ruby2_keywords (0.0.5) rubyzip (3.2.2) securerandom (0.4.1) selenium-webdriver (4.40.0) @@ -334,16 +313,12 @@ GEM rexml (~> 3.2, >= 3.2.5) rubyzip (>= 1.2.2, < 4.0) websocket (~> 1.0) - sinatra (4.2.1) - logger (>= 1.6.0) - mustermann (~> 3.0) - rack (>= 3.0.0, < 4) - rack-protection (= 4.2.1) - rack-session (>= 2.0.0, < 3) - tilt (~> 2.0) - skinny (0.2.2) - eventmachine (~> 1.0) - thin + simplecov (0.22.0) + docile (~> 1.1) + simplecov-html (~> 0.11) + simplecov_json_formatter (~> 0.1) + simplecov-html (0.13.2) + simplecov_json_formatter (0.1.4) solid_cable (3.0.12) actioncable (>= 7.2) activejob (>= 7.2) @@ -361,8 +336,6 @@ GEM railties (>= 7.1) thor (>= 1.3.1) sqlite3 (2.9.0-x86_64-linux-gnu) - sqlite3-ruby (1.3.3) - sqlite3 (>= 1.3.3) sshkit (1.25.0) base64 logger @@ -373,15 +346,8 @@ GEM stimulus-rails (1.3.4) railties (>= 6.0.0) stringio (3.2.0) - temple (0.10.4) - thin (2.0.1) - daemons (~> 1.0, >= 1.0.9) - eventmachine (~> 1.0, >= 1.0.4) - logger - rack (>= 1, < 4) thor (1.5.0) thruster (0.1.17-x86_64-linux) - tilt (2.7.0) timeout (0.6.0) tsort (0.2.0) turbo-rails (2.0.23) @@ -413,6 +379,7 @@ PLATFORMS DEPENDENCIES bcrypt (~> 3.1.7) + benchmark bootsnap brakeman bundler-audit @@ -424,13 +391,13 @@ DEPENDENCIES importmap-rails jbuilder kamal - mailcatcher propshaft puma (>= 5.0) rails (~> 8.1.2) roo (~> 3.0) rubocop-rails-omakase selenium-webdriver + simplecov solid_cable solid_cache solid_queue @@ -459,6 +426,7 @@ CHECKSUMS base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b bcrypt (3.1.21) sha256=5964613d750a42c7ee5dc61f7b9336fb6caca429ba4ac9f2011609946e4a2dcf bcrypt_pbkdf (1.1.2) sha256=c2414c23ce66869b3eb9f643d6a3374d8322dfb5078125c82792304c10b94cf6 + benchmark (0.5.0) sha256=465df122341aedcb81a2a24b4d3bd19b6c67c1530713fd533f3ff034e419236c bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 bindex (0.8.1) sha256=7b1ecc9dc539ed8bccfc8cb4d2732046227b09d6f37582ff12e50a5047ceb17e bootsnap (1.21.1) sha256=9373acfe732da35846623c337d3481af8ce77c7b3a927fb50e9aa92b46dbc4c4 @@ -472,20 +440,18 @@ CHECKSUMS connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.6) sha256=dc516022a56e7b3b156099abc81b6d2b08ea1ed12676ac7a5657617f012bd45d csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f - daemons (1.4.1) sha256=8fc76d76faec669feb5e455d72f35bd4c46dc6735e28c420afb822fac1fa9a1d date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6 + docile (1.4.1) sha256=96159be799bfa73cdb721b840e9802126e4e03dfc26863db73647204c727f21e dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506 erb (6.0.1) sha256=28ecdd99c5472aebd5674d6061e3c6b0a45c049578b071e5a52c2a7f13c197e5 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 et-orbi (1.4.0) sha256=6c7e3c90779821f9e3b324c5e96fda9767f72995d6ae435b96678a4f3e2de8bc - eventmachine (1.2.7) sha256=994016e42aa041477ba9cff45cbe50de2047f25dd418eba003e84f0d16560972 ffi (1.17.3-x86_64-linux-gnu) sha256=3746b01f677aae7b16dc1acb7cb3cc17b3e35bdae7676a3f568153fb0e2c887f fugit (1.12.1) sha256=5898f478ede9b415f0804e42b8f3fd53f814bd85eebffceebdbc34e1107aaf68 globalid (1.3.0) sha256=05c639ad6eb4594522a0b07983022f04aa7254626ab69445a0e493aa3786ff11 - haml (7.2.0) sha256=87fd2b71f7feab1724337b090a7d767f5ab2d42f08c974f3ead673f18cfcd55a htmlentities (4.4.2) sha256=bbafbdf69f2eca9262be4efef7e43e6a1de54c95eb600f26984f71d2fe96c5c3 i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 image_processing (1.14.0) sha256=754cc169c9c262980889bec6bfd325ed1dafad34f85242b5a07b60af004742fb @@ -500,14 +466,12 @@ CHECKSUMS logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 loofah (2.25.0) sha256=df5ed7ac3bac6a4ec802df3877ee5cc86d027299f8952e6243b3dac446b060e6 mail (2.9.0) sha256=6fa6673ecd71c60c2d996260f9ee3dd387d4673b8169b502134659ece6d34941 - mailcatcher (0.2.4) sha256=ba1d6f23d32f69929dce332d0aa7aeabddadd15507de474754e593807111dda9 marcel (1.1.0) sha256=fdcfcfa33cc52e93c4308d40e4090a5d4ea279e160a7f6af988260fa970e0bee matrix (0.4.3) sha256=a0d5ab7ddcc1973ff690ab361b67f359acbb16958d1dc072b8b956a286564c5b mini_magick (5.3.1) sha256=29395dfd76badcabb6403ee5aff6f681e867074f8f28ce08d78661e9e4a351c4 mini_mime (1.1.5) sha256=8681b7e2e4215f2a159f9400b5816d85e9d8c6c6b491e96a12797e798f8bccef minitest (6.0.1) sha256=7854c74f48e2e975969062833adc4013f249a4b212f5e7b9d5c040bf838d54bb msgpack (1.8.0) sha256=e64ce0212000d016809f5048b48eb3a65ffb169db22238fb4b72472fecb2d732 - mustermann (3.0.4) sha256=85fadcb6b3c6493a8b511b42426f904b7f27b282835502233dd154daab13aa22 net-imap (0.6.2) sha256=08caacad486853c61676cca0c0c47df93db02abc4a8239a8b67eb0981428acc6 net-pop (0.1.2) sha256=848b4e982013c15b2f0382792268763b748cce91c9e91e36b0f27ed26420dff3 net-protocol (0.2.2) sha256=aa73e0cba6a125369de9837b8d8ef82a61849360eba0521900e2c3713aa162a8 @@ -530,7 +494,6 @@ CHECKSUMS raabro (1.4.0) sha256=d4fa9ff5172391edb92b242eed8be802d1934b1464061ae5e70d80962c5da882 racc (1.8.1) sha256=4a7f6929691dbec8b5209a0b373bc2614882b55fc5d2e447a21aaa691303d62f rack (3.2.4) sha256=5d74b6f75082a643f43c1e76b419c40f0e5527fcfee1e669ac1e6b73c0ccb6f6 - rack-protection (4.2.1) sha256=cf6e2842df8c55f5e4d1a4be015e603e19e9bc3a7178bae58949ccbb58558bac rack-session (2.1.1) sha256=0b6dc07dea7e4b583f58a48e8b806d4c9f1c6c9214ebc202ec94562cbea2e4e9 rack-test (2.2.0) sha256=005a36692c306ac0b4a9350355ee080fd09ddef1148a5f8b2ac636c720f5c463 rackup (2.3.1) sha256=6c79c26753778e90983761d677a48937ee3192b3ffef6bc963c0950f94688868 @@ -552,25 +515,21 @@ CHECKSUMS rubocop-rails-omakase (1.1.0) sha256=2af73ac8ee5852de2919abbd2618af9c15c19b512c4cfc1f9a5d3b6ef009109d ruby-progressbar (1.13.0) sha256=80fc9c47a9b640d6834e0dc7b3c94c9df37f08cb072b7761e4a71e22cff29b33 ruby-vips (2.3.0) sha256=e685ec02c13969912debbd98019e50492e12989282da5f37d05f5471442f5374 - ruby2_keywords (0.0.5) sha256=ffd13740c573b7301cf7a2e61fc857b2a8e3d3aff32545d6f8300d8bae10e3ef rubyzip (3.2.2) sha256=c0ed99385f0625415c8f05bcae33fe649ed2952894a95ff8b08f26ca57ea5b3c securerandom (0.4.1) sha256=cc5193d414a4341b6e225f0cb4446aceca8e50d5e1888743fac16987638ea0b1 selenium-webdriver (4.40.0) sha256=16ef7aa9853c1d4b9d52eac45aafa916e3934c5c83cb4facb03f250adfd15e5b - sinatra (4.2.1) sha256=b7aeb9b11d046b552972ade834f1f9be98b185fa8444480688e3627625377080 - skinny (0.2.2) sha256=f40caceccfe3e1d9826f60195a090f43ea7c1130c36b3170887db69a9fb52102 + simplecov (0.22.0) sha256=fe2622c7834ff23b98066bb0a854284b2729a569ac659f82621fc22ef36213a5 + simplecov-html (0.13.2) sha256=bd0b8e54e7c2d7685927e8d6286466359b6f16b18cb0df47b508e8d73c777246 + simplecov_json_formatter (0.1.4) sha256=529418fbe8de1713ac2b2d612aa3daa56d316975d307244399fa4838c601b428 solid_cable (3.0.12) sha256=a168a54731a455d5627af48d8441ea3b554b8c1f6e6cd6074109de493e6b0460 solid_cache (1.0.10) sha256=bc05a2fb3ac78a6f43cbb5946679cf9db67dd30d22939ededc385cb93e120d41 solid_queue (1.3.1) sha256=d9580111180c339804ff1a810a7768f69f5dc694d31e86cf1535ff2cd7a87428 sqlite3 (2.9.0-x86_64-linux-gnu) sha256=72fff9bd750070ba3af695511ba5f0e0a2d8a9206f84869640b3e99dfaf3d5a5 - sqlite3-ruby (1.3.3) sha256=140b6742875dd5afc3f30ab95720fe60d38e154ae1f4d0728e250778a04094e7 sshkit (1.25.0) sha256=c8c6543cdb60f91f1d277306d585dd11b6a064cb44eab0972827e4311ff96744 stimulus-rails (1.3.4) sha256=765676ffa1f33af64ce026d26b48e8ffb2e0b94e0f50e9119e11d6107d67cb06 stringio (3.2.0) sha256=c37cb2e58b4ffbd33fe5cd948c05934af997b36e0b6ca6fdf43afa234cf222e1 - temple (0.10.4) sha256=b7a1e94b6f09038ab0b6e4fe0126996055da2c38bec53a8a336f075748fff72c - thin (2.0.1) sha256=5bbde5648377f5c3864b5da7cd89a23b5c2d8d8bb9435719f6db49644bcdade9 thor (1.5.0) sha256=e3a9e55fe857e44859ce104a84675ab6e8cd59c650a49106a05f55f136425e73 thruster (0.1.17-x86_64-linux) sha256=77b8f335075bd4ece7631dc84a19a710a1e6e7102cbce147b165b45851bdfcd3 - tilt (2.7.0) sha256=0d5b9ba69f6a36490c64b0eee9f6e9aad517e20dcc848800a06eb116f08c6ab3 timeout (0.6.0) sha256=6d722ad619f96ee383a0c557ec6eb8c4ecb08af3af62098a0be5057bf00de1af tsort (0.2.0) sha256=9650a793f6859a43b6641671278f79cfead60ac714148aabe4e3f0060480089f turbo-rails (2.0.23) sha256=ee0d90733aafff056cf51ff11e803d65e43cae258cc55f6492020ec1f9f9315f diff --git a/config/environments/test.rb b/config/environments/test.rb index c2095b1..f3cc198 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -20,7 +20,7 @@ Rails.application.configure do # Show full error reports. config.consider_all_requests_local = true - config.cache_store = :null_store + config.cache_store = :memory_store # Render exception templates for rescuable exceptions and raise for other exceptions. config.action_dispatch.show_exceptions = :rescuable diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb new file mode 100644 index 0000000..7139984 --- /dev/null +++ b/test/application_system_test_case.rb @@ -0,0 +1,15 @@ +require "test_helper" +require "capybara/rails" +require "capybara/minitest" + +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] + + # Helper to login as a user + def login_as(user) + visit login_path + fill_in "Email", with: user.email + fill_in "Password", with: "password123456" + click_button "Sign In" + end +end diff --git a/test/controllers/admin/invitations_controller_test.rb b/test/controllers/admin/invitations_controller_test.rb index da433f5..8fac205 100644 --- a/test/controllers/admin/invitations_controller_test.rb +++ b/test/controllers/admin/invitations_controller_test.rb @@ -140,4 +140,404 @@ class Admin::InvitationsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to root_path end + + # Resend action tests + test "should resend invitation for pending invitation" do + login_as(users(:admin_user)) + pending_user = users(:pending_invitation) + original_token = pending_user.invitation_token + original_sent_at = pending_user.invitation_sent_at + + assert_enqueued_emails 1 do + put resend_admin_invitation_path(pending_user) + end + + assert_redirected_to admin_invitations_path + assert_match /Invitation resent to #{pending_user.email}/, flash[:notice] + + pending_user.reload + assert_not_equal original_token, pending_user.invitation_token + assert_operator pending_user.invitation_sent_at, :>, original_sent_at + end + + test "should not resend accepted invitation" do + login_as(users(:admin_user)) + accepted_user = users(:contributor_user) + + assert_enqueued_emails 0 do + put resend_admin_invitation_path(accepted_user) + end + + assert_redirected_to admin_invitations_path + assert_equal "Cannot resend an accepted invitation.", flash[:alert] + end + + test "should update invitation_sent_at when resending" do + login_as(users(:admin_user)) + pending_user = users(:pending_invitation) + + freeze_time do + put resend_admin_invitation_path(pending_user) + + pending_user.reload + assert_in_delta Time.current.to_i, pending_user.invitation_sent_at.to_i, 2 + end + end + + test "should generate new token when resending" do + login_as(users(:admin_user)) + pending_user = users(:pending_invitation) + original_token = pending_user.invitation_token + + put resend_admin_invitation_path(pending_user) + + pending_user.reload + assert_not_nil pending_user.invitation_token + assert_not_equal original_token, pending_user.invitation_token + assert pending_user.invitation_token.length >= 32 + end + + test "should not allow non-admin to resend invitation" do + login_as(users(:contributor_user)) + + put resend_admin_invitation_path(users(:pending_invitation)) + + assert_redirected_to root_path + end + + test "should require authentication to resend invitation" do + put resend_admin_invitation_path(users(:pending_invitation)) + + assert_redirected_to login_path + end + + # Index action tests + test "should list pending invitations in descending order by sent date" do + login_as(users(:admin_user)) + + # Create multiple pending invitations + user1 = User.create!(email: "user1@example.com", name: "User 1", password: "temp123456789") + user1.invite_by!(users(:admin_user)) + + user2 = User.create!(email: "user2@example.com", name: "User 2", password: "temp123456789") + user2.update_columns(invitation_token: "token2", invitation_sent_at: 1.hour.ago) + + get admin_invitations_path + + assert_response :success + assert_select "h3", /Pending Invitations/i + # Check both emails appear in the page + assert_select "td", text: "user1@example.com" + assert_select "td", text: "user2@example.com" + end + + test "should display accepted invitations section" do + login_as(users(:admin_user)) + + get admin_invitations_path + + assert_response :success + assert_select "h3", /Recently Accepted/i + end + + test "should show pending invitation count badge" do + login_as(users(:admin_user)) + + get admin_invitations_path + + assert_response :success + assert_select "span.bg-yellow-100" + end + + test "should display pending invitations table when invitations exist" do + login_as(users(:admin_user)) + + get admin_invitations_path + + assert_select "table" + assert_select "th", text: "Email" + assert_select "th", text: "Role" + assert_select "th", text: "Sent" + end + + # Create action - additional tests + test "should create invitation with reviewer role" do + login_as(users(:admin_user)) + + post admin_invitations_path, params: { + user: { + email: "reviewer@example.com", + name: "New Reviewer", + role: "reviewer", + primary_language: "en" + } + } + + new_user = User.find_by(email: "reviewer@example.com") + assert_equal "reviewer", new_user.role + end + + test "should create invitation with admin role" do + login_as(users(:admin_user)) + + post admin_invitations_path, params: { + user: { + email: "admin@example.com", + name: "New Admin", + role: "admin", + primary_language: "en" + } + } + + new_user = User.find_by(email: "admin@example.com") + assert_equal "admin", new_user.role + end + + test "should ignore invalid role parameter" do + login_as(users(:admin_user)) + + post admin_invitations_path, params: { + user: { + email: "newuser@example.com", + name: "New User", + role: "superadmin", + primary_language: "en" + } + } + + new_user = User.find_by(email: "newuser@example.com") + assert_equal "contributor", new_user.role + end + + test "should set invited_by to current admin" do + login_as(users(:admin_user)) + + post admin_invitations_path, params: { + user: { + email: "newuser@example.com", + name: "New User", + role: "contributor", + primary_language: "en" + } + } + + new_user = User.find_by(email: "newuser@example.com") + assert_equal users(:admin_user).id, new_user.invited_by_id + end + + test "should generate random password for new invitation" do + login_as(users(:admin_user)) + + post admin_invitations_path, params: { + user: { + email: "newuser@example.com", + name: "New User", + role: "contributor", + primary_language: "en" + } + } + + new_user = User.find_by(email: "newuser@example.com") + assert new_user.password_digest.present? + end + + test "should not create invitation with duplicate email" do + login_as(users(:admin_user)) + existing_user = users(:admin_user) + + assert_no_difference("User.count") do + post admin_invitations_path, params: { + user: { + email: existing_user.email, + name: "Duplicate User", + role: "contributor", + primary_language: "en" + } + } + end + + assert_response :unprocessable_entity + assert_select "div.text-red-700", text: /already been taken/i + end + + test "should create invitation even with blank name" do + login_as(users(:admin_user)) + + assert_difference("User.count", 1) do + post admin_invitations_path, params: { + user: { + email: "newuser@example.com", + name: "", + role: "contributor", + primary_language: "en" + } + } + end + + assert_redirected_to admin_invitations_path + new_user = User.find_by(email: "newuser@example.com") + assert_not_nil new_user + end + + test "should show success message with email after creating invitation" do + login_as(users(:admin_user)) + + post admin_invitations_path, params: { + user: { + email: "newuser@example.com", + name: "New User", + role: "contributor", + primary_language: "en" + } + } + + assert_redirected_to admin_invitations_path + assert_match /Invitation sent to newuser@example\.com/, flash[:notice] + end + + # Destroy action - additional tests + test "should show success message after cancelling invitation" do + login_as(users(:admin_user)) + + delete admin_invitation_path(users(:pending_invitation)) + + assert_redirected_to admin_invitations_path + assert_equal "Invitation cancelled.", flash[:notice] + end + + test "should delete user record when cancelling invitation" do + login_as(users(:admin_user)) + pending_user = users(:pending_invitation) + user_id = pending_user.id + + delete admin_invitation_path(pending_user) + + assert_nil User.find_by(id: user_id) + end + + test "should not allow destroying user with entries" do + login_as(users(:admin_user)) + user_with_entries = users(:contributor_user) + + # This user has accepted invitation and potentially has entries + assert_no_difference("User.count") do + delete admin_invitation_path(user_with_entries) + end + + assert_redirected_to admin_invitations_path + assert_equal "Cannot cancel an accepted invitation.", flash[:alert] + end + + # Security tests + test "should only permit email, name, primary_language, and role params" do + login_as(users(:admin_user)) + + post admin_invitations_path, params: { + user: { + email: "newuser@example.com", + name: "New User", + role: "contributor", + primary_language: "en", + password: "hackedpassword123", + invitation_token: "hacked_token", + invitation_accepted_at: Time.current + } + } + + new_user = User.find_by(email: "newuser@example.com") + assert_not_nil new_user + # Password should be randomly generated, not from params + assert_not new_user.authenticate("hackedpassword123") + # Invitation token should be generated by invite_by, not from params + assert_not_equal "hacked_token", new_user.invitation_token + # Invitation should not be pre-accepted + assert_nil new_user.invitation_accepted_at + end + + test "should require admin role for all actions" do + login_as(users(:reviewer_user)) + + # Index + get admin_invitations_path + assert_redirected_to root_path + + # New + get new_admin_invitation_path + assert_redirected_to root_path + + # Create + post admin_invitations_path, params: { user: { email: "test@example.com", name: "Test" } } + assert_redirected_to root_path + + # Resend + put resend_admin_invitation_path(users(:pending_invitation)) + assert_redirected_to root_path + + # Destroy + delete admin_invitation_path(users(:pending_invitation)) + assert_redirected_to root_path + end + + # Edge cases + test "should handle resending expired invitation" do + login_as(users(:admin_user)) + pending_user = users(:pending_invitation) + pending_user.update_columns(invitation_sent_at: 20.days.ago) + + put resend_admin_invitation_path(pending_user) + + assert_redirected_to admin_invitations_path + pending_user.reload + assert pending_user.invitation_sent_at > 1.hour.ago + end + + test "should normalize email to lowercase when creating invitation" do + login_as(users(:admin_user)) + + post admin_invitations_path, params: { + user: { + email: "NewUser@Example.COM", + name: "New User", + role: "contributor", + primary_language: "en" + } + } + + new_user = User.find_by(email: "newuser@example.com") + assert_not_nil new_user + assert_equal "newuser@example.com", new_user.email + end + + test "should handle missing role parameter gracefully" do + login_as(users(:admin_user)) + + post admin_invitations_path, params: { + user: { + email: "newuser@example.com", + name: "New User", + primary_language: "en" + } + } + + new_user = User.find_by(email: "newuser@example.com") + assert_not_nil new_user + assert_equal "contributor", new_user.role + end + + test "should handle blank role parameter" do + login_as(users(:admin_user)) + + post admin_invitations_path, params: { + user: { + email: "newuser@example.com", + name: "New User", + role: "", + primary_language: "en" + } + } + + new_user = User.find_by(email: "newuser@example.com") + assert_not_nil new_user + assert_equal "contributor", new_user.role + end end diff --git a/test/controllers/admin/requests_controller_test.rb b/test/controllers/admin/requests_controller_test.rb index 65aceb9..2ba0ad6 100644 --- a/test/controllers/admin/requests_controller_test.rb +++ b/test/controllers/admin/requests_controller_test.rb @@ -43,9 +43,9 @@ class Admin::RequestsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_select "h1", "Entry Request Details" - assert_match @requested_entry.fi, response.body - assert_match @requested_entry.en, response.body - assert_match @requested_entry.requested_by.name, response.body + assert_select "div", text: @requested_entry.fi + assert_select "div", text: @requested_entry.en + assert_select "span", text: @requested_entry.requested_by.name end test "should show edit form for requested entry" do diff --git a/test/controllers/admin/users_controller_test.rb b/test/controllers/admin/users_controller_test.rb index 53b9392..3ef1a65 100644 --- a/test/controllers/admin/users_controller_test.rb +++ b/test/controllers/admin/users_controller_test.rb @@ -18,6 +18,26 @@ class Admin::UsersControllerTest < ActionDispatch::IntegrationTest assert_response :success end + test "should filter users by role" do + login_as(users(:admin_user)) + + get admin_users_path, params: { role: "reviewer" } + + assert_response :success + assert_select "td", text: /#{Regexp.escape(users(:reviewer_user).email)}/ + assert_select "td", text: /#{Regexp.escape(users(:contributor_user).email)}/, count: 0 + end + + test "should filter users by email query" do + login_as(users(:admin_user)) + + get admin_users_path, params: { q: "admin" } + + assert_response :success + assert_select "td", text: /#{Regexp.escape(users(:admin_user).email)}/ + assert_select "td", text: /#{Regexp.escape(users(:contributor_user).email)}/, count: 0 + end + test "should get edit page for user when logged in as admin" do login_as(users(:admin_user)) get edit_admin_user_path(users(:contributor_user)) @@ -35,6 +55,45 @@ class Admin::UsersControllerTest < ActionDispatch::IntegrationTest assert_equal "reviewer", users(:contributor_user).reload.role end + test "should not allow admin to update own role" do + admin_user = users(:admin_user) + login_as(admin_user) + + patch admin_user_path(admin_user), params: { + user: { role: "reviewer" } + } + + assert_redirected_to admin_users_path + assert_equal "You cannot modify your own role.", flash[:alert] + assert_equal "admin", admin_user.reload.role + end + + test "should ignore invalid role updates" do + login_as(users(:admin_user)) + contributor = users(:contributor_user) + + patch admin_user_path(contributor), params: { + user: { role: "invalid_role", name: "Updated Name" } + } + + assert_redirected_to admin_users_path + contributor.reload + assert_equal "contributor", contributor.role + assert_equal "Updated Name", contributor.name + end + + test "should render edit when update is invalid" do + login_as(users(:admin_user)) + contributor = users(:contributor_user) + + patch admin_user_path(contributor), params: { + user: { email: "" } + } + + assert_response :unprocessable_entity + assert_select "li", text: "Email can't be blank" + end + test "should delete user when logged in as admin" do login_as(users(:admin_user)) @@ -46,6 +105,37 @@ class Admin::UsersControllerTest < ActionDispatch::IntegrationTest assert_redirected_to admin_users_path end + test "should not allow admin to delete own account" do + admin_user = users(:admin_user) + login_as(admin_user) + + assert_no_difference("User.count") do + delete admin_user_path(admin_user) + end + + assert_redirected_to admin_users_path + assert_equal "You cannot delete your own account.", flash[:alert] + end + + test "should not allow deleting first admin user" do + other_admin = User.create!( + email: "other-admin@example.com", + name: "Other Admin", + role: :admin, + primary_language: "en", + password: "password123456", + invitation_accepted_at: Time.current + ) + login_as(other_admin) + + assert_no_difference("User.count") do + delete admin_user_path(User.first) + end + + assert_redirected_to admin_users_path + assert_equal "Cannot delete the first admin user (system default contact).", flash[:alert] + end + test "should not allow non-admin to update user" do login_as(users(:contributor_user)) diff --git a/test/controllers/entries_controller_test.rb b/test/controllers/entries_controller_test.rb new file mode 100644 index 0000000..ba56056 --- /dev/null +++ b/test/controllers/entries_controller_test.rb @@ -0,0 +1,176 @@ +require "test_helper" +require "roo" +require "tempfile" + +class EntriesControllerTest < ActionDispatch::IntegrationTest + setup do + @entry = entries(:one) + @user = users(:admin_user) + end + + # INDEX tests + test "should get index" do + get entries_url + assert_response :success + end + + test "should filter by language" do + get entries_url, params: { language: "fi" } + assert_response :success + assert_select "input[type=hidden][name='language'][value='fi']" + end + + test "should filter by category" do + get entries_url, params: { category: "word" } + assert_response :success + assert_select "input[type=hidden][name='category'][value='word']" + end + + test "should search with query" do + get entries_url, params: { q: "test" } + assert_response :success + assert_select "input[name='q'][value='test']" + end + + test "should filter by starts_with" do + get entries_url, params: { starts_with: "a" } + assert_response :success + assert_select "input[type=hidden][name='starts_with'][value='a']" + end + + test "should paginate results" do + get entries_url, params: { page: 2 } + assert_response :success + assert_select "div", text: /Page 2 of/i + end + + test "should handle invalid language code" do + get entries_url, params: { language: "invalid" } + assert_response :success + assert_select "input[name='language']", count: 0 + end + + test "should respond to turbo_stream" do + get entries_url, as: :turbo_stream + assert_response :success + end + + test "should only show active entries in index" do + # Create a requested entry that should not appear + requested_entry = Entry.create!( + fi: "Requested", + category: :word, + status: :requested, + requested_by: @user + ) + + get entries_url + assert_response :success + assert_select "td", text: requested_entry.fi, count: 0 + end + + # SHOW tests + test "should show entry" do + get entry_url(@entry) + assert_response :success + end + + test "should show entry with comments" do + get entry_url(@entry) + assert_response :success + assert_select "p", text: @entry.fi + end + + # EDIT tests + test "should get edit" do + get edit_entry_url(@entry) + assert_response :success + end + + # UPDATE tests + test "should update entry" do + patch entry_url(@entry), params: { + entry: { + fi: "Updated Finnish", + en: "Updated English", + category: "word" + } + } + assert_redirected_to entry_url(@entry) + @entry.reload + assert_equal "Updated Finnish", @entry.fi + end + + test "should not update entry with invalid data" do + patch entry_url(@entry), params: { + entry: { + fi: "", + en: "", + sv: "", + no: "", + ru: "", + de: "" + } + } + assert_response :unprocessable_entity + end + + test "should update entry category" do + patch entry_url(@entry), params: { + entry: { category: "phrase" } + } + assert_redirected_to entry_url(@entry) + @entry.reload + assert_equal "phrase", @entry.category + end + + # DOWNLOAD tests + test "should download xlsx" do + get download_entries_url(format: :xlsx) + assert_response :success + assert_equal "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + response.media_type + end + + test "should include active entries in xlsx" do + unique_entry = Entry.create!( + fi: "UniqueActiveEntry123", + category: :word, + status: :active + ) + + get download_entries_url(format: :xlsx) + assert_response :success + + Tempfile.create([ "entries", ".xlsx" ]) do |file| + file.binmode + file.write(response.body) + file.flush + + sheet = Roo::Excelx.new(file.path).sheet(0) + cell_values = sheet.to_a.flatten.compact + assert_includes cell_values, unique_entry.fi + end + end + + # Statistics tests + test "should calculate entry statistics" do + get entries_url + assert_response :success + assert_select "div", text: /entries/i + assert_select "div", text: /% complete/i + end + + test "should calculate language completion" do + get entries_url + assert_response :success + assert_select "div", text: /% complete/i + end + + # Language ordering tests + test "should prioritize selected language in display" do + get entries_url, params: { language: "fi" } + assert_response :success + assert_select "th span", text: "FI", count: 1 + end +end diff --git a/test/controllers/invitations_controller_test.rb b/test/controllers/invitations_controller_test.rb index 33be840..5719cd0 100644 --- a/test/controllers/invitations_controller_test.rb +++ b/test/controllers/invitations_controller_test.rb @@ -90,4 +90,306 @@ class InvitationsControllerTest < ActionDispatch::IntegrationTest assert_redirected_to root_path assert_equal "Invalid or expired invitation link.", flash[:alert] end + + test "should redirect admin to dashboard after accepting invitation" do + inviter = users(:admin_user) + pending_admin = User.create!( + email: "pending-admin@example.com", + name: "Pending Admin", + role: :admin, + primary_language: "en", + invitation_token: "pending_admin_token", + invitation_sent_at: 1.day.ago, + invited_by: inviter, + password: "password123456" + ) + + patch accept_invitation_path(pending_admin.invitation_token), params: { + user: { + password: "securepassword123", + password_confirmation: "securepassword123" + } + } + + pending_admin.reload + assert_not_nil pending_admin.invitation_accepted_at + assert_nil pending_admin.invitation_token + assert_equal pending_admin.id, session[:user_id] + assert_redirected_to admin_root_path + end + + # Entry activation tests + test "should activate approved entries on invitation acceptance" do + user = users(:pending_invitation) + + # Create approved entry for this user + approved_entry = Entry.create!( + category: :word, + fi: "Test word", + status: :approved, + requested_by: user + ) + + # Create requested entry (should not be activated) + requested_entry = Entry.create!( + category: :word, + fi: "Requested word", + status: :requested, + requested_by: user + ) + + patch accept_invitation_path(user.invitation_token), params: { + user: { + password: "securepassword123", + password_confirmation: "securepassword123" + } + } + + approved_entry.reload + requested_entry.reload + + assert_equal "active", approved_entry.status + assert_equal "requested", requested_entry.status + end + + test "should activate multiple approved entries on invitation acceptance" do + user = users(:pending_invitation) + + # Create multiple approved entries + entries = 3.times.map do |i| + Entry.create!( + category: :word, + fi: "Word #{i}", + status: :approved, + requested_by: user + ) + end + + patch accept_invitation_path(user.invitation_token), params: { + user: { + password: "securepassword123", + password_confirmation: "securepassword123" + } + } + + entries.each do |entry| + entry.reload + assert_equal "active", entry.status + end + end + + test "should not activate entries for other users" do + user = users(:pending_invitation) + other_user = users(:admin_user) + + # Create approved entry for another user + other_entry = Entry.create!( + category: :word, + fi: "Other user word", + status: :approved, + requested_by: other_user + ) + + patch accept_invitation_path(user.invitation_token), params: { + user: { + password: "securepassword123", + password_confirmation: "securepassword123" + } + } + + other_entry.reload + assert_equal "approved", other_entry.status + end + + test "should handle invitation acceptance with no entries" do + user = users(:pending_invitation) + + # Ensure the user has no entries + Entry.where(requested_by: user).delete_all + assert_equal 0, Entry.where(requested_by: user).count + + patch accept_invitation_path(user.invitation_token), params: { + user: { + password: "securepassword123", + password_confirmation: "securepassword123" + } + } + + assert_redirected_to root_path + user.reload + assert_not_nil user.invitation_accepted_at + end + + # Security tests + test "should only permit password parameters" do + user = users(:pending_invitation) + original_email = user.email + original_name = user.name + original_role = user.role + + patch accept_invitation_path(user.invitation_token), params: { + user: { + password: "securepassword123", + password_confirmation: "securepassword123", + email: "hacker@example.com", + name: "Hacker Name", + role: "admin" + } + } + + user.reload + assert_equal original_email, user.email + assert_equal original_name, user.name + assert_equal original_role, user.role + end + + test "should not log in user when password validation fails" do + user = users(:pending_invitation) + + patch accept_invitation_path(user.invitation_token), params: { + user: { + password: "short", + password_confirmation: "short" + } + } + + assert_nil session[:user_id] + end + + # Edge cases + test "should handle already accepted invitation" do + user = users(:pending_invitation) + user.update!(invitation_accepted_at: Time.current, invitation_token: nil) + + patch accept_invitation_path("some_token"), params: { + user: { + password: "securepassword123", + password_confirmation: "securepassword123" + } + } + + assert_redirected_to root_path + assert_equal "Invalid or expired invitation link.", flash[:alert] + end + + test "should set invitation_accepted_at timestamp" do + user = users(:pending_invitation) + assert_nil user.invitation_accepted_at + + freeze_time do + patch accept_invitation_path(user.invitation_token), params: { + user: { + password: "securepassword123", + password_confirmation: "securepassword123" + } + } + + user.reload + assert_in_delta Time.current.to_i, user.invitation_accepted_at.to_i, 2 + end + end + + test "should clear invitation token after acceptance" do + user = users(:pending_invitation) + token = user.invitation_token + assert_not_nil token + + patch accept_invitation_path(token), params: { + user: { + password: "securepassword123", + password_confirmation: "securepassword123" + } + } + + user.reload + assert_nil user.invitation_token + end + + test "should require password to be present" do + user = users(:pending_invitation) + + patch accept_invitation_path(user.invitation_token), params: { + user: { + password: nil, + password_confirmation: nil + } + } + + # has_secure_password validates password presence when setting password + user.reload + assert_nil user.invitation_accepted_at + end + + test "should show validation errors for short password" do + user = users(:pending_invitation) + + patch accept_invitation_path(user.invitation_token), params: { + user: { + password: "short", + password_confirmation: "short" + } + } + + assert_response :unprocessable_entity + assert_select "div.text-red-700", text: /too short/i + end + + test "should show validation errors for mismatched passwords" do + user = users(:pending_invitation) + + patch accept_invitation_path(user.invitation_token), params: { + user: { + password: "securepassword123", + password_confirmation: "differentpassword" + } + } + + assert_response :unprocessable_entity + assert_select "div.text-red-700", text: /confirmation/i + end + + test "should authenticate with new password after acceptance" do + user = users(:pending_invitation) + + patch accept_invitation_path(user.invitation_token), params: { + user: { + password: "mynewpassword123", + password_confirmation: "mynewpassword123" + } + } + + user.reload + assert user.authenticate("mynewpassword123") + assert_not user.authenticate("oldpassword") + end + + test "should log in user immediately after acceptance" do + user = users(:pending_invitation) + + # First visit the invitation page to establish session + get invitation_path(user.invitation_token) + assert_nil session[:user_id] + + patch accept_invitation_path(user.invitation_token), params: { + user: { + password: "securepassword123", + password_confirmation: "securepassword123" + } + } + + assert_equal user.id, session[:user_id] + end + + test "should show welcome message with user name" do + user = users(:pending_invitation) + + patch accept_invitation_path(user.invitation_token), params: { + user: { + password: "securepassword123", + password_confirmation: "securepassword123" + } + } + + assert_match /Welcome to Sanasto Wiki, #{user.name}!/, flash[:notice] + end end diff --git a/test/controllers/password_resets_controller_test.rb b/test/controllers/password_resets_controller_test.rb new file mode 100644 index 0000000..f400f3a --- /dev/null +++ b/test/controllers/password_resets_controller_test.rb @@ -0,0 +1,196 @@ +require "test_helper" + +class PasswordResetsControllerTest < ActionDispatch::IntegrationTest + setup do + @user = users(:admin_user) + @user.update!(invitation_accepted_at: Time.current) + end + + # NEW tests + test "should get new" do + get new_password_reset_url + assert_response :success + end + + test "should show password reset form" do + get new_password_reset_url + assert_select "form" + assert_select "input[type=email]" + end + + # CREATE tests + test "should send reset email for existing user" do + assert_enqueued_emails 1 do + post password_resets_url, params: { email: @user.email } + end + assert_redirected_to login_url + assert_match /password reset instructions/i, flash[:notice] + end + + test "should generate reset token for existing user" do + post password_resets_url, params: { email: @user.email } + @user.reload + assert_not_nil @user.reset_password_token + assert_not_nil @user.reset_password_sent_at + end + + test "should handle non-existent email gracefully" do + post password_resets_url, params: { email: "nonexistent@example.com" } + assert_redirected_to login_url + assert_match /if that email address is in our system/i, flash[:notice] + end + + test "should send invitation for user without accepted invitation" do + pending_user = users(:pending_invitation) + assert_nil pending_user.invitation_accepted_at + + assert_enqueued_emails 1 do + post password_resets_url, params: { email: pending_user.email } + end + assert_redirected_to login_url + end + + test "should handle blank email" do + post password_resets_url, params: { email: "" } + assert_redirected_to login_url + end + + test "should handle email with whitespace" do + post password_resets_url, params: { email: " #{@user.email} " } + @user.reload + assert_not_nil @user.reset_password_token + end + + # EDIT tests + test "should show reset password form with valid token" do + @user.update!( + reset_password_token: SecureRandom.urlsafe_base64(32), + reset_password_sent_at: Time.current + ) + get edit_password_reset_url(@user.reset_password_token) + assert_response :success + assert_select "form" + assert_select "input[type=password]", count: 2 + end + + test "should reject invalid token" do + get edit_password_reset_url("invalid_token") + assert_redirected_to login_url + assert_match /invalid/i, flash[:alert] + end + + test "should reject expired token" do + @user.update!( + reset_password_token: SecureRandom.urlsafe_base64(32), + reset_password_sent_at: 2.hours.ago + ) + get edit_password_reset_url(@user.reset_password_token) + assert_redirected_to new_password_reset_url + assert_match /expired/i, flash[:alert] + end + + # UPDATE tests + test "should reset password with valid token" do + @user.update!( + reset_password_token: SecureRandom.urlsafe_base64(32), + reset_password_sent_at: Time.current + ) + + patch password_reset_url(@user.reset_password_token), params: { + password: "newpassword12345", + password_confirmation: "newpassword12345" + } + + assert_redirected_to root_url + assert_match /password has been reset/i, flash[:notice] + + @user.reload + assert_nil @user.reset_password_token + assert_nil @user.reset_password_sent_at + assert @user.authenticate("newpassword12345") + end + + test "should auto-login after successful password reset" do + @user.update!( + reset_password_token: SecureRandom.urlsafe_base64(32), + reset_password_sent_at: Time.current + ) + + patch password_reset_url(@user.reset_password_token), params: { + password: "newpassword12345", + password_confirmation: "newpassword12345" + } + + assert_equal @user.id, session[:user_id] + end + + test "should reject mismatched passwords" do + @user.update!( + reset_password_token: SecureRandom.urlsafe_base64(32), + reset_password_sent_at: Time.current + ) + + patch password_reset_url(@user.reset_password_token), params: { + password: "newpassword12345", + password_confirmation: "differentpassword" + } + + assert_response :unprocessable_entity + assert_match /doesn't match/i, flash[:alert] + end + + test "should reject blank password" do + @user.update!( + reset_password_token: SecureRandom.urlsafe_base64(32), + reset_password_sent_at: Time.current + ) + + patch password_reset_url(@user.reset_password_token), params: { + password: "", + password_confirmation: "" + } + + assert_response :unprocessable_entity + assert_match /cannot be blank/i, flash[:alert] + end + + test "should reject expired token on update" do + @user.update!( + reset_password_token: SecureRandom.urlsafe_base64(32), + reset_password_sent_at: 2.hours.ago + ) + + patch password_reset_url(@user.reset_password_token), params: { + password: "newpassword12345", + password_confirmation: "newpassword12345" + } + + assert_redirected_to new_password_reset_url + assert_match /expired/i, flash[:alert] + end + + test "should reject invalid token on update" do + patch password_reset_url("invalid_token"), params: { + password: "newpassword12345", + password_confirmation: "newpassword12345" + } + + assert_redirected_to login_url + assert_match /invalid/i, flash[:alert] + end + + test "should enforce password validations" do + @user.update!( + reset_password_token: SecureRandom.urlsafe_base64(32), + reset_password_sent_at: Time.current + ) + + # Password too short (less than 12 characters) + patch password_reset_url(@user.reset_password_token), params: { + password: "short", + password_confirmation: "short" + } + + assert_response :unprocessable_entity + end +end diff --git a/test/helpers/entries_helper_test.rb b/test/helpers/entries_helper_test.rb new file mode 100644 index 0000000..a0058b2 --- /dev/null +++ b/test/helpers/entries_helper_test.rb @@ -0,0 +1,210 @@ +require "test_helper" + +class EntriesHelperTest < ActionView::TestCase + setup do + @entry = entries(:one) + end + + # alphabet_letters tests + test "alphabet_letters returns A-Z for blank language code" do + result = alphabet_letters(nil) + assert_equal ("A".."Z").to_a, result + assert_equal 26, result.length + end + + test "alphabet_letters returns A-Z for empty string" do + result = alphabet_letters("") + assert_equal ("A".."Z").to_a, result + end + + test "alphabet_letters returns A-Z for unknown language" do + result = alphabet_letters("unknown") + assert_equal ("A".."Z").to_a, result + end + + test "alphabet_letters returns A-Z for English" do + result = alphabet_letters("en") + assert_equal ("A".."Z").to_a, result + assert_equal 26, result.length + end + + test "alphabet_letters returns A-Z for German" do + result = alphabet_letters("de") + assert_equal ("A".."Z").to_a, result + end + + test "alphabet_letters returns Cyrillic alphabet for Russian" do + result = alphabet_letters("ru") + assert_equal 33, result.length + assert_includes result, "А" + assert_includes result, "Я" + assert_includes result, "Ё" + assert_not_includes result, "A" + assert_not_includes result, "Z" + end + + test "alphabet_letters returns A-Z plus Æ Ø Å for Norwegian" do + result = alphabet_letters("no") + assert_equal 29, result.length + assert_includes result, "A" + assert_includes result, "Z" + assert_includes result, "Æ" + assert_includes result, "Ø" + assert_includes result, "Å" + assert_equal [ "Æ", "Ø", "Å" ], result.last(3) + end + + test "alphabet_letters returns A-Z plus Å Ä Ö for Swedish" do + result = alphabet_letters("sv") + assert_equal 29, result.length + assert_includes result, "A" + assert_includes result, "Z" + assert_includes result, "Å" + assert_includes result, "Ä" + assert_includes result, "Ö" + assert_equal [ "Å", "Ä", "Ö" ], result.last(3) + end + + test "alphabet_letters returns A-Z plus Å Ä Ö for Finnish" do + result = alphabet_letters("fi") + assert_equal 29, result.length + assert_includes result, "A" + assert_includes result, "Z" + assert_includes result, "Å" + assert_includes result, "Ä" + assert_includes result, "Ö" + assert_equal [ "Å", "Ä", "Ö" ], result.last(3) + end + + # entry_translation_for tests + test "entry_translation_for returns Finnish translation" do + @entry.fi = "Suomalainen sana" + result = entry_translation_for(@entry, "fi") + assert_equal "Suomalainen sana", result + end + + test "entry_translation_for returns English translation" do + @entry.en = "English word" + result = entry_translation_for(@entry, "en") + assert_equal "English word", result + end + + test "entry_translation_for returns Swedish translation" do + @entry.sv = "Svenskt ord" + result = entry_translation_for(@entry, "sv") + assert_equal "Svenskt ord", result + end + + test "entry_translation_for returns Norwegian translation" do + @entry.no = "Norsk ord" + result = entry_translation_for(@entry, "no") + assert_equal "Norsk ord", result + end + + test "entry_translation_for returns Russian translation" do + @entry.ru = "Русское слово" + result = entry_translation_for(@entry, "ru") + assert_equal "Русское слово", result + end + + test "entry_translation_for returns German translation" do + @entry.de = "Deutsches Wort" + result = entry_translation_for(@entry, "de") + assert_equal "Deutsches Wort", result + end + + test "entry_translation_for returns nil for invalid language code" do + result = entry_translation_for(@entry, "invalid") + assert_nil result + end + + test "entry_translation_for returns nil for non-existent attribute" do + result = entry_translation_for(@entry, "zz") + assert_nil result + end + + test "entry_translation_for returns nil for blank translation" do + @entry.fi = nil + result = entry_translation_for(@entry, "fi") + assert_nil result + end + + test "entry_translation_for handles symbol language code" do + @entry.fi = "Test" + result = entry_translation_for(@entry, :fi) + assert_equal "Test", result + end + + # format_entry_category tests + test "format_entry_category formats word category" do + @entry.category = "word" + result = format_entry_category(@entry) + assert_equal "Word", result + end + + test "format_entry_category formats phrase category" do + @entry.category = "phrase" + result = format_entry_category(@entry) + assert_equal "Phrase", result + end + + test "format_entry_category formats proper_name category" do + @entry.category = "proper_name" + result = format_entry_category(@entry) + assert_equal "Proper name", result + end + + test "format_entry_category formats title category" do + @entry.category = "title" + result = format_entry_category(@entry) + assert_equal "Title", result + end + + test "format_entry_category formats reference category" do + @entry.category = "reference" + result = format_entry_category(@entry) + assert_equal "Reference", result + end + + test "format_entry_category formats other category" do + @entry.category = "other" + result = format_entry_category(@entry) + assert_equal "Other", result + end + + # format_entry_status tests + test "format_entry_status returns requested badge" do + @entry.status = :requested + result = format_entry_status(@entry) + assert_match /Requested/, result + assert_match /bg-yellow-100/, result + assert_match /text-yellow-800/, result + end + + test "format_entry_status returns approved badge" do + @entry.status = :approved + result = format_entry_status(@entry) + assert_match /Approved/, result + assert_match /bg-blue-100/, result + assert_match /text-blue-800/, result + end + + test "format_entry_status returns active badge" do + @entry.status = :active + result = format_entry_status(@entry) + assert_match /Active/, result + assert_match /bg-green-100/, result + assert_match /text-green-800/, result + end + + test "format_entry_status badge contains proper HTML structure" do + @entry.status = :active + result = format_entry_status(@entry) + assert_match /= 32 + end + + # invite_by! tests + test "invite_by! saves the user" do + inviter = users(:admin_user) + user = User.new(email: "test@example.com", password: "password123456") + user.invite_by!(inviter) + + assert_not_nil user.id + assert user.persisted? + end + + test "invite_by! works without invitee parameter" do + user = User.new(email: "test@example.com", password: "password123456") + user.invite_by! + + assert_not_nil user.id + assert_not_nil user.invitation_token + end + + # find_by_valid_invitation_token tests + test "find_by_valid_invitation_token finds user with valid token" do + user = User.create!( + email: "test@example.com", + password: "password123456", + invitation_token: "valid_token_12345", + invitation_sent_at: 1.day.ago, + invitation_accepted_at: nil + ) + + found_user = User.find_by_valid_invitation_token("valid_token_12345") + assert_equal user, found_user + end + + test "find_by_valid_invitation_token returns nil for expired token" do + User.create!( + email: "test@example.com", + password: "password123456", + invitation_token: "expired_token", + invitation_sent_at: 15.days.ago, + invitation_accepted_at: nil + ) + + found_user = User.find_by_valid_invitation_token("expired_token") + assert_nil found_user + end + + test "find_by_valid_invitation_token returns nil for accepted invitation" do + User.create!( + email: "test@example.com", + password: "password123456", + invitation_token: "accepted_token", + invitation_sent_at: 1.day.ago, + invitation_accepted_at: Time.current + ) + + found_user = User.find_by_valid_invitation_token("accepted_token") + assert_nil found_user + end + + test "find_by_valid_invitation_token returns nil for non-existent token" do + found_user = User.find_by_valid_invitation_token("nonexistent_token") + assert_nil found_user + end + + # remember_me tests + test "remember_me generates token and saves" do + user = users(:admin_user) + token = user.remember_me + + assert_not_nil token + assert_not_nil user.remember_token + assert_not_nil user.remember_created_at + assert_equal token, user.remember_token + end + + test "remember_me returns the generated token" do + user = users(:admin_user) + token = user.remember_me + + assert_kind_of String, token + assert token.length >= 32 + end + + test "remember_me updates database immediately" do + user = users(:admin_user) + user.remember_me + + user.reload + assert_not_nil user.remember_token + assert_not_nil user.remember_created_at + end + + test "remember_me saves without validation" do + user = users(:admin_user) + # Make user invalid (email already taken by changing to duplicate) + user.email = "" + token = user.remember_me + + assert_not_nil token + user.reload + assert_not_nil user.remember_token + end + + # forget_me tests + test "forget_me clears remember token and timestamp" do + user = users(:admin_user) + user.remember_me + assert_not_nil user.remember_token + + user.forget_me + user.reload + + assert_nil user.remember_token + assert_nil user.remember_created_at + end + + test "forget_me works when no remember token exists" do + user = users(:admin_user) + user.forget_me + + assert_nil user.remember_token + assert_nil user.remember_created_at + end + + # remember_token_expired? tests + test "remember_token_expired? returns true when remember_created_at is nil" do + user = users(:admin_user) + assert user.remember_token_expired? + end + + test "remember_token_expired? returns false for recent token" do + user = users(:admin_user) + user.remember_token = "token" + user.remember_created_at = 1.day.ago + user.save(validate: false) + + assert_not user.remember_token_expired? + end + + test "remember_token_expired? returns true for expired token" do + user = users(:admin_user) + user.remember_token = "token" + user.remember_created_at = 3.weeks.ago + user.save(validate: false) + + assert user.remember_token_expired? + end + + test "remember_token_expired? boundary at 2 weeks" do + user = users(:admin_user) + user.remember_token = "token" + user.remember_created_at = 13.days.ago + user.save(validate: false) + + assert_not user.remember_token_expired? + end + + # find_by_valid_remember_token tests + test "find_by_valid_remember_token finds user with valid token" do + user = users(:admin_user) + token = user.remember_me + + found_user = User.find_by_valid_remember_token(token) + assert_equal user, found_user + end + + test "find_by_valid_remember_token returns nil for expired token" do + user = users(:admin_user) + user.remember_token = "expired_token" + user.remember_created_at = 3.weeks.ago + user.save(validate: false) + + found_user = User.find_by_valid_remember_token("expired_token") + assert_nil found_user + end + + test "find_by_valid_remember_token returns nil for non-existent token" do + found_user = User.find_by_valid_remember_token("nonexistent_token") + assert_nil found_user + end + + test "find_by_valid_remember_token returns nil when user has no remember_created_at" do + user = users(:admin_user) + user.remember_token = "token_without_timestamp" + user.remember_created_at = nil + user.save(validate: false) + + found_user = User.find_by_valid_remember_token("token_without_timestamp") + assert_nil found_user + end + + # Password authentication tests + test "authenticate returns user for correct password" do + user = User.create!(email: "test@example.com", password: "password123456") + assert_equal user, user.authenticate("password123456") + end + + test "authenticate returns false for incorrect password" do + user = User.create!(email: "test@example.com", password: "password123456") + assert_not user.authenticate("wrongpassword") + end + + # Email normalization tests + test "email uniqueness is case-insensitive" do + User.create!(email: "Test@Example.com", password: "password123456") + duplicate_user = User.new(email: "test@example.com", password: "password123456") + + # Note: This depends on database collation, but should be tested + assert_not duplicate_user.valid? + end + + # Constants tests + test "INVITATION_TOKEN_EXPIRY is 14 days" do + assert_equal 14.days, User::INVITATION_TOKEN_EXPIRY + end + + test "REMEMBER_TOKEN_EXPIRY is 2 weeks" do + assert_equal 2.weeks, User::REMEMBER_TOKEN_EXPIRY + end end diff --git a/test/system/admin_workflow_test.rb b/test/system/admin_workflow_test.rb new file mode 100644 index 0000000..54bbf50 --- /dev/null +++ b/test/system/admin_workflow_test.rb @@ -0,0 +1,299 @@ +require "application_system_test_case" + +class AdminWorkflowTest < ApplicationSystemTestCase + setup do + @admin = users(:admin_user) + @admin.update!(invitation_accepted_at: Time.current) + end + + test "admin can access dashboard" do + login_as(@admin) + + visit admin_root_path + + assert_text "Dashboard" + assert_text "Total Users" + assert_text "Total Entries" + end + + test "admin sees admin button in header" do + login_as(@admin) + visit root_path + + within "header" do + assert_link "Admin" + end + end + + test "admin can send invitation" do + login_as(@admin) + + visit admin_invitations_path + click_link "Send New Invitation" + + assert_current_path new_admin_invitation_path + + fill_in "Name", with: "New User" + fill_in "Email", with: "newuser@example.com" + select "Contributor", from: "Role" + + click_button "Send Invitation" + + assert_current_path admin_invitations_path + assert_text "Invitation sent" + assert_text "newuser@example.com" + end + + test "admin can view users list" do + login_as(@admin) + + visit admin_users_path + + assert_text "Users" + assert_selector "table" + assert_text @admin.email + end + + test "admin can edit user role" do + user = users(:contributor_user) + login_as(@admin) + + visit admin_users_path + within "#user_#{user.id}" do + click_link "Edit" + end + + assert_current_path edit_admin_user_path(user) + + select "Reviewer", from: "Role" + click_button "Update User" + + assert_current_path admin_users_path + assert_text "User updated" + + user.reload + assert_equal "reviewer", user.role + end + + test "admin can delete user" do + user = users(:contributor_user) + login_as(@admin) + + visit admin_users_path + + within "#user_#{user.id}" do + click_button "Delete" + end + + assert_current_path admin_users_path + assert_text "User deleted" + assert_no_text user.email + end + + test "admin can view entry requests" do + requested_entry = Entry.create!( + fi: "Requested Entry", + category: :word, + status: :requested, + requested_by: users(:contributor_user) + ) + + login_as(@admin) + + visit admin_requests_path + + assert_text "Requested Entry" + assert_link "View" + assert_link "Edit" + assert_button "Approve" + end + + test "admin can approve entry request" do + requester = users(:contributor_user) + requested_entry = Entry.create!( + fi: "Requested Entry", + category: :word, + status: :requested, + requested_by: requester + ) + + login_as(@admin) + + visit admin_requests_path + + within "#entry_#{requested_entry.id}" do + click_button "Approve" + end + + assert_current_path admin_requests_path + assert_text "approved" + + requested_entry.reload + assert_equal "approved", requested_entry.status + end + + test "admin can edit entry request before approval" do + requested_entry = Entry.create!( + fi: "Requested Entry", + category: :word, + status: :requested, + requested_by: users(:contributor_user) + ) + + login_as(@admin) + + visit edit_admin_request_path(requested_entry) + + fill_in "Finnish", with: "Edited Finnish" + fill_in "English", with: "Added English" + click_button "Save Changes" + + assert_current_path admin_requests_path + requested_entry.reload + assert_equal "Edited Finnish", requested_entry.fi + assert_equal "Added English", requested_entry.en + end + + test "admin can reject entry request" do + requested_entry = Entry.create!( + fi: "Requested Entry", + category: :word, + status: :requested, + requested_by: users(:contributor_user) + ) + + login_as(@admin) + + visit admin_requests_path + + within "#entry_#{requested_entry.id}" do + click_button "Reject" + end + + assert_current_path admin_requests_path + assert_text "rejected" + assert_nil Entry.find_by(id: requested_entry.id) + end + + test "admin can resend invitation" do + pending_user = users(:pending_invitation) + login_as(@admin) + + visit admin_invitations_path + + within "#invitation_#{pending_user.id}" do + click_button "Resend" + end + + assert_current_path admin_invitations_path + assert_text "Invitation resent" + end + + test "admin can cancel invitation" do + pending_user = users(:pending_invitation) + login_as(@admin) + + visit admin_invitations_path + + within "#invitation_#{pending_user.id}" do + click_button "Cancel" + end + + assert_current_path admin_invitations_path + assert_text "deleted" + assert_nil User.find_by(id: pending_user.id) + end + + test "admin sees request count badge" do + Entry.create!( + fi: "Requested Entry 1", + category: :word, + status: :requested, + requested_by: users(:contributor_user) + ) + Entry.create!( + fi: "Requested Entry 2", + category: :word, + status: :requested, + requested_by: users(:contributor_user) + ) + + login_as(@admin) + visit admin_root_path + + within "header" do + assert_text "2" + end + end + + test "admin navigation is responsive" do + login_as(@admin) + visit admin_root_path + + # Test mobile menu if visible + if page.has_selector?("#admin-mobile-menu-button", visible: true) + click_button id: "admin-mobile-menu-button" + assert_selector "#admin-mobile-menu", visible: true + assert_link "Dashboard" + assert_link "Users" + assert_link "Invitations" + end + end + + test "admin cannot delete themselves" do + login_as(@admin) + + visit admin_users_path + + within "#user_#{@admin.id}" do + click_button "Delete" + end + + assert_text "cannot delete yourself" + assert User.exists?(@admin.id) + end + + test "admin can view dashboard statistics" do + login_as(@admin) + + visit admin_dashboard_path + + assert_text "Total Users" + assert_text "Total Entries" + assert_text "Pending Invitations" + assert_text "Entry Requests" + end + + test "admin can navigate between admin sections" do + login_as(@admin) + + visit admin_root_path + + click_link "Users" + assert_current_path admin_users_path + + click_link "Invitations" + assert_current_path admin_invitations_path + + click_link "Requests" + assert_current_path admin_requests_path + + click_link "Dashboard" + assert_current_path admin_dashboard_path + end + + test "admin can return to main site from admin" do + login_as(@admin) + + visit admin_root_path + + # On mobile, click hamburger menu first + if page.has_selector?("#admin-mobile-menu-button", visible: true) + click_button id: "admin-mobile-menu-button" + end + + click_link "Back to Site" + + assert_current_path root_path + end +end diff --git a/test/system/contributor_workflow_test.rb b/test/system/contributor_workflow_test.rb new file mode 100644 index 0000000..c7a99e4 --- /dev/null +++ b/test/system/contributor_workflow_test.rb @@ -0,0 +1,185 @@ +require "application_system_test_case" + +class ContributorWorkflowTest < ApplicationSystemTestCase + setup do + @contributor = users(:contributor_user) + @contributor.update!(invitation_accepted_at: Time.current) + end + + test "contributor can sign in" do + visit login_path + + fill_in "Email", with: @contributor.email + fill_in "Password", with: "password123456" + click_button "Sign In" + + assert_text "Welcome back" + within "header" do + assert_text @contributor.name.split.first + end + end + + test "contributor can edit entry" do + entry = entries(:one) + login_as(@contributor) + + visit entry_path(entry) + click_link "Edit" + + assert_current_path edit_entry_path(entry) + + fill_in "Finnish", with: "Updated Finnish Text" + fill_in "English", with: "Updated English Text" + select "Phrase", from: "Category" + fill_in "Additional Notes", with: "Updated notes" + + click_button "Save Changes" + + assert_current_path entry_path(entry) + assert_text "Entry updated" + assert_text "Updated Finnish Text" + assert_text "Updated English Text" + end + + test "contributor can add comment to entry" do + entry = entries(:one) + login_as(@contributor) + + visit entry_path(entry) + + click_button "Add Comment" + + within "#comment_form_modal" do + select "Finnish (FI)", from: "Language" + fill_in "Comment", with: "This is my comment on the Finnish translation" + click_button "Submit" + end + + assert_text "This is my comment on the Finnish translation" + assert_text @contributor.name + end + + test "contributor sees signed in status in header" do + login_as(@contributor) + visit root_path + + within "header" do + assert_text @contributor.name.split.first + assert_button "Sign Out", visible: :all + end + end + + test "contributor can sign out" do + login_as(@contributor) + visit root_path + + # On mobile, click hamburger menu first + if page.has_selector?("#mobile-menu-button", visible: true) + click_button id: "mobile-menu-button" + end + + click_link "Sign Out" + + assert_current_path root_path + assert_text "You have been logged out" + within "header" do + assert_link "Sign In" + end + end + + test "contributor cannot access admin pages" do + login_as(@contributor) + + visit admin_root_path + + assert_current_path root_path + assert_text "administrator" + end + + test "contributor can request password reset" do + visit login_path + + click_link "Forgot password?" + + assert_current_path new_password_reset_path + + fill_in "Email", with: @contributor.email + click_button "Send Reset Instructions" + + assert_current_path login_path + assert_text "password reset instructions" + end + + test "contributor session persists across page loads" do + login_as(@contributor) + + visit root_path + assert_text @contributor.name.split.first + + visit entries_path + within "header" do + assert_text @contributor.name.split.first + end + end + + test "contributor can use remember me" do + visit login_path + + fill_in "Email", with: @contributor.email + fill_in "Password", with: "password123456" + check "Remember me for 2 weeks" + click_button "Sign In" + + assert_text "Welcome back" + end + + test "contributor sees validation errors when editing entry incorrectly" do + entry = entries(:one) + login_as(@contributor) + + visit edit_entry_path(entry) + + # Clear all translations (should fail validation) + fill_in "Finnish", with: "" + fill_in "English", with: "" + fill_in "Swedish", with: "" + fill_in "Norwegian", with: "" + fill_in "Russian", with: "" + fill_in "German", with: "" + + click_button "Save Changes" + + assert_text "At least one language translation is required" + end + + test "contributor can filter comments by language tab" do + entry = entries(:one) + login_as(@contributor) + + # Create comments in different languages + Comment.create!( + commentable: entry, + user: @contributor, + body: "Finnish comment", + language_code: "fi" + ) + Comment.create!( + commentable: entry, + user: @contributor, + body: "English comment", + language_code: "en" + ) + + visit entry_path(entry) + + click_link "Finnish" + assert_text "Finnish comment" + + click_link "English" + assert_text "English comment" + + click_link "All languages" + assert_text "Finnish comment" + assert_text "English comment" + end +end diff --git a/test/system/public_browsing_test.rb b/test/system/public_browsing_test.rb new file mode 100644 index 0000000..c10a516 --- /dev/null +++ b/test/system/public_browsing_test.rb @@ -0,0 +1,114 @@ +require "application_system_test_case" + +class PublicBrowsingTest < ApplicationSystemTestCase + test "visitor can browse entries without logging in" do + visit root_path + + assert_selector "h1", text: "Sanasto Wiki" + assert_selector ".entry-row", minimum: 1 + end + + test "visitor can search entries" do + entry = entries(:one) + visit root_path + + fill_in "q", with: entry.fi + click_button "Search" + + assert_text entry.fi + end + + test "visitor can filter by language" do + visit root_path + + click_button "Finnish" + + assert_current_path entries_path(language: "fi") + end + + test "visitor can filter by category" do + visit root_path + + select "Word", from: "category" + + assert_current_path entries_path(category: "word") + end + + test "visitor can view entry details" do + entry = entries(:one) + visit root_path + + click_link entry.fi + + assert_text entry.fi + assert_text entry.en if entry.en.present? + end + + test "visitor can browse alphabetically" do + visit root_path + + click_button "Finnish" + click_link "A" + + assert_current_path entries_path(language: "fi", starts_with: "a") + end + + test "visitor can download XLSX" do + visit root_path + + click_link "Download XLSX" + + assert_equal "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + page.response_headers["Content-Type"] + end + + test "visitor sees sign in link in header" do + visit root_path + + within "header" do + assert_link "Sign In" + end + end + + test "visitor can request new entry" do + visit root_path + + click_link "Request Entry" + + assert_current_path new_request_path + assert_field "Name" + assert_field "Email" + end + + test "search results show no results message when nothing matches" do + visit root_path + + fill_in "q", with: "nonexistentword12345xyz" + click_button "Search" + + assert_text "No entries matched your filters" + end + + test "visitor can paginate through results" do + # Create enough entries to require pagination + 26.times do |i| + Entry.create!( + fi: "Test Entry #{i}", + category: :word, + status: :active + ) + end + + visit root_path + + assert_selector ".entry-row", count: 25 + assert_link "2" + end + + test "visitor sees entry statistics" do + visit root_path + + assert_text "Total Entries" + assert_text "Verified" + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 615a5e7..f62a2bc 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -1,3 +1,6 @@ +require "simplecov" +SimpleCov.start "rails" + ENV["RAILS_ENV"] ||= "test" require_relative "../config/environment" require "rails/test_help"