96.99% test coverage

This commit is contained in:
2026-01-31 15:46:40 +01:00
parent 8ec8f15857
commit 4bc393887b
20 changed files with 2899 additions and 62 deletions
+15
View File
@@ -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
@@ -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
@@ -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
@@ -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))
+176
View File
@@ -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
@@ -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
@@ -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
+210
View File
@@ -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 /<span/, result
assert_match /class=/, result
assert_match /px-2 py-1/, result
assert_match /text-xs/, result
assert_match /font-semibold/, result
assert_match /rounded-full/, result
end
end
@@ -0,0 +1,317 @@
require "test_helper"
class AuthenticationFlowTest < ActionDispatch::IntegrationTest
setup do
@user = users(:admin_user)
@user.update!(invitation_accepted_at: Time.current)
Rails.cache.clear
end
test "user can sign in with valid credentials" do
get login_path
assert_response :success
post login_path, params: {
email: @user.email,
password: "password123456"
}
assert_redirected_to admin_root_path
follow_redirect!
assert_response :success
assert_equal @user.id, session[:user_id]
end
test "user cannot sign in with invalid password" do
post login_path, params: {
email: @user.email,
password: "wrongpassword"
}
assert_response :unprocessable_entity
assert_nil session[:user_id]
assert_select "div[role='alert']", text: /invalid email or password/i
end
test "user cannot sign in with non-existent email" do
post login_path, params: {
email: "nonexistent@example.com",
password: "password123456"
}
assert_response :unprocessable_entity
assert_nil session[:user_id]
end
test "pending user cannot sign in" do
pending_user = users(:pending_invitation)
post login_path, params: {
email: pending_user.email,
password: "password123456"
}
assert_response :unprocessable_entity
assert_nil session[:user_id]
assert_select "div[role='alert']", text: /pending/i
end
test "user can sign out" do
# Sign in first
post login_path, params: {
email: @user.email,
password: "password123456"
}
assert_equal @user.id, session[:user_id]
# Sign out
delete logout_path
assert_redirected_to root_path
assert_nil session[:user_id]
end
test "session persists across requests" do
post login_path, params: {
email: @user.email,
password: "password123456"
}
assert_equal @user.id, session[:user_id]
get root_path
assert_equal @user.id, session[:user_id]
get entries_path
assert_equal @user.id, session[:user_id]
end
test "remember me creates cookie" do
post login_path, params: {
email: @user.email,
password: "password123456",
remember_me: "1"
}
assert_not_nil cookies[:remember_token]
@user.reload
assert_not_nil @user.remember_token
assert_not_nil @user.remember_created_at
end
test "remember me cookie logs user in automatically" do
post login_path, params: {
email: @user.email,
password: "password123456",
remember_me: "1"
}
remember_cookie = cookies[:remember_token]
assert_not_nil remember_cookie
# Clear session
reset!
# Make request with remember cookie
cookies[:remember_token] = remember_cookie
get root_path
assert_equal @user.id, session[:user_id]
end
test "logout clears remember me cookie" do
# Sign in with remember me
post login_path, params: {
email: @user.email,
password: "password123456",
remember_me: "1"
}
assert_not_nil cookies[:remember_token]
# Sign out
delete logout_path
remember_cookie = cookies[:remember_token]
assert remember_cookie.nil? || remember_cookie.empty?
@user.reload
assert_nil @user.remember_token
end
test "rate limiting prevents brute force" do
max_attempts = 5
responses = []
(max_attempts + 1).times do
post login_path, params: {
email: @user.email,
password: "wrongpassword"
}
responses << response.status
end
assert responses.first(max_attempts).all? { |status| status == 422 },
"Expected first #{max_attempts} responses to be 422, got #{responses.first(max_attempts)}"
if Rails.cache.is_a?(ActiveSupport::Cache::NullStore)
assert responses.last == 422,
"Expected last response to be 422 with null cache store, got #{responses.last}"
else
assert responses.last == 429, "Expected last response to be 429, got #{responses.last}"
end
assert_select "div[role='alert']", text: /too many/i
end
test "successful login resets rate limit" do
# Fail a few times
3.times do
post login_path, params: {
email: @user.email,
password: "wrongpassword"
}
end
# Then succeed
post login_path, params: {
email: @user.email,
password: "password123456"
}
assert_redirected_to admin_root_path
# Should be able to login again immediately
delete logout_path
post login_path, params: {
email: @user.email,
password: "password123456"
}
assert_redirected_to admin_root_path
end
test "session timeout logs user out after inactivity" do
# Sign in
post login_path, params: {
email: @user.email,
password: "password123456"
}
assert_equal @user.id, session[:user_id]
# Simulate time passing
travel 4.days do
get root_path
assert_redirected_to login_path
assert_match /expired/i, flash[:alert]
assert_nil session[:user_id]
end
end
test "remember me prevents session timeout" do
# Sign in with remember me
post login_path, params: {
email: @user.email,
password: "password123456",
remember_me: "1"
}
remember_cookie = cookies[:remember_token]
# Simulate time passing (but within remember me period)
travel 5.days do
cookies[:remember_token] = remember_cookie
get root_path
assert_response :success
assert_equal @user.id, session[:user_id]
end
end
test "password reset flow completes successfully" do
# Request reset
post password_resets_path, params: { email: @user.email }
assert_redirected_to login_path
@user.reload
assert_not_nil @user.reset_password_token
# Visit reset form
get edit_password_reset_path(@user.reset_password_token)
assert_response :success
# Submit new password
patch password_reset_path(@user.reset_password_token), params: {
password: "newpassword12345",
password_confirmation: "newpassword12345"
}
assert_redirected_to root_path
assert_equal @user.id, session[:user_id]
@user.reload
assert @user.authenticate("newpassword12345")
assert_nil @user.reset_password_token
end
test "invitation acceptance flow" do
pending_user = users(:pending_invitation)
# Visit invitation
get invitation_path(pending_user.invitation_token)
assert_response :success
# Accept invitation
patch accept_invitation_path(pending_user.invitation_token), params: {
user: {
password: "newpassword12345",
password_confirmation: "newpassword12345"
}
}
assert_redirected_to root_path
assert_equal pending_user.id, session[:user_id]
pending_user.reload
assert_not_nil pending_user.invitation_accepted_at
assert pending_user.authenticate("newpassword12345")
end
test "expired invitation cannot be accepted" do
pending_user = users(:pending_invitation)
pending_user.update!(invitation_sent_at: 15.days.ago)
get invitation_path(pending_user.invitation_token)
assert_redirected_to root_path
assert_match /expired/i, flash[:alert]
end
test "admin user redirects to admin dashboard after login" do
post login_path, params: {
email: @user.email,
password: "password123456"
}
assert_redirected_to admin_root_path
end
test "contributor redirects to root after login" do
contributor = users(:contributor_user)
contributor.update!(invitation_accepted_at: Time.current)
post login_path, params: {
email: contributor.email,
password: "password123456"
}
assert_redirected_to root_path
end
test "already logged in user redirects from login page" do
# Sign in first
post login_path, params: {
email: @user.email,
password: "password123456"
}
# Try to visit login page again
get login_path
assert_redirected_to admin_root_path
end
end
+1 -1
View File
@@ -127,7 +127,7 @@ class EntryRequestFlowTest < ActionDispatch::IntegrationTest
assert_response :success
# Active entry should be counted
assert_match /#{Entry.active_entries.count}/, response.body
assert_select "div", text: "#{Entry.active_entries.count} entries"
# Verify counts exclude requested/approved entries
total_entries = Entry.count
+129
View File
@@ -0,0 +1,129 @@
require "test_helper"
require "benchmark"
class SearchPerformanceTest < ActionDispatch::IntegrationTest
setup do
# Create a substantial number of test entries for performance testing
@test_entries = []
50.times do |i|
@test_entries << Entry.create!(
fi: "Testi sana #{i}",
en: "Test word #{i}",
sv: "Test ord #{i}",
category: :word,
status: :active
)
end
end
teardown do
# Clean up test entries
Entry.where(id: @test_entries.map(&:id)).delete_all
end
test "full text search completes in reasonable time" do
measure_time("Full text search") do
get entries_path, params: { q: "test" }
assert_response :success
end
end
test "language-specific search is performant" do
measure_time("Language-specific search") do
get entries_path, params: { q: "test", language: "en" }
assert_response :success
end
end
test "alphabetical browsing is performant" do
measure_time("Alphabetical browsing") do
get entries_path, params: { language: "fi", starts_with: "t" }
assert_response :success
end
end
test "category filtering is performant" do
measure_time("Category filtering") do
get entries_path, params: { category: "word" }
assert_response :success
end
end
test "combined filters are performant" do
measure_time("Combined filters") do
get entries_path, params: { q: "test", language: "fi", category: "word" }
assert_response :success
end
end
test "pagination does not degrade performance" do
measure_time("Pagination") do
get entries_path, params: { page: 2 }
assert_response :success
end
end
test "entry show page loads quickly" do
entry = @test_entries.first
measure_time("Entry show page") do
get entry_path(entry)
assert_response :success
end
end
test "XLSX download handles large datasets" do
measure_time("XLSX download") do
get download_entries_path(format: :xlsx)
assert_response :success
end
end
test "statistics calculation is performant" do
measure_time("Statistics calculation") do
get entries_path
assert_response :success
assert_select "div", text: /entries/i
assert_select "div", text: /% complete/i
end
end
test "search with no results is fast" do
measure_time("No results search") do
get entries_path, params: { q: "nonexistentword12345xyz" }
assert_response :success
end
end
test "multiple sequential searches maintain performance" do
searches = [ "test", "sana", "word", "ord" ]
total_time = Benchmark.measure do
searches.each do |query|
get entries_path, params: { q: query }
assert_response :success
end
end
assert total_time.real < 2.0, "Multiple searches took too long: #{total_time.real}s"
end
test "turbo stream responses are performant" do
measure_time("Turbo stream response") do
get entries_path, as: :turbo_stream, params: { q: "test" }
assert_response :success
end
end
private
def measure_time(description, max_time_ms: 500)
time = Benchmark.measure do
yield
end
time_ms = (time.real * 1000).round(2)
assert time_ms < max_time_ms,
"#{description} took too long: #{time_ms}ms (max: #{max_time_ms}ms)"
end
end
@@ -0,0 +1,23 @@
require "test_helper"
class PasswordResetMailerTest < ActionMailer::TestCase
test "reset email contains token and expiry" do
user = users(:admin_user)
user.update!(
reset_password_token: "reset_token_123",
reset_password_sent_at: Time.current
)
email = PasswordResetMailer.reset(user)
assert_emails 1 do
email.deliver_now
end
assert_equal [ user.email ], email.to
assert_equal "Reset your Sanasto Wiki password", email.subject
assert_includes email.content_type, "text/html"
assert_includes email.body.encoded, "reset_token_123"
assert_includes email.body.encoded, "Password Reset Request"
assert_includes email.body.encoded, "will expire on"
end
end
+418
View File
@@ -37,4 +37,422 @@ class UserTest < ActiveSupport::TestCase
assert_not user.valid?
assert_includes user.errors[:password], "is too short (minimum is 12 characters)"
end
# Association tests
test "can have invited_by association" do
inviter = users(:admin_user)
user = User.create!(email: "invited@example.com", password: "password123456", invited_by: inviter)
assert_equal inviter, user.invited_by
end
test "can have invited_users" do
inviter = users(:admin_user)
user = User.create!(email: "invited@example.com", password: "password123456", invited_by: inviter)
assert_includes inviter.invited_users, user
end
test "has many created_entries" do
user = users(:admin_user)
assert_respond_to user, :created_entries
end
test "has many updated_entries" do
user = users(:admin_user)
assert_respond_to user, :updated_entries
end
test "has many requested_entries" do
user = users(:admin_user)
assert_respond_to user, :requested_entries
end
test "has many submitted_suggested_meanings" do
user = users(:admin_user)
assert_respond_to user, :submitted_suggested_meanings
end
test "has many reviewed_suggested_meanings" do
user = users(:admin_user)
assert_respond_to user, :reviewed_suggested_meanings
end
test "has many comments" do
user = users(:admin_user)
assert_respond_to user, :comments
end
# Scope tests
test "by_role scope filters contributors" do
contributor_user = users(:contributor_user)
results = User.by_role(:contributor)
assert_includes results, contributor_user
end
test "by_role scope filters reviewers" do
reviewer_user = users(:reviewer_user)
results = User.by_role(:reviewer)
assert_includes results, reviewer_user
end
test "by_role scope filters admins" do
admin_user = users(:admin_user)
results = User.by_role(:admin)
assert_includes results, admin_user
end
test "by_role scope returns all when role is blank" do
results = User.by_role(nil)
assert_equal User.all, results
end
test "search_email scope finds users by email" do
admin_user = users(:admin_user)
results = User.search_email("admin")
assert_includes results, admin_user
end
test "search_email scope returns all when query is blank" do
results = User.search_email(nil)
assert_equal User.all, results
end
test "search_email scope finds partial matches" do
admin_user = users(:admin_user)
results = User.search_email("exam")
assert_includes results, admin_user
end
# invitation_expired? tests
test "invitation_expired? returns false when invitation_sent_at is nil" do
user = User.new(email: "test@example.com", password: "password123456")
assert_not user.invitation_expired?
end
test "invitation_expired? returns false for recent invitation" do
user = User.new(
email: "test@example.com",
password: "password123456",
invitation_sent_at: 1.day.ago
)
assert_not user.invitation_expired?
end
test "invitation_expired? returns true for expired invitation" do
user = User.new(
email: "test@example.com",
password: "password123456",
invitation_sent_at: 15.days.ago
)
assert user.invitation_expired?
end
test "invitation_expired? returns false exactly at expiry boundary" do
user = User.new(
email: "test@example.com",
password: "password123456",
invitation_sent_at: 13.days.ago
)
assert_not user.invitation_expired?
end
# invitation_pending? tests
test "invitation_pending? returns true for valid pending invitation" do
user = User.new(
email: "test@example.com",
password: "password123456",
invitation_token: "valid_token",
invitation_sent_at: 1.day.ago,
invitation_accepted_at: nil
)
assert user.invitation_pending?
end
test "invitation_pending? returns false when invitation is accepted" do
user = User.new(
email: "test@example.com",
password: "password123456",
invitation_token: "valid_token",
invitation_sent_at: 1.day.ago,
invitation_accepted_at: Time.current
)
assert_not user.invitation_pending?
end
test "invitation_pending? returns false when invitation is expired" do
user = User.new(
email: "test@example.com",
password: "password123456",
invitation_token: "valid_token",
invitation_sent_at: 15.days.ago,
invitation_accepted_at: nil
)
assert_not user.invitation_pending?
end
test "invitation_pending? returns false when no invitation token" do
user = User.new(
email: "test@example.com",
password: "password123456",
invitation_token: nil,
invitation_sent_at: 1.day.ago,
invitation_accepted_at: nil
)
assert_not user.invitation_pending?
end
# invite_by tests
test "invite_by sets invited_by and generates token" do
inviter = users(:admin_user)
user = User.new(email: "test@example.com", password: "password123456")
user.invite_by(inviter)
assert_equal inviter, user.invited_by
assert_not_nil user.invitation_token
assert_not_nil user.invitation_sent_at
end
test "invite_by does not override existing invited_by" do
original_inviter = users(:admin_user)
new_inviter = users(:reviewer_user)
user = User.new(email: "test@example.com", password: "password123456", invited_by: original_inviter)
user.invite_by(new_inviter)
assert_equal original_inviter, user.invited_by
end
test "invite_by handles nil invitee" do
user = User.new(email: "test@example.com", password: "password123456")
user.invite_by(nil)
assert_nil user.invited_by
assert_not_nil user.invitation_token
assert_not_nil user.invitation_sent_at
end
test "invite_by generates 32-character token" do
inviter = users(:admin_user)
user = User.new(email: "test@example.com", password: "password123456")
user.invite_by(inviter)
assert user.invitation_token.length >= 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
+299
View File
@@ -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
+185
View File
@@ -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
+114
View File
@@ -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
+3
View File
@@ -1,3 +1,6 @@
require "simplecov"
SimpleCov.start "rails"
ENV["RAILS_ENV"] ||= "test"
require_relative "../config/environment"
require "rails/test_help"