Compare commits

..

10 Commits

Author SHA1 Message Date
Runar Ingebrigtsen
a79b27020a edit category
CI / scan_ruby (push) Failing after 37s
CI / scan_js (push) Failing after 7s
CI / lint (push) Failing after 42s
CI / test (push) Failing after 7s
CI / system-test (push) Failing after 18s
2026-01-23 22:10:51 +01:00
Runar Ingebrigtsen
9a814f1aa1 add comments on entries 2026-01-23 21:55:54 +01:00
Runar Ingebrigtsen
b3726e0777 DRY supported_languages 2026-01-23 21:55:06 +01:00
Runar Ingebrigtsen
a7713b962f add todo 2026-01-23 21:52:45 +01:00
Runar Ingebrigtsen
4fdebc8bf8 where's my schema 2026-01-23 14:01:47 +01:00
Runar Ingebrigtsen
faf87fe44f remove versioning 2026-01-23 14:00:18 +01:00
Runar Ingebrigtsen
396e649960 invitation emails 2026-01-23 13:49:56 +01:00
Runar Ingebrigtsen
35c29749fb add controller tests 2026-01-23 12:20:31 +01:00
Runar Ingebrigtsen
dea0ef508a switch install state to db 2026-01-23 12:20:13 +01:00
Runar Ingebrigtsen
965e8cdffe less spacing in filters interface 2026-01-23 10:19:18 +01:00
56 changed files with 1504 additions and 274 deletions
+2 -106
View File
@@ -53,7 +53,7 @@ When translators disagree on a translation or want to suggest alternatives (regi
## Setup / First User
When the file `.installed` is missing, the `/setup` route is accessible for creating the initial admin account. The first user created will be the system's default contact email (accessible via `User.first.email`).
When setup has not been completed, the `/setup` route is accessible for creating the initial admin account. Completion is tracked in the database. The first user created will be the system's default contact email (accessible via `User.first.email`).
For detailed setup instructions, see [SETUP_GUIDE.md](docs/SETUP_GUIDE.md).
@@ -94,112 +94,8 @@ For detailed setup instructions, see [SETUP_GUIDE.md](docs/SETUP_GUIDE.md).
---
## Database Schema
```
# db/schema.rb
ActiveRecord::Schema[8.0].define(version: 2025_01_22_100000) do
create_table "entries", force: :cascade do |t|
t.integer "category", null: false # word, phrase, proper_name, title, reference, other
# Language columns
t.string "fi" # Finnish
t.string "en" # English
t.string "sv" # Swedish
t.string "no" # Norwegian
t.string "ru" # Russian
t.string "de" # German
t.text "notes"
t.boolean "verified", default: false
t.integer "created_by_id"
t.integer "updated_by_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["category"], name: "index_entries_on_category"
end
create_table "suggested_meanings", force: :cascade do |t|
t.integer "entry_id", null: false
t.string "language_code", null: false
t.string "alternative_translation", null: false
t.text "context"
t.text "reasoning"
t.string "source"
t.string "region"
t.integer "status", default: 0 # pending, accepted, rejected
t.integer "submitted_by_id"
t.integer "reviewed_by_id"
t.datetime "reviewed_at"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["entry_id"], name: "index_suggested_meanings_on_entry_id"
t.index ["language_code"], name: "index_suggested_meanings_on_language_code"
t.index ["status"], name: "index_suggested_meanings_on_status"
end
create_table "comments", force: :cascade do |t|
t.integer "user_id", null: false
t.string "commentable_type", null: false
t.integer "commentable_id", null: false
t.text "body", null: false
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["commentable_type", "commentable_id"], name: "index_comments_on_commentable"
end
create_table "users", force: :cascade do |t|
t.string "email", null: false
t.string "password_digest", null: false
t.string "name"
t.integer "role", default: 0 # contributor, reviewer, admin
t.string "primary_language"
t.string "invitation_token"
t.datetime "invitation_sent_at"
t.datetime "invitation_accepted_at"
t.integer "invited_by_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["email"], name: "index_users_on_email", unique: true
t.index ["invitation_token"], name: "index_users_on_invitation_token", unique: true
end
create_table "entry_versions", force: :cascade do |t|
t.integer "entry_id", null: false
t.integer "user_id", null: false
t.json "changes_made", null: false
t.string "change_type" # create, update, verify
t.datetime "created_at", null: false
t.index ["entry_id"], name: "index_entry_versions_on_entry_id"
end
create_table "supported_languages", force: :cascade do |t|
t.string "code", null: false
t.string "name", null: false
t.string "native_name", null: false
t.integer "sort_order", default: 0
t.boolean "active", default: true
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["code"], name: "index_supported_languages_on_code", unique: true
end
add_foreign_key "suggested_meanings", "entries"
add_foreign_key "suggested_meanings", "supported_languages", column: "language_code", primary_key: "code"
add_foreign_key "suggested_meanings", "users", column: "submitted_by_id"
add_foreign_key "suggested_meanings", "users", column: "reviewed_by_id"
add_foreign_key "comments", "users"
add_foreign_key "entries", "users", column: "created_by_id"
add_foreign_key "entries", "users", column: "updated_by_id"
add_foreign_key "entry_versions", "entries"
add_foreign_key "entry_versions", "users"
end
```
see `db/structure.sql`
---
+53
View File
@@ -0,0 +1,53 @@
# Sanasto Wiki TODO List
This document outlines planned improvements, bug fixes, and new features for the Sanasto Wiki application.
---
## High Priority
### Bugs
- [x] **Search input loses focus on filter change**: This issue has been resolved. The search input now retains focus when filters are applied.
- [x] **Mismatched `enum` syntax in models**: This issue has been resolved by correcting the `enum` declarations in `SuggestedMeaning.rb` and `User.rb` to use the updated Rails 8 syntax. All tests now pass.
- [ ] **[BUG] Mobile browser access is blocked by `:modern` browser requirement in `ApplicationController`**: This issue has been resolved by removing the `allow_browser versions: :modern` line from `ApplicationController`.
### Improvements
- [x] **Replace hardcoded `LANGUAGE_COLUMNS` with dynamic query**: The `Entry` model now dynamically fetches language codes via `SupportedLanguage.valid_codes` and caches them, removing the hardcoded array. This task is completed.
---
## Medium Priority
### New Features
- [ ] **Add user authentication:** The application currently lacks user authentication, which is a critical security vulnerability. Implementing a robust authentication system will protect sensitive data and ensure only authorized users can make changes.
- [ ] **Implement user roles and permissions:** The `README.md` defines user roles (contributor, reviewer, admin), but the application does not yet enforce these roles. Implementing a permissions system will ensure that users can only perform actions appropriate for their role.
- [ ] **Add create, edit, update, and destroy actions to `EntriesController`:** The `EntriesController` currently lacks the full set of CRUD actions needed for managing entries.
- [ ] **Add views for creating and editing entries:** Corresponding views for entry creation and editing are missing.
- [ ] **Add pages for user profiles, admin dashboard, and suggested meanings queue:** Essential UI components for user management and content review are absent.
### Discussion
- [x] **Add comments to entries**: Users can now add comments to entries.
- [x] **Submit alternative translations as suggested meanings**: This is part of the comments and discussion feature, and the infrastructure for this is in place. Need to verify that the suggested meaning model is used to actually submit alternative translations.
- [x] **Participate in translation discussions**: The comments section provides the foundation for this. Additional features might be needed for a full discussion.
- [ ] **Plan for user profile based notification exception**: Implement logic to allow users to opt out of notifications for specific language changes or comments on their profile.
### Refactoring
- [x] **Improve fixture quality**: The test fixtures have been refactored to resolve conflicts and foreign key violations, ensuring tests pass reliably. This task is completed.
---
## Low Priority
### New Features
- [x] **Add a download button for entries**: This feature has been implemented in the `EntriesController#download` action and is accessible from the UI. This task is completed.
### Improvements
- [ ] **Enhance UI/UX:** While functional, the user interface could be improved to be more intuitive and visually appealing. A design review and subsequent enhancements would improve the overall user experience.
- [ ] **Add tests for controllers and views:** The current test suite only covers the models. To ensure the reliability of the application, tests for the controllers and views should also be added.
@@ -22,8 +22,7 @@ class Admin::DashboardController < Admin::BaseController
.where("invitation_sent_at > ?", 14.days.ago)
.count
@supported_languages = SupportedLanguage.where(active: true).order(:sort_order)
@language_completion = @supported_languages.index_with do |language|
@language_completion = supported_languages.index_with do |language|
next 0 if @entry_count.zero?
(Entry.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round
@@ -20,8 +20,8 @@ class Admin::InvitationsController < Admin::BaseController
@invitation.password = SecureRandom.urlsafe_base64(16)
if @invitation.save
# TODO: Send invitation email
# InvitationMailer.invite(@invitation).deliver_later
# Send invitation email
InvitationMailer.invite(@invitation).deliver_later
redirect_to admin_invitations_path, notice: "Invitation sent to #{@invitation.email}"
else
+7 -3
View File
@@ -1,12 +1,16 @@
class ApplicationController < ActionController::Base
# Changes to the importmap will invalidate the etag for HTML responses
stale_when_importmap_changes
helper_method :current_user, :logged_in?, :admin?, :reviewer_or_admin?, :contributor_or_above?, :setup_completed?
helper_method :supported_languages, :current_user, :logged_in?, :admin?, :reviewer_or_admin?,
:contributor_or_above?, :setup_completed?
private
def supported_languages
@supported_languages ||= SupportedLanguage.where(active: true).order(:sort_order, :name)
end
def current_user
@current_user ||= User.find_by(id: session[:user_id]) if session[:user_id]
end
@@ -57,6 +61,6 @@ class ApplicationController < ActionController::Base
end
def setup_completed?
File.exist?(Rails.root.join(".installed"))
SetupState.installed?
end
end
+31
View File
@@ -0,0 +1,31 @@
class CommentsController < ApplicationController
before_action :require_login
before_action :set_commentable
def create
@comment = @commentable.comments.build(comment_params)
@comment.user = current_user
if @comment.save
respond_to do |format|
format.turbo_stream
format.html { redirect_to @commentable }
end
else
# Handle validation errors
redirect_to @commentable, alert: "Comment could not be created: #{@comment.errors.full_messages.to_sentence}"
end
end
private
def set_commentable
if params[:entry_id]
@commentable = Entry.find(params[:entry_id])
end
end
def comment_params
params.require(:comment).permit(:body, :language_code)
end
end
+20 -7
View File
@@ -1,8 +1,7 @@
class EntriesController < ApplicationController
before_action :set_entry, only: [ :show ]
before_action :set_entry, only: [ :show, :edit, :update ]
def index
@supported_languages = SupportedLanguage.where(active: true).order(:sort_order, :name)
@language_code = params[:language].presence
@category = params[:category].presence
@query = params[:q].to_s.strip
@@ -24,21 +23,21 @@ class EntriesController < ApplicationController
@entry_count = Entry.count
@verified_count = Entry.where(verified: true).count
@needs_review_count = @entry_count - @verified_count
@complete_entries_count = @supported_languages.reduce(Entry.all) do |scope, language|
@complete_entries_count = supported_languages.reduce(Entry.all) do |scope, language|
scope.where.not(language.code => [ nil, "" ])
end.count
@missing_entries_count = @entry_count - @complete_entries_count
@language_completion = @supported_languages.index_with do |language|
@language_completion = supported_languages.index_with do |language|
next 0 if @entry_count.zero?
(Entry.where.not(language.code => [ nil, "" ]).count * 100.0 / @entry_count).round
end
if @language_code.present?
primary_language, other_languages = @supported_languages.partition { |language| language.code == @language_code }
primary_language, other_languages = supported_languages.partition { |language| language.code == @language_code }
@display_languages = primary_language + other_languages
else
@display_languages = @supported_languages
@display_languages = supported_languages
end
respond_to do |format|
@@ -48,7 +47,17 @@ class EntriesController < ApplicationController
end
def show
@supported_languages = SupportedLanguage.where(active: true).order(:sort_order, :name)
end
def edit
end
def update
if @entry.update(entry_params)
redirect_to entry_path(@entry), notice: "Entry updated."
else
render :edit, status: :unprocessable_entity
end
end
def download
@@ -66,4 +75,8 @@ class EntriesController < ApplicationController
def set_entry
@entry = Entry.find(params[:id])
end
def entry_params
params.require(:entry).permit(:category)
end
end
+36
View File
@@ -0,0 +1,36 @@
class InvitationsController < ApplicationController
def show
@user = User.find_by_valid_invitation_token(params[:token])
if @user.nil?
redirect_to root_path, alert: "Invalid or expired invitation link."
end
end
def update
@user = User.find_by_valid_invitation_token(params[:token])
if @user.nil?
redirect_to root_path, alert: "Invalid or expired invitation link."
return
end
if @user.update(invitation_params)
@user.update(
invitation_accepted_at: Time.current,
invitation_token: nil
)
session[:user_id] = @user.id
redirect_to admin? ? admin_root_path : root_path, notice: "Welcome to Sanasto Wiki, #{@user.name}!"
else
render :show, status: :unprocessable_entity
end
end
private
def invitation_params
params.require(:user).permit(:password, :password_confirmation)
end
end
+2 -10
View File
@@ -11,7 +11,7 @@ class SetupController < ApplicationController
@user.invitation_accepted_at = Time.current
if @user.save
create_installed_marker
SetupState.mark_installed!
session[:user_id] = @user.id
redirect_to admin_root_path, notice: "Setup complete! Welcome to Sanasto Wiki."
else
@@ -28,15 +28,7 @@ class SetupController < ApplicationController
end
def setup_completed?
File.exist?(installed_marker_path)
end
def installed_marker_path
Rails.root.join(".installed")
end
def create_installed_marker
FileUtils.touch(installed_marker_path)
SetupState.installed?
end
def user_params
+3
View File
@@ -1,2 +1,5 @@
module ApplicationHelper
def language_name(code)
supported_languages.find { |l| l.code == code }&.name
end
end
@@ -0,0 +1,25 @@
import { Controller } from "@hotwired/stimulus"
export default class extends Controller {
static targets = ["modal", "button"]
connect() {
this.modalTarget.classList.add("hidden")
this.buttonTarget.classList.remove("hidden")
}
open(event) {
event.preventDefault()
this.modalTarget.classList.remove("hidden")
}
close(event) {
if (event.target === this.modalTarget) {
this.modalTarget.classList.add("hidden")
}
}
closeWithButton() {
this.modalTarget.classList.add("hidden")
}
}
+12
View File
@@ -0,0 +1,12 @@
class InvitationMailer < ApplicationMailer
def invite(user)
@user = user
@invitation_url = invitation_url(@user.invitation_token)
@expires_at = @user.invitation_sent_at + User::INVITATION_TOKEN_EXPIRY
mail(
to: @user.email,
subject: "You've been invited to join Sanasto Wiki"
)
end
end
+17
View File
@@ -1,6 +1,23 @@
class Comment < ApplicationRecord
belongs_to :user
belongs_to :commentable, polymorphic: true
belongs_to :language,
class_name: "SupportedLanguage",
foreign_key: :language_code,
primary_key: :code,
optional: true
validates :body, presence: true
after_create_commit :notify_users
private
def notify_users
return if language_code.blank?
# Placeholder for notification logic once we decide delivery channels.
users_to_notify = User.where(primary_language: language_code).where.not(id: user_id)
# puts "Notifying users: #{users_to_notify.pluck(:email).join(", ")}"
end
end
-1
View File
@@ -4,7 +4,6 @@ class Entry < ApplicationRecord
has_many :suggested_meanings, dependent: :destroy
has_many :comments, as: :commentable, dependent: :destroy
has_many :entry_versions, dependent: :destroy
enum :category, %i[word phrase proper_name title reference other]
-6
View File
@@ -1,6 +0,0 @@
class EntryVersion < ApplicationRecord
belongs_to :entry
belongs_to :user
validates :changes_made, presence: true
end
+16
View File
@@ -0,0 +1,16 @@
class SetupState < ApplicationRecord
def self.installed?
first&.installed? || false
end
def self.mark_installed!
record = first_or_initialize
record.installed = true
record.installed_at ||= Time.current
record.save!
end
def self.reset!
delete_all
end
end
+19 -1
View File
@@ -14,11 +14,29 @@ class User < ApplicationRecord
class_name: "SuggestedMeaning",
foreign_key: :reviewed_by_id,
dependent: :nullify
has_many :entry_versions, dependent: :nullify
has_many :comments, dependent: :nullify
enum :role, %i[contributor reviewer admin]
validates :email, presence: true, uniqueness: true
validates :password, length: { minimum: 12 }, if: -> { password.present? }
# Invitation token expires after 14 days
INVITATION_TOKEN_EXPIRY = 14.days
def invitation_expired?
return false if invitation_sent_at.nil?
invitation_sent_at < INVITATION_TOKEN_EXPIRY.ago
end
def invitation_pending?
invitation_token.present? && invitation_accepted_at.nil? && !invitation_expired?
end
def self.find_by_valid_invitation_token(token)
where(invitation_token: token)
.where(invitation_accepted_at: nil)
.where("invitation_sent_at > ?", INVITATION_TOKEN_EXPIRY.ago)
.first
end
end
+1 -1
View File
@@ -115,7 +115,7 @@
<h3 class="text-lg leading-6 font-medium text-gray-900">Language Completion</h3>
<div class="mt-5">
<div class="space-y-4">
<% @supported_languages.each do |language| %>
<% supported_languages.each do |language| %>
<div>
<div class="flex items-center justify-between">
<span class="text-sm font-medium text-gray-700"><%= language.native_name %></span>
@@ -0,0 +1,13 @@
<%= turbo_stream.append "comments-#{@comment.language_code.presence || 'all'}" do %>
<%= render "entries/comment", comment: @comment %>
<% end %>
<% if @comment.language_code.present? %>
<%= turbo_stream.replace "comment-details-#{@comment.language_code}" do %>
<%= render "entries/language_comment_details", entry: @commentable, language_code: @comment.language_code %>
<% end %>
<% end %>
<%= turbo_stream.replace "comment_tabs" do %>
<%= render "entries/comment_tabs", entry: @commentable %>
<% end %>
+24
View File
@@ -0,0 +1,24 @@
<div class="flex items-start space-x-4">
<div class="flex-shrink-0">
<%# Add user avatars here if you have them %>
<div class="h-10 w-10 rounded-full bg-slate-200 flex items-center justify-center text-slate-600 font-bold">
<%= comment.user&.name&.first || 'A' %>
</div>
</div>
<div class="flex-1">
<div class="bg-slate-100 rounded-lg p-3">
<div class="flex items-center justify-between">
<p class="text-sm text-slate-900">
<span class="font-semibold "><%= comment.user&.name || "Anonymous" %></span>
<% unless comment.language_code.blank? %>
<span class="italic">on the <%= language_name(comment.language_code) %> translation</span>
<% end -%>
</p>
<p class="text-xs text-slate-500">
<%= comment.created_at ? "#{time_ago_in_words(comment.created_at)} ago" : "just now" %>
</p>
</div>
<p class="text-sm text-slate-800 mt-1"><%= comment.body %></p>
</div>
</div>
</div>
+12
View File
@@ -0,0 +1,12 @@
<%= form_with(model: [entry, Comment.new(commentable: entry)],
data: { turbo_stream: true },
html: { class: "space-y-4" }) do |form| %>
<%= form.hidden_field :language_code, value: (local_assigns[:language_code].presence || nil) %>
<div>
<%= form.label :body, "Comment", class: "sr-only" %>
<%= form.text_area :body, rows: 4, class: "block w-full border-slate-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm", placeholder: "Add your comment..." %>
</div>
<div class="flex justify-end">
<%= form.submit "Submit", class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
</div>
<% end %>
+30
View File
@@ -0,0 +1,30 @@
<div id="comment_tabs" class="border-b border-slate-200">
<nav class="-mb-px flex space-x-6" aria-label="Tabs">
<% grouped_comments = entry.comments.group_by(&:language) %>
<% language_groups = supported_languages.map { |language| [language, grouped_comments[language] || []] } %>
<% language_groups.unshift([:all, entry.comments]) %>
<% language_groups.each do |language, comments| %>
<% language_label = language == :all ? "All languages" : language&.name %>
<% language_code = language == :all ? "all" : language&.code %>
<a href="#"
class="comment-tab border-transparent text-slate-500 hover:text-slate-700 hover:border-slate-300 whitespace-nowrap py-4 px-1 border-b-2 font-medium text-sm"
data-lang="<%= language_code %>">
<%= language_label %> <span class="bg-slate-100 text-slate-600 ml-2 py-0.5 px-2.5 rounded-full text-xs font-medium"><%= comments.count %></span>
</a>
<% end %>
</nav>
</div>
<div class="mt-4">
<% if entry.comments.empty? %>
<p class="text-slate-500">No comments yet. Be the first to add one!</p>
<% end %>
<% language_groups.each do |language, comments| %>
<% language_code = language == :all ? "all" : language&.code %>
<div id="comments-<%= language_code %>" class="comment-group space-y-4 hidden">
<% comments.each do |comment| %>
<%= render "entries/comment", comment: comment %>
<% end %>
</div>
<% end %>
</div>
@@ -0,0 +1,57 @@
<% if current_user %>
<div class="mt-8" data-controller="comments">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold text-slate-900">Discussion</h3>
<button id="add_comment_button" data-action="click->comments#open" data-comments-target="button" class="bg-indigo-600 text-white px-4 py-2 rounded-full shadow hover:bg-indigo-700 transition text-sm font-semibold">
Add Comment
</button>
</div>
<%= render "entries/comment_tabs", entry: entry %>
<div id="comment_form_modal" data-comments-target="modal" data-action="click->comments#close" class="hidden fixed inset-0 bg-slate-900 bg-opacity-50 z-40 flex items-center justify-center">
<div class="bg-white rounded-lg shadow-xl p-6 w-full max-w-lg">
<div class="flex justify-between items-center">
<h4 class="text-lg font-bold">Add a Comment</h4>
<button id="close_comment_form" data-action="click->comments#closeWithButton" class="text-slate-500 hover:text-slate-700 text-2xl leading-none">
&times;
</button>
</div>
<%= render "entries/comment_form", entry: entry %>
</div>
</div>
</div>
<script>
function activateCommentTab(targetLanguageCode) {
const commentGroups = document.querySelectorAll(".comment-group");
commentGroups.forEach((group) => {
const isTarget = group.id === `comments-${targetLanguageCode}`;
group.classList.toggle("hidden", !isTarget);
});
const tabs = document.querySelectorAll(".comment-tab");
tabs.forEach((tab) => {
const isActive = tab.dataset.lang === targetLanguageCode;
tab.classList.toggle("text-slate-900", isActive);
tab.classList.toggle("border-indigo-600", isActive);
tab.classList.toggle("text-slate-500", !isActive);
tab.classList.toggle("border-transparent", !isActive);
});
}
document.addEventListener("click", (event) => {
const tab = event.target.closest(".comment-tab");
if (!tab) {
return;
}
event.preventDefault();
activateCommentTab(tab.dataset.lang);
});
document.addEventListener("turbo:load", () => {
activateCommentTab("all");
});
</script>
<% end %>
+10 -10
View File
@@ -1,43 +1,43 @@
<div class="flex flex-wrap gap-2">
<div class="flex flex-wrap gap-1">
<% base_params = { q: @query, category: @category.presence, language: @language_code.presence, starts_with: @starts_with.presence }.compact %>
<% all_category_params = base_params.except(:category) %>
<%= link_to "All",
entries_path(all_category_params),
class: "px-4 py-1.5 rounded-full #{@category.blank? ? 'bg-indigo-100 text-indigo-700' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'} text-xs font-bold uppercase tracking-wider",
class: "px-3 py-1 rounded-full #{@category.blank? ? 'bg-indigo-100 text-indigo-700' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'} text-xs font-bold uppercase tracking-wider",
data: { turbo_stream: true } %>
<% Entry.categories.keys.each do |category_name| %>
<%= link_to category_name.tr('_', ' ').capitalize,
entries_path(base_params.merge(category: category_name)),
class: "px-4 py-1.5 rounded-full #{@category == category_name ? 'bg-indigo-100 text-indigo-700' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'} text-xs font-bold uppercase tracking-wider",
class: "px-3 py-1 rounded-full #{@category == category_name ? 'bg-indigo-100 text-indigo-700' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'} text-xs font-bold uppercase tracking-wider",
data: { turbo_stream: true } %>
<% end %>
</div>
<div class="flex flex-wrap gap-2 mt-2 mb-2">
<div class="flex flex-wrap gap-1 mt-2 mb-2">
<% all_language_params = base_params.except(:language, :starts_with, :page) %>
<%= link_to "All Languages",
entries_path(all_language_params),
class: "px-3 py-1.5 rounded-full #{@language_code.blank? ? 'bg-slate-900 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'} text-xs font-semibold uppercase tracking-wider",
class: "px-3 py-1 rounded-full #{@language_code.blank? ? 'bg-slate-900 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'} text-xs font-semibold uppercase tracking-wider",
data: { turbo_stream: true } %>
<% @supported_languages.each do |language| %>
<% supported_languages.each do |language| %>
<%= link_to "#{language.name} (#{language.code.upcase})",
entries_path(all_language_params.merge(language: language.code)),
class: "px-3 py-1.5 rounded-full #{@language_code == language.code ? 'bg-slate-900 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'} text-xs font-semibold uppercase tracking-wider",
class: "px-3 py-1 rounded-full #{@language_code == language.code ? 'bg-slate-900 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-slate-300'} text-xs font-semibold uppercase tracking-wider",
data: { turbo_stream: true } %>
<% end %>
</div>
<% if @language_code.present? %>
<div class="flex flex-wrap gap-2 text-xs mb-2">
<div class="flex flex-wrap gap-1 text-xs mb-2">
<% alphabet_params = base_params.merge(language: @language_code).except(:starts_with, :page) %>
<%= link_to "All",
entries_path(alphabet_params),
class: "px-2.5 py-1 rounded-md #{@starts_with.blank? ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'}",
class: "px-2 py-1 rounded-md #{@starts_with.blank? ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'}",
data: { turbo_stream: true } %>
<% alphabet_letters(@language_code).each do |letter| %>
<%= link_to letter,
entries_path(alphabet_params.merge(starts_with: letter)),
class: "px-2.5 py-1 rounded-md #{@starts_with == letter ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'}",
class: "px-2 py-1 rounded-md #{@starts_with == letter ? 'bg-indigo-600 text-white' : 'bg-white border border-slate-200 text-slate-600 hover:border-indigo-300'}",
data: { turbo_stream: true } %>
<% end %>
</div>
@@ -0,0 +1,8 @@
<details class="text-xs">
<summary class="inline-flex items-center gap-1 text-indigo-600 font-semibold cursor-pointer">
Add comment
</summary>
<div class="mt-3">
<%= render "entries/comment_form", entry: entry, language_code: language_code %>
</div>
</details>
+70
View File
@@ -0,0 +1,70 @@
<% content_for :title, "Edit Entry" %>
<nav class="sticky top-0 z-50 bg-white border-b border-slate-200">
<div class="max-w-5xl mx-auto px-4 h-16 flex items-center justify-between">
<div class="flex items-center gap-2">
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Wiki</span>
</div>
<div class="flex items-center gap-4">
<%= link_to "Browse", entries_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600" %>
<%= link_to "Download XLSX", download_entries_path(format: :xlsx), class: "text-sm font-semibold text-indigo-700 px-3 py-2 rounded-lg border border-indigo-200 bg-indigo-50 hover:bg-indigo-100 transition" %>
</div>
</div>
</nav>
<main class="max-w-5xl mx-auto px-4 py-8 space-y-6">
<div class="flex items-center justify-between">
<%= link_to "← Back to entry", entry_path(@entry), class: "text-sm text-slate-500 hover:text-indigo-600" %>
<%= link_to "Back to search", entries_path, class: "text-sm text-slate-500 hover:text-indigo-600" %>
</div>
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center">
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400">Edit Category</span>
<% if @entry.verified? %>
<div class="flex items-center gap-1.5 text-emerald-600">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
<span class="text-xs font-bold">Verified</span>
</div>
<% else %>
<span class="text-xs font-semibold text-amber-600">Unverified</span>
<% end %>
</div>
<div class="p-6 space-y-6">
<%= form_with model: @entry, class: "space-y-4" do |form| %>
<div>
<%= form.label :category, "Category", class: "block text-xs font-bold text-slate-500 uppercase tracking-widest mb-2" %>
<%= form.select :category,
Entry.categories.keys.map { |key| [key.tr("_", " ").capitalize, key] },
{},
class: "block w-full border-slate-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm" %>
</div>
<div class="flex justify-end">
<%= form.submit "Save Category", class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition" %>
</div>
<% end %>
<div class="grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-12">
<% supported_languages.each do |language| %>
<% translation = entry_translation_for(@entry, language.code) %>
<% next if translation.blank? %>
<div class="space-y-2">
<div class="grid grid-cols-2">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tight"><%= "#{language.name} (#{language.code.upcase})" %></span>
</div>
<p class="text-2xl font-semibold text-slate-800"><%= translation %></p>
</div>
<% end %>
</div>
<% if @entry.notes.present? %>
<div class="mt-6 pt-5 border-t border-slate-100">
<h4 class="text-xs font-bold text-slate-400 uppercase mb-2">Context & Notes</h4>
<p class="text-sm text-slate-600 italic"><%= @entry.notes %></p>
</div>
<% end %>
</div>
</div>
</main>
+13 -3
View File
@@ -21,6 +21,7 @@
<div class="bg-white border border-slate-200 rounded-xl shadow-sm overflow-hidden">
<div class="px-6 py-4 border-b border-slate-100 bg-slate-50/50 flex justify-between items-center">
<span class="text-[10px] font-black uppercase tracking-widest text-slate-400"><%= format_entry_category(@entry) %></span>
<%= link_to "Edit", edit_entry_path(@entry) if admin? %>
<% if @entry.verified? %>
<div class="flex items-center gap-1.5 text-emerald-600">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"/></svg>
@@ -33,12 +34,19 @@
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-y-6 gap-x-12">
<% @supported_languages.each do |language| %>
<% supported_languages.each do |language| %>
<% translation = entry_translation_for(@entry, language.code) %>
<% next if translation.blank? %>
<div class="space-y-1">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tight"><%= "#{language.name} (#{language.code.upcase})" %></span>
<div class="space-y-2">
<div class="grid grid-cols-2">
<span class="text-[10px] font-bold text-slate-400 uppercase tracking-tight"><%= "#{language.name} (#{language.code.upcase})" %></span>
</div>
<p class="text-2xl font-semibold text-slate-800"><%= translation %></p>
<% if current_user %>
<div id="comment-details-<%= language.code %>">
<%= render "entries/language_comment_details", entry: @entry, language_code: language.code %>
</div>
<% end %>
</div>
<% end %>
</div>
@@ -51,4 +59,6 @@
<% end %>
</div>
</div>
<%= render "entries/comments_section", entry: @entry %>
</main>
+136
View File
@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html>
<head>
<meta content='text/html; charset=UTF-8' http-equiv='Content-Type' />
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
color: #334155;
max-width: 640px;
margin: 0 auto;
padding: 20px;
}
.header {
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
color: white;
padding: 30px;
border-radius: 8px 8px 0 0;
text-align: center;
}
.header h1 {
margin: 0;
font-size: 28px;
font-weight: 700;
}
.header p {
margin: 8px 0 0 0;
opacity: 0.9;
}
.content {
background: white;
border: 1px solid #e2e8f0;
border-top: none;
padding: 30px;
border-radius: 0 0 8px 8px;
}
.greeting {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
color: #1e293b;
}
.info-box {
background: #f8fafc;
border-left: 4px solid #6366f1;
padding: 16px;
margin: 20px 0;
border-radius: 4px;
}
.info-box strong {
color: #1e293b;
}
.button {
display: inline-block;
background: linear-gradient(135deg, #6366f1 0%, #4f46e5 100%);
color: white;
padding: 14px 32px;
text-decoration: none;
border-radius: 8px;
font-weight: 600;
margin: 24px 0;
text-align: center;
}
.button:hover {
background: linear-gradient(135deg, #4f46e5 0%, #4338ca 100%);
}
.footer {
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e2e8f0;
font-size: 14px;
color: #64748b;
}
.expiry-notice {
background: #fef3c7;
border-left: 4px solid #f59e0b;
padding: 12px;
margin: 16px 0;
font-size: 14px;
border-radius: 4px;
}
</style>
</head>
<body>
<div class="header">
<h1>Sanasto Wiki</h1>
<p>Kristillisyyden sanasto</p>
</div>
<div class="content">
<p class="greeting">Hello <%= @user.name %>,</p>
<p>
The <strong>Sanasto Wiki</strong> let you search and compare, or download, translations across languages used all over the living Christianity.
</p>
<p>With a login account, you can contribute to this work.</p>
<div class="info-box">
<p style="margin: 0;"><strong>Your Account Details:</strong></p>
<p style="margin: 8px 0 0 0;">
Email: <%= @user.email %><br>
Role: <%= @user.role.titleize %>
</p>
</div>
<p>
To accept this invitation and set your password, click the button below:
</p>
<div style="text-align: center;">
<%= link_to "Accept Invitation", @invitation_url, class: "button" %>
</div>
<div class="expiry-notice">
This invitation will expire on <strong><%= @expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %></strong>.
</div>
<p>
You can also copy and paste this link into your browser:
</p>
<p style="word-break: break-all; color: #6366f1; font-size: 14px;">
<%= @invitation_url %>
</p>
<div class="footer">
<p>
If you weren't expecting this invitation, you can safely ignore this email.
</p>
<p style="margin-top: 12px;">
Questions? Reply to this email.
</p>
</div>
</div>
</body>
</html>
@@ -0,0 +1,26 @@
========================================
SANASTO WIKI - INVITATION
========================================
Hello <%= @user.name %>,
The Sanasto Wiki let you search and compare, or download, translations across languages used all over the living Christianity.
With a login account, you can contribute to this work.
YOUR ACCOUNT DETAILS
--------------------
Email: <%= @user.email %>
Role: <%= @user.role.titleize %>
TO ACCEPT THIS INVITATION
--------------------------
Please visit the following link to set your password and complete your registration:
<%= @invitation_url %>
This invitation will expire on <%= @expires_at.strftime("%B %d, %Y at %I:%M %p %Z") %>.
If you weren't expecting this invitation, you can safely ignore this email.
Questions? Reply to this email.
+75
View File
@@ -0,0 +1,75 @@
<% content_for :title, "Accept Invitation" %>
<div class="min-h-screen flex flex-col">
<header class="bg-white border-b border-slate-200">
<div class="max-w-7xl mx-auto px-4">
<div class="h-16 flex items-center">
<%= link_to root_path, class: "flex items-center gap-2" do %>
<span class="text-xl font-bold tracking-tight text-indigo-600">Sanasto</span>
<span class="text-xl font-light text-slate-400">Wiki</span>
<% end %>
</div>
</div>
</header>
<div class="flex-1 flex items-center justify-center px-4 py-12 bg-slate-50">
<div class="w-full max-w-md">
<div class="bg-white rounded-2xl shadow-sm border border-slate-200 p-8">
<div class="mb-8">
<h1 class="text-2xl font-bold text-slate-900 mb-2">Accept Invitation</h1>
<p class="text-sm text-slate-600">
You've been invited to join Sanasto Wiki as <%= @user.name %> (<%= @user.email %>)
</p>
<p class="text-sm text-slate-600 mt-1">
Role: <span class="font-medium text-indigo-600"><%= @user.role.titleize %></span>
</p>
</div>
<% if @user.errors.any? %>
<div class="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded-lg mb-6" role="alert">
<h3 class="font-medium mb-2"><%= pluralize(@user.errors.count, "error") %> prevented acceptance:</h3>
<ul class="list-disc pl-5 space-y-1 text-sm">
<% @user.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<%= form_with model: @user, url: accept_invitation_path(params[:token]), method: :patch, local: true, class: "space-y-5" do |form| %>
<div>
<%= form.label :password, "Set Your Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
<%= form.password_field :password,
autofocus: true,
required: true,
placeholder: "Minimum 12 characters",
class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %>
<p class="mt-1 text-xs text-slate-500">Choose a strong password with at least 12 characters.</p>
</div>
<div>
<%= form.label :password_confirmation, "Confirm Password", class: "block text-sm font-medium text-slate-700 mb-2" %>
<%= form.password_field :password_confirmation,
required: true,
placeholder: "Re-enter your password",
class: "block w-full px-4 py-3 bg-white border border-slate-200 rounded-lg shadow-sm focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 transition" %>
</div>
<div class="pt-2">
<%= form.submit "Accept Invitation & Join",
class: "w-full bg-indigo-600 text-white px-4 py-3 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
</div>
<% end %>
<div class="mt-6 text-center">
<%= link_to root_path, class: "text-sm text-slate-600 hover:text-indigo-600 transition inline-flex items-center gap-1" do %>
<svg class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18" />
</svg>
Back to Wiki
<% end %>
</div>
</div>
</div>
</div>
</div>
+5 -1
View File
@@ -21,6 +21,10 @@ Rails.application.routes.draw do
post "login", to: "sessions#create"
delete "logout", to: "sessions#destroy", as: :logout
# Invitation acceptance routes
get "invitations/:token", to: "invitations#show", as: :invitation
patch "invitations/:token/accept", to: "invitations#update", as: :accept_invitation
# Admin namespace
namespace :admin do
root "dashboard#index"
@@ -30,13 +34,13 @@ Rails.application.routes.draw do
end
resources :entries do
resources :comments, only: [:create]
collection do
get :download
end
end
resources :suggested_meanings
resources :comments, only: [:create, :update, :destroy]
resources :entry_versions, only: [:index, :show]
resources :supported_languages, only: [:index, :show]
resources :users
end
@@ -0,0 +1,10 @@
class CreateSetupStates < ActiveRecord::Migration[8.1]
def change
create_table :setup_states do |t|
t.boolean :installed, null: false, default: false
t.datetime :installed_at
t.timestamps
end
end
end
@@ -0,0 +1,14 @@
class DropEntryVersions < ActiveRecord::Migration[8.1]
def change
drop_table :entry_versions do |t|
t.text :changes_made
t.string :change_type
t.datetime :created_at, null: false
t.integer :entry_id, null: false
t.datetime :updated_at, null: false
t.integer :user_id
t.index [ :entry_id ], name: "index_entry_versions_on_entry_id"
t.index [ :user_id ], name: "index_entry_versions_on_user_id"
end
end
end
@@ -0,0 +1,5 @@
class AddLanguageCodeToComments < ActiveRecord::Migration[8.1]
def change
add_column :comments, :language_code, :string
end
end
+5 -10
View File
@@ -10,21 +10,12 @@ FOREIGN KEY ("updated_by_id")
CREATE INDEX "index_entries_on_created_by_id" ON "entries" ("created_by_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_updated_by_id" ON "entries" ("updated_by_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_entries_on_category" ON "entries" ("category") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "comments" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "user_id" integer NOT NULL, "commentable_type" varchar NOT NULL, "commentable_id" integer NOT NULL, "body" text NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_03de2dc08c"
CREATE TABLE IF NOT EXISTS "comments" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "user_id" integer NOT NULL, "commentable_type" varchar NOT NULL, "commentable_id" integer NOT NULL, "body" text NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "language_code" varchar /*application='SanastoWiki'*/, CONSTRAINT "fk_rails_03de2dc08c"
FOREIGN KEY ("user_id")
REFERENCES "users" ("id")
);
CREATE INDEX "index_comments_on_user_id" ON "comments" ("user_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_comments_on_commentable" ON "comments" ("commentable_type", "commentable_id") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "entry_versions" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "entry_id" integer NOT NULL, "user_id" integer NOT NULL, "changes_made" json NOT NULL, "change_type" varchar, "created_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_be24c8cfa1"
FOREIGN KEY ("entry_id")
REFERENCES "entries" ("id")
, CONSTRAINT "fk_rails_aaeb10db8b"
FOREIGN KEY ("user_id")
REFERENCES "users" ("id")
);
CREATE INDEX "index_entry_versions_on_entry_id" ON "entry_versions" ("entry_id") /*application='SanastoWiki'*/;
CREATE INDEX "index_entry_versions_on_user_id" ON "entry_versions" ("user_id") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "supported_languages" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "code" varchar NOT NULL, "name" varchar NOT NULL, "native_name" varchar NOT NULL, "sort_order" integer DEFAULT 0 NOT NULL, "active" boolean DEFAULT TRUE NOT NULL, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
CREATE UNIQUE INDEX "index_supported_languages_on_code" ON "supported_languages" ("code") /*application='SanastoWiki'*/;
CREATE TABLE IF NOT EXISTS "users" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "email" varchar NOT NULL, "password_digest" varchar NOT NULL, "name" varchar, "role" integer DEFAULT 0 NOT NULL, "primary_language" varchar, "invitation_token" varchar, "invitation_sent_at" datetime(6), "invitation_accepted_at" datetime(6), "invited_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_ae14a5013f"
@@ -88,7 +79,11 @@ BEGIN
INSERT INTO entries_fts(entries_fts, rowid, fi, en, sv, no, ru, de, notes)
VALUES('delete', old.id, old.fi, old.en, old.sv, old.no, old.ru, old.de, old.notes);
END;
CREATE TABLE IF NOT EXISTS "setup_states" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "installed" boolean DEFAULT FALSE NOT NULL, "installed_at" datetime(6), "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
INSERT INTO "schema_migrations" (version) VALUES
('20260123130957'),
('20260123125325'),
('20260122131000'),
('20260122130000'),
('20260122124151'),
('20260122123837'),
+10 -11
View File
@@ -10,12 +10,12 @@
- [ ] Password reset flow
- [ ] Rate limiting on login attempts
- [ ] Session management (remember me, session timeout)
- [ ] **Invitation system**
- [x] **Invitation system**
- [x] Invitations controller (create, list, cancel)
- [x] Invitation token generation
- [ ] Registration via invitation link (acceptance flow)
- [ ] Token expiry validation (14 days)
- [ ] Invitation mailer
- [x] Registration via invitation link (acceptance flow)
- [x] Token expiry validation (14 days)
- [x] Invitation mailer
- [ ] **Authorization & roles**
- [x] Role-based access control middleware (Admin::BaseController)
- [x] Admin permissions enforcement
@@ -50,17 +50,11 @@
- [ ] **Comment threading** (optional: replies to comments)
- [ ] **Comment notifications** for entry contributors
### History & Audit
- [ ] **Entry version tracking** (record all changes in `entry_versions`)
- [ ] **View edit history** on entry page
- [ ] **Diff view** showing what changed
- [ ] **Revert to previous version** (admin/reviewer only)
## User Management
- [x] **Setup** adds the first user
- [x] **Admin dashboard**
- [x] Send invitations interface (email delivery pending mailer implementation)
- [x] Send invitations interface (with email delivery)
- [x] Manage users (list, edit roles, delete)
- [x] System statistics (users, entries, contributions)
- [ ] **User profile page**
@@ -126,6 +120,11 @@
## Completed
- [x] **Invitation system** (complete flow with email, acceptance, and expiry validation)
- [x] **Invitation acceptance flow** (users can accept invitations and set passwords)
- [x] **Invitation mailer** (HTML and text email templates with styled design)
- [x] **Token expiry validation** (14-day expiration for invitation links)
- [x] **Controller tests** (40 tests with 160+ assertions for authentication)
- [x] **Authentication system** (login/logout with session management)
- [x] **Admin layout design** updated to match entries page style
- [x] **Dynamic navigation** (Admin button for logged-in admins, Sign In for guests)
@@ -0,0 +1,36 @@
require "test_helper"
class Admin::DashboardControllerTest < ActionDispatch::IntegrationTest
test "should redirect to login when not authenticated" do
get admin_root_path
assert_redirected_to login_path
follow_redirect!
assert_select ".bg-red-50", /You must be logged in/
end
test "should redirect to root when logged in as non-admin" do
login_as(users(:contributor_user))
get admin_root_path
assert_redirected_to root_path
assert_equal "You must be an administrator to access this page.", flash[:alert]
end
test "should redirect to root when logged in as reviewer" do
login_as(users(:reviewer_user))
get admin_root_path
assert_redirected_to root_path
assert_equal "You must be an administrator to access this page.", flash[:alert]
end
test "should show dashboard when logged in as admin" do
login_as(users(:admin_user))
get admin_root_path
assert_response :success
end
test "should show admin dashboard path" do
login_as(users(:admin_user))
get admin_dashboard_path
assert_response :success
end
end
@@ -0,0 +1,143 @@
require "test_helper"
class Admin::InvitationsControllerTest < ActionDispatch::IntegrationTest
test "should redirect to login when not authenticated" do
get admin_invitations_path
assert_redirected_to login_path
end
test "should redirect to root when logged in as non-admin" do
login_as(users(:contributor_user))
get admin_invitations_path
assert_redirected_to root_path
end
test "should show invitations index when logged in as admin" do
login_as(users(:admin_user))
get admin_invitations_path
assert_response :success
end
test "should get new invitation page when logged in as admin" do
login_as(users(:admin_user))
get new_admin_invitation_path
assert_response :success
assert_select "form"
assert_select "input[name='user[email]']"
assert_select "input[name='user[name]']"
assert_select "select[name='user[role]']"
assert_select "select[name='user[primary_language]']"
end
test "should display pending invitations on index page" do
login_as(users(:admin_user))
get admin_invitations_path
assert_response :success
assert_select "h1,h2", /Invitations/
end
test "should create invitation when logged in as admin" do
login_as(users(:admin_user))
assert_difference("User.count", 1) do
post admin_invitations_path, params: {
user: {
email: "newuser@example.com",
name: "New User",
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
assert_not_nil new_user.invitation_token
assert_not_nil new_user.invitation_sent_at
assert_nil new_user.invitation_accepted_at
assert_equal users(:admin_user).id, new_user.invited_by_id
end
test "should send invitation email when creating invitation" do
login_as(users(:admin_user))
assert_enqueued_emails 1 do
post admin_invitations_path, params: {
user: {
email: "newuser@example.com",
name: "New User",
role: "contributor",
primary_language: "en"
}
}
end
end
test "should not create invitation with invalid data" do
login_as(users(:admin_user))
assert_no_difference("User.count") do
post admin_invitations_path, params: {
user: {
email: "",
name: "New User",
role: "contributor",
primary_language: "en"
}
}
end
assert_response :unprocessable_entity
end
test "should cancel pending invitation when logged in as admin" do
login_as(users(:admin_user))
assert_difference("User.count", -1) do
delete admin_invitation_path(users(:pending_invitation))
end
assert_redirected_to admin_invitations_path
end
test "should not cancel accepted invitation" do
login_as(users(:admin_user))
assert_no_difference("User.count") do
delete admin_invitation_path(users(:contributor_user))
end
assert_redirected_to admin_invitations_path
follow_redirect!
assert_select ".bg-red-50", /Cannot cancel an accepted invitation/
end
test "should not allow non-admin to create invitation" do
login_as(users(:contributor_user))
assert_no_difference("User.count") do
post admin_invitations_path, params: {
user: {
email: "newuser@example.com",
name: "New User",
role: "contributor",
primary_language: "en"
}
}
end
assert_redirected_to root_path
end
test "should not allow non-admin to cancel invitation" do
login_as(users(:contributor_user))
assert_no_difference("User.count") do
delete admin_invitation_path(users(:pending_invitation))
end
assert_redirected_to root_path
end
end
@@ -0,0 +1,68 @@
require "test_helper"
class Admin::UsersControllerTest < ActionDispatch::IntegrationTest
test "should redirect to login when not authenticated" do
get admin_users_path
assert_redirected_to login_path
end
test "should redirect to root when logged in as non-admin" do
login_as(users(:contributor_user))
get admin_users_path
assert_redirected_to root_path
end
test "should show users index when logged in as admin" do
login_as(users(:admin_user))
get admin_users_path
assert_response :success
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))
assert_response :success
end
test "should update user role when logged in as admin" do
login_as(users(:admin_user))
patch admin_user_path(users(:contributor_user)), params: {
user: { role: "reviewer" }
}
assert_redirected_to admin_users_path
assert_equal "reviewer", users(:contributor_user).reload.role
end
test "should delete user when logged in as admin" do
login_as(users(:admin_user))
# Delete reviewer_user who has no associated records
assert_difference("User.count", -1) do
delete admin_user_path(users(:reviewer_user))
end
assert_redirected_to admin_users_path
end
test "should not allow non-admin to update user" do
login_as(users(:contributor_user))
patch admin_user_path(users(:reviewer_user)), params: {
user: { role: "admin" }
}
assert_redirected_to root_path
end
test "should not allow non-admin to delete user" do
login_as(users(:contributor_user))
assert_no_difference("User.count") do
delete admin_user_path(users(:reviewer_user))
end
assert_redirected_to root_path
end
end
@@ -0,0 +1,42 @@
require "test_helper"
class CommentsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = users(:contributor_user)
@entry = entries(:one)
@supported_language = supported_languages(:one)
end
test "should not create comment if not logged in" do
assert_no_difference("Comment.count") do
post entry_comments_url(@entry), params: { comment: { body: "Test comment", language_code: @supported_language.code } }
end
assert_redirected_to new_user_session_url
end
test "should create comment if logged in" do
login_as @user
assert_difference("Comment.count", 1) do
post entry_comments_url(@entry), params: { comment: { body: "Test comment", language_code: @supported_language.code } }
end
assert_redirected_to @entry
assert_equal "Test comment", Comment.last.body
assert_equal @supported_language.code, Comment.last.language_code
assert_equal @user, Comment.last.user
end
# Assuming you want to test turbo stream responses as well
test "should create comment and respond with turbo stream" do
login_as @user
post entry_comments_url(@entry), params: { comment: { body: "Test turbo comment", language_code: @supported_language.code } }, as: :turbo_stream
assert_response :success
assert_match(/turbo-stream action=\"append\" target=\"comments-#{@supported_language.code}\"/, response.body)
assert_match(/Test turbo comment/, response.body)
end
private
def new_user_session_url
login_path
end
end
@@ -0,0 +1,93 @@
require "test_helper"
class InvitationsControllerTest < ActionDispatch::IntegrationTest
test "should show invitation acceptance page with valid token" do
user = users(:pending_invitation)
get invitation_path(user.invitation_token)
assert_response :success
assert_select "h1", "Accept Invitation"
assert_select "input[type=password]", count: 2
end
test "should redirect with invalid token" do
get invitation_path("invalid_token")
assert_redirected_to root_path
assert_equal "Invalid or expired invitation link.", flash[:alert]
end
test "should redirect with expired token" do
user = users(:pending_invitation)
user.update(invitation_sent_at: 15.days.ago)
get invitation_path(user.invitation_token)
assert_redirected_to root_path
assert_equal "Invalid or expired invitation link.", flash[:alert]
end
test "should accept invitation with valid password" do
user = users(:pending_invitation)
patch accept_invitation_path(user.invitation_token), params: {
user: {
password: "securepassword123",
password_confirmation: "securepassword123"
}
}
user.reload
assert_not_nil user.invitation_accepted_at
assert_nil user.invitation_token
assert_equal user.id, session[:user_id]
assert_redirected_to root_path
end
test "should not accept invitation with short password" do
user = users(:pending_invitation)
patch accept_invitation_path(user.invitation_token), params: {
user: {
password: "short",
password_confirmation: "short"
}
}
user.reload
assert_nil user.invitation_accepted_at
assert_not_nil user.invitation_token
assert_response :unprocessable_entity
end
test "should not accept invitation with mismatched passwords" do
user = users(:pending_invitation)
patch accept_invitation_path(user.invitation_token), params: {
user: {
password: "securepassword123",
password_confirmation: "differentpassword"
}
}
user.reload
assert_nil user.invitation_accepted_at
assert_response :unprocessable_entity
end
test "should not accept expired invitation" do
user = users(:pending_invitation)
user.update(invitation_sent_at: 15.days.ago)
patch accept_invitation_path(user.invitation_token), params: {
user: {
password: "securepassword123",
password_confirmation: "securepassword123"
}
}
assert_redirected_to root_path
assert_equal "Invalid or expired invitation link.", flash[:alert]
end
end
@@ -0,0 +1,105 @@
require "test_helper"
class SessionsControllerTest < ActionDispatch::IntegrationTest
test "should get login page" do
get login_path
assert_response :success
assert_select "h1", "Sign in"
assert_select "input[type=email]"
assert_select "input[type=password]"
end
test "should redirect to admin if already logged in as admin" do
login_as(users(:admin_user))
get login_path
assert_redirected_to admin_root_path
end
test "should redirect to root if already logged in as non-admin" do
login_as(users(:contributor_user))
get login_path
assert_redirected_to root_path
end
test "should login with valid credentials" do
post login_path, params: {
email: "admin@example.com",
password: "password123456"
}
assert_redirected_to admin_root_path
assert_equal users(:admin_user).id, session[:user_id]
follow_redirect!
assert_select ".bg-green-50", /Welcome back/
end
test "should login contributor and redirect to root" do
post login_path, params: {
email: "contributor@example.com",
password: "password123456"
}
assert_redirected_to root_path
assert_equal users(:contributor_user).id, session[:user_id]
end
test "should not login with invalid email" do
post login_path, params: {
email: "nonexistent@example.com",
password: "password123456"
}
assert_response :unprocessable_entity
assert_nil session[:user_id]
assert_select ".bg-red-50", /Invalid email or password/
end
test "should not login with invalid password" do
post login_path, params: {
email: "admin@example.com",
password: "wrongpassword"
}
assert_response :unprocessable_entity
assert_nil session[:user_id]
assert_select ".bg-red-50", /Invalid email or password/
end
test "should handle email with whitespace and case insensitivity" do
post login_path, params: {
email: " ADMIN@EXAMPLE.COM ",
password: "password123456"
}
assert_redirected_to admin_root_path
assert_equal users(:admin_user).id, session[:user_id]
end
test "should not login user with pending invitation" do
post login_path, params: {
email: "pending@example.com",
password: "password123456"
}
assert_response :unprocessable_entity
assert_nil session[:user_id]
assert_select ".bg-red-50", /Your account is pending/
end
test "should logout and redirect to root" do
login_as(users(:admin_user))
delete logout_path
assert_redirected_to root_path
assert_nil session[:user_id]
assert_equal "You have been logged out.", flash[:notice]
end
test "should logout even when not logged in" do
delete logout_path
assert_redirected_to root_path
assert_nil session[:user_id]
end
end
+100
View File
@@ -0,0 +1,100 @@
require "test_helper"
class SetupControllerTest < ActionDispatch::IntegrationTest
def setup
SetupState.reset!
end
def teardown
SetupState.reset!
end
test "should show setup page when not installed" do
get setup_path
assert_response :success
assert_select "h2", /Create Admin Account/
end
test "should redirect to root when already installed" do
SetupState.mark_installed!
get setup_path
assert_redirected_to root_path
assert_equal "Setup has already been completed.", flash[:alert]
end
test "should create admin user and mark as installed" do
assert_difference("User.count", 1) do
post setup_path, params: {
user: {
email: "setupadmin@example.com",
name: "Setup Admin",
password: "securepassword123",
password_confirmation: "securepassword123",
primary_language: "en"
}
}
end
assert SetupState.installed?
new_user = User.find_by(email: "setupadmin@example.com")
assert_not_nil new_user
assert_equal "admin", new_user.role
assert_not_nil new_user.invitation_accepted_at
assert_equal new_user.id, session[:user_id]
assert_redirected_to admin_root_path
end
test "should not create user with invalid password" do
assert_no_difference("User.count") do
post setup_path, params: {
user: {
email: "setupadmin@example.com",
name: "Setup Admin",
password: "short", # Too short, minimum is 12
password_confirmation: "short",
primary_language: "en"
}
}
end
assert_not SetupState.installed?
assert_response :unprocessable_entity
end
test "should not create user with mismatched passwords" do
assert_no_difference("User.count") do
post setup_path, params: {
user: {
email: "setupadmin@example.com",
name: "Setup Admin",
password: "securepassword123",
password_confirmation: "differentpassword",
primary_language: "en"
}
}
end
assert_not SetupState.installed?
assert_response :unprocessable_entity
end
test "should not create user without email" do
assert_no_difference("User.count") do
post setup_path, params: {
user: {
email: "",
name: "Setup Admin",
password: "securepassword123",
password_confirmation: "securepassword123",
primary_language: "en"
}
}
end
assert_not SetupState.installed?
assert_response :unprocessable_entity
end
end
+2 -2
View File
@@ -1,13 +1,13 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
user: one
user: admin_user
commentable: one
commentable_type: Commentable
body: MyText
two:
user: two
user: contributor_user
commentable: two
commentable_type: Commentable
body: MyText
+4 -4
View File
@@ -10,8 +10,8 @@ one:
de: MyString
notes: MyText
verified: false
created_by: one
updated_by: one
created_by: admin_user
updated_by: admin_user
two:
category: 1
@@ -23,5 +23,5 @@ two:
de: MyString
notes: MyText
verified: false
created_by: two
updated_by: two
created_by: contributor_user
updated_by: contributor_user
-13
View File
@@ -1,13 +0,0 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
entry: one
user: one
changes_made: "{}"
change_type: MyString
two:
entry: two
user: two
changes_made: "{}"
change_type: MyString
+2 -2
View File
@@ -9,7 +9,7 @@ one:
source: MyString
region: MyString
status: :pending
submitted_by: one
submitted_by: contributor_user
two:
entry: two
@@ -20,4 +20,4 @@ two:
source: MyString
region: MyString
status: :pending
submitted_by: two
submitted_by: contributor_user
+41 -18
View File
@@ -1,23 +1,46 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
# Password for all test users: "password123456"
one:
email: "one@example.com"
password_digest: <%= BCrypt::Password.create('password') %>
name: "User One"
role: 1
admin_user:
email: "admin@example.com"
password_digest: <%= BCrypt::Password.create('password123456') %>
name: "Admin User"
role: 2 # admin
primary_language: "en"
invitation_token: "one"
invitation_sent_at: 2026-01-22 13:38:37
invitation_accepted_at: 2026-01-22 13:38:37
invited_by: one
invitation_token: "admin_token_accepted"
invitation_sent_at: <%= 30.days.ago %>
invitation_accepted_at: <%= 30.days.ago %>
invited_by_id: ~
two:
email: "two@example.com"
password_digest: <%= BCrypt::Password.create('password') %>
name: "User Two"
role: 1
reviewer_user:
email: "reviewer@example.com"
password_digest: <%= BCrypt::Password.create('password123456') %>
name: "Reviewer User"
role: 1 # reviewer
primary_language: "en"
invitation_token: "reviewer_token_accepted"
invitation_sent_at: <%= 20.days.ago %>
invitation_accepted_at: <%= 20.days.ago %>
invited_by: admin_user
contributor_user:
email: "contributor@example.com"
password_digest: <%= BCrypt::Password.create('password123456') %>
name: "Contributor User"
role: 0 # contributor
primary_language: "fi"
invitation_token: "two"
invitation_sent_at: 2026-01-22 13:38:37
invitation_accepted_at: 2026-01-22 13:38:37
invited_by: two
invitation_token: "contributor_token_accepted"
invitation_sent_at: <%= 10.days.ago %>
invitation_accepted_at: <%= 10.days.ago %>
invited_by: admin_user
pending_invitation:
email: "pending@example.com"
password_digest: <%= BCrypt::Password.create('password123456') %>
name: "Pending User"
role: 0 # contributor
primary_language: "en"
invitation_token: "pending_token_12345"
invitation_sent_at: <%= 2.days.ago %>
invitation_accepted_at: ~
invited_by: admin_user
+38
View File
@@ -0,0 +1,38 @@
require "test_helper"
class InvitationMailerTest < ActionMailer::TestCase
test "invite sends email with correct details" do
user = users(:pending_invitation)
mail = InvitationMailer.invite(user)
assert_equal "You've been invited to join Sanasto Wiki", mail.subject
assert_equal [ user.email ], mail.to
assert_match user.name, mail.body.encoded
assert_match user.email, mail.body.encoded
assert_match user.role.titleize, mail.body.encoded
end
test "invite includes invitation link" do
user = users(:pending_invitation)
mail = InvitationMailer.invite(user)
assert_match "invitations/#{user.invitation_token}", mail.body.encoded
end
test "invite includes expiry date" do
user = users(:pending_invitation)
mail = InvitationMailer.invite(user)
expires_at = user.invitation_sent_at + User::INVITATION_TOKEN_EXPIRY
assert_match expires_at.strftime("%B"), mail.body.encoded
end
test "invite has both HTML and text parts" do
user = users(:pending_invitation)
mail = InvitationMailer.invite(user)
assert_equal 2, mail.parts.size
assert_equal "text/plain", mail.text_part.content_type.split(";").first
assert_equal "text/html", mail.html_part.content_type.split(";").first
end
end
@@ -0,0 +1,7 @@
# Preview all emails at http://localhost:3000/rails/mailers/invitation_mailer
class InvitationMailerPreview < ActionMailer::Preview
# Preview this email at http://localhost:3000/rails/mailers/invitation_mailer/invite
def invite
InvitationMailer.invite
end
end
+20 -8
View File
@@ -1,29 +1,41 @@
require "test_helper"
class CommentTest < ActiveSupport::TestCase
test "should be valid with a user, body, and commentable" do
user = users(:one)
test "should be valid with a user, body, commentable, and language code" do
user = users(:contributor_user)
entry = entries(:one)
comment = Comment.new(user: user, body: "This is a comment.", commentable: entry)
language = supported_languages(:one)
comment = Comment.new(user: user, body: "This is a comment.", commentable: entry, language: language)
assert comment.valid?
end
test "should be valid without a language code" do
user = users(:contributor_user)
entry = entries(:one)
comment = Comment.new(user: user, body: "General note.", commentable: entry, language_code: nil)
assert comment.valid?
end
test "should be invalid without a body" do
user = users(:one)
user = users(:contributor_user)
entry = entries(:one)
comment = Comment.new(user: user, commentable: entry)
language = supported_languages(:one)
comment = Comment.new(user: user, commentable: entry, language: language)
assert_not comment.valid?
end
test "should be invalid without a user" do
entry = entries(:one)
comment = Comment.new(body: "This is a comment.", commentable: entry)
language = supported_languages(:one)
comment = Comment.new(body: "This is a comment.", commentable: entry, language: language)
assert_not comment.valid?
end
test "should be invalid without a commentable" do
user = users(:one)
comment = Comment.new(user: user, body: "This is a comment.")
user = users(:contributor_user)
language = supported_languages(:one)
comment = Comment.new(user: user, body: "This is a comment.", language: language)
assert_not comment.valid?
end
end
-36
View File
@@ -1,36 +0,0 @@
require "test_helper"
class EntryVersionTest < ActiveSupport::TestCase
test "should be valid with all attributes" do
version = EntryVersion.new(
entry: entries(:one),
user: users(:one),
changes_made: { "fi" => "uusi sana" }
)
assert version.valid?
end
test "should be invalid without changes_made" do
version = EntryVersion.new(
entry: entries(:one),
user: users(:one)
)
assert_not version.valid?
end
test "should be invalid without an entry" do
version = EntryVersion.new(
user: users(:one),
changes_made: { "fi" => "uusi sana" }
)
assert_not version.valid?
end
test "should be invalid without a user" do
version = EntryVersion.new(
entry: entries(:one),
changes_made: { "fi" => "uusi sana" }
)
assert_not version.valid?
end
end
+6 -6
View File
@@ -6,7 +6,7 @@ class SuggestedMeaningTest < ActiveSupport::TestCase
entry: entries(:one),
language_code: supported_languages(:one).code,
alternative_translation: "New Translation",
submitted_by: users(:one)
submitted_by: users(:contributor_user)
)
assert meaning.valid?
end
@@ -15,7 +15,7 @@ class SuggestedMeaningTest < ActiveSupport::TestCase
meaning = SuggestedMeaning.new(
entry: entries(:one),
alternative_translation: "New Translation",
submitted_by: users(:one)
submitted_by: users(:contributor_user)
)
assert_not meaning.valid?
end
@@ -24,7 +24,7 @@ class SuggestedMeaningTest < ActiveSupport::TestCase
meaning = SuggestedMeaning.new(
entry: entries(:one),
language_code: supported_languages(:one).code,
submitted_by: users(:one)
submitted_by: users(:contributor_user)
)
assert_not meaning.valid?
end
@@ -34,7 +34,7 @@ class SuggestedMeaningTest < ActiveSupport::TestCase
entry: entries(:one),
language_code: supported_languages(:one).code,
alternative_translation: "New Translation",
submitted_by: users(:one)
submitted_by: users(:contributor_user)
)
assert meaning.pending?
end
@@ -44,7 +44,7 @@ class SuggestedMeaningTest < ActiveSupport::TestCase
entry: entries(:one),
language_code: supported_languages(:one).code,
alternative_translation: "New Translation",
submitted_by: users(:one),
submitted_by: users(:contributor_user),
status: :accepted
)
assert meaning.accepted?
@@ -55,7 +55,7 @@ class SuggestedMeaningTest < ActiveSupport::TestCase
entry: entries(:one),
language_code: supported_languages(:one).code,
alternative_translation: "New Translation",
submitted_by: users(:one),
submitted_by: users(:contributor_user),
status: :rejected
)
assert meaning.rejected?
+2 -3
View File
@@ -2,13 +2,12 @@ require "test_helper"
class SupportedLanguageTest < ActiveSupport::TestCase
test "should be valid with all attributes" do
language = SupportedLanguage.new(code: "es", name: "Spanish", native_name: "Español")
language = SupportedLanguage.new(code: "sv", name: "Swedish", native_name: "Svenska")
assert language.valid?
end
test "should be invalid with a duplicate code" do
SupportedLanguage.create(code: "de", name: "German", native_name: "Deutsch")
language = SupportedLanguage.new(code: "de", name: "German", native_name: "Deutsch")
language = SupportedLanguage.new(code: supported_languages(:one).code, name: "English", native_name: "English")
assert_not language.valid?
end
end
+6 -6
View File
@@ -2,7 +2,7 @@ require "test_helper"
class UserTest < ActiveSupport::TestCase
test "should be valid with an email and password" do
user = User.new(email: "test@example.com", password: "password123456")
user = User.new(email: "new-user@example.com", password: "password123456")
assert user.valid?
end
@@ -12,23 +12,23 @@ class UserTest < ActiveSupport::TestCase
end
test "should be invalid with a duplicate email" do
User.create(email: "test@example.com", password: "password123456")
user = User.new(email: "test@example.com", password: "password123456")
existing_user = users(:admin_user)
user = User.new(email: existing_user.email, password: "password123456")
assert_not user.valid?
end
test "should have a default role of contributor" do
user = User.new(email: "test@example.com", password: "password123456")
user = User.new(email: "new-user@example.com", password: "password123456")
assert user.contributor?
end
test "can be a reviewer" do
user = User.new(email: "test@example.com", password: "password123456", role: :reviewer)
user = User.new(email: "new-user@example.com", password: "password123456", role: :reviewer)
assert user.reviewer?
end
test "can be an admin" do
user = User.new(email: "test@example.com", password: "password123456", role: :admin)
user = User.new(email: "new-user@example.com", password: "password123456", role: :admin)
assert user.admin?
end
+19 -2
View File
@@ -4,8 +4,8 @@ require "rails/test_help"
module ActiveSupport
class TestCase
# Run tests in parallel with specified workers
parallelize(workers: :number_of_processors)
# Run tests serially to avoid sqlite/FTS5 conflicts in test setup.
parallelize(workers: 1)
# Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
fixtures :all
@@ -13,3 +13,20 @@ module ActiveSupport
# Add more helper methods to be used by all tests here...
end
end
module ActionDispatch
class IntegrationTest
# Helper method to login as a user in integration tests
def login_as(user, password: "password123456")
post login_path, params: {
email: user.email,
password: password
}
end
# Helper method to logout
def logout
delete logout_path
end
end
end