diff --git a/app/views/invitation_mailer/invite.text.erb b/app/views/invitation_mailer/invite.text.erb
index 0200355..ac0f820 100644
--- a/app/views/invitation_mailer/invite.text.erb
+++ b/app/views/invitation_mailer/invite.text.erb
@@ -4,9 +4,28 @@ SANASTO WIKI - INVITATION
Hello <%= @user.name %>,
+<% if @approved_entry %>
+Great news! Your entry request has been APPROVED and is ready to be published.
+
+YOUR APPROVED ENTRY
+-------------------
+Category: <%= @approved_entry.category.to_s.humanize %>
+
+Translations:
+<% if @approved_entry.fi.present? %> • Finnish: <%= @approved_entry.fi %>
+<% end %><% if @approved_entry.en.present? %> • English: <%= @approved_entry.en %>
+<% end %><% if @approved_entry.sv.present? %> • Swedish: <%= @approved_entry.sv %>
+<% end %><% if @approved_entry.no.present? %> • Norwegian: <%= @approved_entry.no %>
+<% end %><% if @approved_entry.ru.present? %> • Russian: <%= @approved_entry.ru %>
+<% end %><% if @approved_entry.de.present? %> • German: <%= @approved_entry.de %>
+<% end %>
+
+To complete the process and publish your entry, please accept this invitation to create your account on Sanasto Wiki.
+<% else %>
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.
+<% end %>
YOUR ACCOUNT DETAILS
--------------------
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb
index e2a5597..b7387fd 100644
--- a/app/views/layouts/admin.html.erb
+++ b/app/views/layouts/admin.html.erb
@@ -24,6 +24,16 @@
<%= link_to "Dashboard", admin_dashboard_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= link_to "Users", admin_users_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= link_to "Invitations", admin_invitations_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
+ <% requested_count = Entry.requested.count %>
+ <% gap = requested_count.zero? ? '' : 'pr-4' %>
+ <%= link_to admin_requests_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition relative #{gap}" do %>
+ Requests
+ <% if requested_count > 0 %>
+
+ <% end %>
+ <% end %>
<%= link_to "Back to Site", root_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
<%= button_to "Log Out", logout_path, method: :delete, form: { data: { turbo: false }, style: "display: inline-block;" }, class: "bg-indigo-600 text-white px-4 py-2 rounded-lg text-sm font-semibold hover:bg-indigo-700 transition cursor-pointer" %>
@@ -35,8 +45,13 @@
<% if flash.any? %>
<% flash.each do |type, message| %>
-
-
<%= message %>
+
+
<%= message %>
+
<% end %>
diff --git a/app/views/requests/new.html.erb b/app/views/requests/new.html.erb
new file mode 100644
index 0000000..c44a3e5
--- /dev/null
+++ b/app/views/requests/new.html.erb
@@ -0,0 +1,129 @@
+<% content_for :title, "Request a New Entry" %>
+
+
+
+
+
+ <%= link_to root_path, class: "flex items-center gap-2" do %>
+
Sanasto
+
Wiki
+ <% end %>
+
+ <%= link_to "Sign In", login_path, class: "text-sm font-medium text-slate-600 hover:text-indigo-600 transition" %>
+
+
+
+
+
+
+
+
+
+
Request a New Entry
+
Is there a word you would like to see in this glossary?
+
+
+ <% if flash[:alert] %>
+
+ <%= flash[:alert] %>
+
+ <% end %>
+
+ <% if @pending_count && @pending_count > 0 %>
+
+ You have <%= @pending_count %> pending <%= "request".pluralize(@pending_count) %> being reviewed.
+
+ <% end %>
+
+ <%= form_with model: @entry, url: requests_path, class: "space-y-6", data: { turbo: false } do |f| %>
+ <% if @entry.errors.any? %>
+
+
Please fix the following errors:
+
+ <% @entry.errors.full_messages.each do |message| %>
+ - <%= message %>
+ <% end %>
+
+
+ <% end %>
+
+
+ <% if current_user %>
+
+
+ Submitting as: <%= current_user.name %> (<%= current_user.email %>)
+
+
+ <% else %>
+
+ <%= f.label :name, "Your Name", class: "block text-sm font-semibold text-gray-700 mb-2" %>
+ <%= f.text_field :name, required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
+
+
+
+ <%= f.label :email, "Your Email", class: "block text-sm font-semibold text-gray-700 mb-2" %>
+ <%= f.email_field :email, required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
+
+ <% end %>
+
+
+ <%= f.label :category, "Category", class: "block text-sm font-semibold text-gray-700 mb-2" %>
+ <%= f.select :category, Entry.categories.keys.map { |cat| [cat.humanize, cat] }, { prompt: "Select a category" }, { required: true, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" } %>
+
+
+
+
+
Translations (at least one required)
+
+
+ <%= f.label :fi, "🇫🇮 Finnish", class: "block text-sm font-medium text-gray-700 mb-2" %>
+ <%= f.text_field :fi, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
+
+
+
+ <%= f.label :en, "🇬🇧 English", class: "block text-sm font-medium text-gray-700 mb-2" %>
+ <%= f.text_field :en, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
+
+
+
+ <%= f.label :sv, "🇸🇪 Swedish", class: "block text-sm font-medium text-gray-700 mb-2" %>
+ <%= f.text_field :sv, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
+
+
+
+ <%= f.label :no, "🇳🇴 Norwegian", class: "block text-sm font-medium text-gray-700 mb-2" %>
+ <%= f.text_field :no, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
+
+
+
+ <%= f.label :ru, "🇷🇺 Russian", class: "block text-sm font-medium text-gray-700 mb-2" %>
+ <%= f.text_field :ru, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
+
+
+
+ <%= f.label :de, "🇩🇪 German", class: "block text-sm font-medium text-gray-700 mb-2" %>
+ <%= f.text_field :de, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition" %>
+
+
+
+
+
+ <%= f.label :notes, "Additional Notes (optional)", class: "block text-sm font-semibold text-gray-700 mb-2" %>
+ <%= f.text_area :notes, rows: 4, class: "w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition", placeholder: "Any additional context or information about this entry..." %>
+
+
+
+ <%= f.submit "Submit Request", class: "flex-1 bg-indigo-600 hover:bg-indigo-700 text-white font-semibold py-3 px-6 rounded-lg transition shadow-md hover:shadow-lg" %>
+ <%= link_to "Cancel", root_path, class: "flex-1 text-center bg-gray-100 hover:bg-gray-200 text-gray-800 font-semibold py-3 px-6 rounded-lg transition" %>
+
+ <% end %>
+
+ <% unless current_user %>
+
+ Already have an account? <%= link_to "Sign in", login_path, class: "text-indigo-600 hover:text-indigo-800 font-semibold" %>
+
+ <% end %>
+
+
+
+
diff --git a/config/routes.rb b/config/routes.rb
index 33338ea..2c82939 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -25,6 +25,9 @@ Rails.application.routes.draw do
get "invitations/:token", to: "invitations#show", as: :invitation
patch "invitations/:token/accept", to: "invitations#update", as: :accept_invitation
+ # Public entry request routes
+ resources :requests, only: [:new, :create]
+
# Admin namespace
namespace :admin do
root "dashboard#index"
@@ -35,6 +38,12 @@ Rails.application.routes.draw do
put :resend
end
end
+ resources :requests, only: [ :index, :show, :edit, :update ] do
+ member do
+ post :approve
+ delete :reject
+ end
+ end
end
resources :entries do
diff --git a/db/migrate/20260129204705_add_status_to_entries.rb b/db/migrate/20260129204705_add_status_to_entries.rb
new file mode 100644
index 0000000..f6efa71
--- /dev/null
+++ b/db/migrate/20260129204705_add_status_to_entries.rb
@@ -0,0 +1,13 @@
+class AddStatusToEntries < ActiveRecord::Migration[8.1]
+ def change
+ add_column :entries, :status, :integer, default: 2, null: false
+ add_index :entries, :status
+
+ # Set all existing entries to status: 2 (active)
+ reversible do |dir|
+ dir.up do
+ execute "UPDATE entries SET status = 2"
+ end
+ end
+ end
+end
diff --git a/db/migrate/20260129204706_add_requested_by_to_entries.rb b/db/migrate/20260129204706_add_requested_by_to_entries.rb
new file mode 100644
index 0000000..8fc17f8
--- /dev/null
+++ b/db/migrate/20260129204706_add_requested_by_to_entries.rb
@@ -0,0 +1,7 @@
+class AddRequestedByToEntries < ActiveRecord::Migration[8.1]
+ def change
+ add_column :entries, :requested_by_id, :integer
+ add_foreign_key :entries, :users, column: :requested_by_id
+ add_index :entries, :requested_by_id
+ end
+end
diff --git a/db/structure.sql b/db/structure.sql
index 11d81d6..133a308 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -1,15 +1,5 @@
CREATE TABLE IF NOT EXISTS "schema_migrations" ("version" varchar NOT NULL PRIMARY KEY);
CREATE TABLE IF NOT EXISTS "ar_internal_metadata" ("key" varchar NOT NULL PRIMARY KEY, "value" varchar, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL);
-CREATE TABLE IF NOT EXISTS "entries" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "category" integer DEFAULT 0 NOT NULL, "fi" varchar, "en" varchar, "sv" varchar, "no" varchar, "ru" varchar, "de" varchar, "notes" text, "verified" boolean DEFAULT FALSE NOT NULL, "created_by_id" integer, "updated_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, CONSTRAINT "fk_rails_367d1ab731"
-FOREIGN KEY ("created_by_id")
- REFERENCES "users" ("id")
-, CONSTRAINT "fk_rails_6f84c41258"
-FOREIGN KEY ("updated_by_id")
- REFERENCES "users" ("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, "language_code" varchar /*application='SanastoWiki'*/, CONSTRAINT "fk_rails_03de2dc08c"
FOREIGN KEY ("user_id")
REFERENCES "users" ("id")
@@ -59,28 +49,25 @@ CREATE TABLE IF NOT EXISTS 'entries_fts_data'(id INTEGER PRIMARY KEY, block BLOB
CREATE TABLE IF NOT EXISTS 'entries_fts_idx'(segid, term, pgno, PRIMARY KEY(segid, term)) WITHOUT ROWID;
CREATE TABLE IF NOT EXISTS 'entries_fts_docsize'(id INTEGER PRIMARY KEY, sz BLOB);
CREATE TABLE IF NOT EXISTS 'entries_fts_config'(k PRIMARY KEY, v) WITHOUT ROWID;
-CREATE TRIGGER entries_fts_after_insert
-AFTER INSERT ON entries
-BEGIN
- INSERT INTO entries_fts(rowid, fi, en, sv, no, ru, de, notes)
- VALUES (new.id, new.fi, new.en, new.sv, new.no, new.ru, new.de, new.notes);
-END;
-CREATE TRIGGER entries_fts_after_update
-AFTER UPDATE ON entries
-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);
- INSERT INTO entries_fts(rowid, fi, en, sv, no, ru, de, notes)
- VALUES (new.id, new.fi, new.en, new.sv, new.no, new.ru, new.de, new.notes);
-END;
-CREATE TRIGGER entries_fts_after_delete
-AFTER DELETE ON entries
-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);
+CREATE TABLE IF NOT EXISTS "entries" ("id" integer PRIMARY KEY AUTOINCREMENT NOT NULL, "category" integer DEFAULT 0 NOT NULL, "fi" varchar, "en" varchar, "sv" varchar, "no" varchar, "ru" varchar, "de" varchar, "notes" text, "verified" boolean DEFAULT FALSE NOT NULL, "created_by_id" integer, "updated_by_id" integer, "created_at" datetime(6) NOT NULL, "updated_at" datetime(6) NOT NULL, "status" integer DEFAULT 2 NOT NULL, "requested_by_id" integer, CONSTRAINT "fk_rails_6f84c41258"
+FOREIGN KEY ("updated_by_id")
+ REFERENCES "users" ("id")
+, CONSTRAINT "fk_rails_367d1ab731"
+FOREIGN KEY ("created_by_id")
+ REFERENCES "users" ("id")
+, CONSTRAINT "fk_rails_4d36fd8a36"
+FOREIGN KEY ("requested_by_id")
+ REFERENCES "users" ("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 INDEX "index_entries_on_status" ON "entries" ("status") /*application='SanastoWiki'*/;
+CREATE INDEX "index_entries_on_requested_by_id" ON "entries" ("requested_by_id") /*application='SanastoWiki'*/;
INSERT INTO "schema_migrations" (version) VALUES
+('20260129204706'),
+('20260129204705'),
('20260123130957'),
('20260123125325'),
('20260122131000'),
diff --git a/public/favicon.ico b/public/favicon.ico
index ee6b906..83208a9 100644
Binary files a/public/favicon.ico and b/public/favicon.ico differ
diff --git a/public/icon.png b/public/icon.png
index bbc2adf..be5ad17 100644
Binary files a/public/icon.png and b/public/icon.png differ
diff --git a/public/icon.svg b/public/icon.svg
index 4e891e1..8a3d9ef 100644
--- a/public/icon.svg
+++ b/public/icon.svg
@@ -2,8 +2,9 @@
diff --git a/test/README_REQUESTS_TESTING.md b/test/README_REQUESTS_TESTING.md
new file mode 100644
index 0000000..f22f8ce
--- /dev/null
+++ b/test/README_REQUESTS_TESTING.md
@@ -0,0 +1,141 @@
+# Entry Request System Test Coverage
+
+## Test Files Created
+
+### 1. Model Tests: `test/models/entry_request_test.rb`
+Tests for Entry model enhancements:
+- ✅ Validates that at least one translation is required
+- ✅ Entry status defaults to "active"
+- ✅ Scopes (requested, approved, active_entries) work correctly
+- ✅ requested_by association functions properly
+- ✅ Status transitions (requested → approved → active)
+- ✅ Blank translations are properly handled
+
+**13 tests, 40 assertions**
+
+### 2. Public Controller Tests: `test/controllers/requests_controller_test.rb`
+Tests for public entry request submission:
+- ✅ Shows new request form for anonymous users (with name/email fields)
+- ✅ Shows new request form for logged-in users (without name/email fields)
+- ✅ Creates entry request with valid data
+- ✅ Requires at least one translation
+- ✅ Redirects to login if email already exists with accepted invitation
+- ✅ Shows pending count for email with existing requests
+- ✅ Creates entry with single or multiple translations
+- ✅ Logged-in user can submit request without providing name/email
+- ✅ Does not modify existing user when they submit request
+- ✅ Reuses existing pending user without modifying them
+- ✅ Transaction rollback on validation failure
+
+**11 tests, 78 assertions**
+
+### 3. Admin Controller Tests: `test/controllers/admin/requests_controller_test.rb`
+Tests for admin request management:
+- ✅ Requires admin authentication
+- ✅ Shows requests index with requested and approved sections
+- ✅ Lists requested and approved entries
+- ✅ Shows request details
+- ✅ Shows edit form for requested entry
+- ✅ Updates entry details
+- ✅ Validates entry data on update
+- ✅ Approves request and sends invitation email
+- ✅ Rejects request and deletes entry/user
+- ✅ Preserves user if they have multiple entries
+- ✅ Blocks access for non-admin users (contributors, reviewers)
+
+**14 tests, 85 assertions**
+
+### 4. Integration Tests: `test/integration/entry_request_flow_test.rb`
+Full end-to-end flow tests:
+- ✅ Complete flow: request → admin approve → user accepts → entry active
+- ✅ Rejected request removes entry and user
+- ✅ Requested/approved entries not visible on public site
+- ✅ Multiple entries by same requester all activated on invitation acceptance
+- ✅ Admin can edit entry details before approval
+- ✅ Cannot submit request with existing user email
+
+**6 tests**
+
+## Fixtures Added
+
+### Updated: `test/fixtures/entries.yml`
+- Added `status: 2` (active) to existing entries
+- Added `requested_entry` fixture (status: requested)
+- Added `approved_entry` fixture (status: approved)
+
+### Updated: `test/fixtures/users.yml`
+- Added `requester_user` fixture (user without accepted invitation)
+
+### 5. Mailer Tests: `test/mailers/invitation_mailer_test.rb`
+Invitation email tests including entry approval notifications:
+- ✅ Sends email with correct details
+- ✅ Includes invitation link and expiry date
+- ✅ Has both HTML and text parts
+- ✅ **With approved entry: includes entry details in email**
+- ✅ **With approved entry: shows correct message and formatting**
+- ✅ Without approved entry: uses standard invitation message
+
+**7 tests, 38 assertions**
+
+## Test Summary
+
+**Total: 51 tests, 316 assertions**
+
+All tests passing ✅
+
+Full test suite: **131 tests, 566 assertions** ✅
+
+## Running the Tests
+
+Run all request-related tests:
+```bash
+bin/rails test test/models/entry_request_test.rb \
+ test/controllers/requests_controller_test.rb \
+ test/controllers/admin/requests_controller_test.rb \
+ test/integration/entry_request_flow_test.rb
+```
+
+Run individual test files:
+```bash
+bin/rails test test/models/entry_request_test.rb
+bin/rails test test/controllers/requests_controller_test.rb
+bin/rails test test/controllers/admin/requests_controller_test.rb
+bin/rails test test/integration/entry_request_flow_test.rb
+```
+
+Run specific test:
+```bash
+bin/rails test test/integration/entry_request_flow_test.rb:3
+```
+
+## Test Coverage Areas
+
+### Public Request Flow
+- Form display and validation (different for logged-in vs anonymous users)
+- User and entry creation
+- Email duplicate detection for active accounts
+- Logged-in users submit without providing name/email
+- Existing users are never modified during request submission
+- Existing pending users are reused without modification
+- Transaction safety
+
+### Admin Management Flow
+- Authentication and authorization
+- Request listing and filtering
+- Request details display
+- Entry editing before approval
+- Approval with invitation sending
+- Rejection with cleanup
+
+### Integration Flow
+- Complete user journey from request to active entry
+- Entry visibility rules (requested/approved not shown publicly)
+- Multi-entry approval and activation
+- Admin workflow with editing
+
+### Edge Cases
+- Validation failures with transaction rollback
+- User preservation when they have multiple entries
+- Expired invitations
+- Non-admin access attempts
+- Blank translations handling
diff --git a/test/controllers/admin/requests_controller_test.rb b/test/controllers/admin/requests_controller_test.rb
new file mode 100644
index 0000000..65aceb9
--- /dev/null
+++ b/test/controllers/admin/requests_controller_test.rb
@@ -0,0 +1,184 @@
+require "test_helper"
+
+class Admin::RequestsControllerTest < ActionDispatch::IntegrationTest
+ setup do
+ @admin = users(:admin_user)
+ @requested_entry = entries(:requested_entry)
+ @approved_entry = entries(:approved_entry)
+ login_as(@admin)
+ end
+
+ test "should require admin authentication" do
+ logout
+ get admin_requests_path
+ assert_redirected_to login_path
+ end
+
+ test "should show requests index" do
+ get admin_requests_path
+
+ assert_response :success
+ assert_select "h1", "Entry Requests"
+ assert_select "h2", /Pending Review/
+ assert_select "h2", /Approved/
+ end
+
+ test "should list requested entries" do
+ get admin_requests_path
+
+ assert_response :success
+ assert_select "td", text: @requested_entry.fi
+ assert_select "a[href=?]", admin_request_path(@requested_entry)
+ end
+
+ test "should list approved entries" do
+ get admin_requests_path
+
+ assert_response :success
+ assert_select "td", text: @approved_entry.fi
+ end
+
+ test "should show request details" do
+ get admin_request_path(@requested_entry)
+
+ 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
+ end
+
+ test "should show edit form for requested entry" do
+ get edit_admin_request_path(@requested_entry)
+
+ assert_response :success
+ assert_select "h1", "Edit Entry Request"
+ assert_select "form[action=?]", admin_request_path(@requested_entry)
+ assert_select "input[name='entry[fi]'][value=?]", @requested_entry.fi
+ end
+
+ test "should update entry details" do
+ patch admin_request_path(@requested_entry), params: {
+ entry: {
+ category: "phrase",
+ fi: "päivitetty sana",
+ en: "updated word",
+ notes: "Updated notes"
+ }
+ }
+
+ @requested_entry.reload
+ assert_equal "phrase", @requested_entry.category
+ assert_equal "päivitetty sana", @requested_entry.fi
+ assert_equal "updated word", @requested_entry.en
+ assert_equal "Updated notes", @requested_entry.notes
+ assert_redirected_to admin_request_path(@requested_entry)
+ assert_equal "Request updated successfully.", flash[:notice]
+ end
+
+ test "should not update with invalid data" do
+ patch admin_request_path(@requested_entry), params: {
+ entry: {
+ fi: "",
+ en: "",
+ sv: "",
+ no: "",
+ ru: "",
+ de: ""
+ }
+ }
+
+ assert_response :unprocessable_entity
+ @requested_entry.reload
+ assert_equal "testisana", @requested_entry.fi # Unchanged
+ end
+
+ test "should approve request and send invitation" do
+ user = @requested_entry.requested_by
+ assert_nil user.invitation_token
+ assert_nil user.invitation_sent_at
+
+ assert_enqueued_emails 1 do
+ post approve_admin_request_path(@requested_entry)
+ end
+
+ user.reload
+ @requested_entry.reload
+
+ assert_equal "approved", @requested_entry.status
+ assert_not_nil user.invitation_token
+ assert_not_nil user.invitation_sent_at
+ assert_equal @admin, user.invited_by
+ assert_redirected_to admin_requests_path
+ assert_match(/invitation sent/i, flash[:notice])
+ end
+
+ test "should not approve already approved entry" do
+ # Try to approve an already approved entry
+ user = @approved_entry.requested_by
+
+ post approve_admin_request_path(@approved_entry)
+
+ @approved_entry.reload
+ assert_equal "approved", @approved_entry.status
+ end
+
+ test "should reject request and delete entry and user" do
+ user = @requested_entry.requested_by
+ entry_id = @requested_entry.id
+ user_id = user.id
+
+ assert_difference("Entry.count", -1) do
+ assert_difference("User.count", -1) do
+ delete reject_admin_request_path(@requested_entry)
+ end
+ end
+
+ assert_not Entry.exists?(entry_id)
+ assert_not User.exists?(user_id)
+ assert_redirected_to admin_requests_path
+ assert_match(/rejected and deleted/i, flash[:notice])
+ end
+
+ test "should reject but not delete user with multiple entries" do
+ user = @requested_entry.requested_by
+
+ # Create another entry for the same user
+ another_entry = Entry.create!(
+ category: :word,
+ fi: "toinen sana",
+ en: "another word",
+ status: :requested,
+ requested_by: user
+ )
+
+ assert_difference("Entry.count", -1) do
+ assert_no_difference("User.count") do
+ delete reject_admin_request_path(@requested_entry)
+ end
+ end
+
+ assert User.exists?(user.id)
+ assert Entry.exists?(another_entry.id)
+ end
+
+ test "contributors should not access admin requests" do
+ logout
+ contributor = users(:contributor_user)
+ login_as(contributor)
+
+ get admin_requests_path
+ assert_redirected_to root_path
+ assert_match(/administrator/i, flash[:alert])
+ end
+
+ test "reviewers should not access admin requests" do
+ logout
+ reviewer = users(:reviewer_user)
+ login_as(reviewer)
+
+ get admin_requests_path
+ assert_redirected_to root_path
+ assert_match(/administrator/i, flash[:alert])
+ end
+end
diff --git a/test/controllers/requests_controller_test.rb b/test/controllers/requests_controller_test.rb
new file mode 100644
index 0000000..825b653
--- /dev/null
+++ b/test/controllers/requests_controller_test.rb
@@ -0,0 +1,223 @@
+require "test_helper"
+
+class RequestsControllerTest < ActionDispatch::IntegrationTest
+ test "should show new request form for anonymous users" do
+ get new_request_path
+
+ assert_response :success
+ assert_select "h1", "Request a New Entry"
+ assert_select "form"
+ assert_select "input[name='entry[name]']"
+ assert_select "input[name='entry[email]']"
+ assert_select "select[name='entry[category]']"
+ end
+
+ test "should show new request form for logged-in users without name/email fields" do
+ login_as(users(:contributor_user))
+ get new_request_path
+
+ assert_response :success
+ assert_select "h1", "Request a New Entry"
+ assert_select "form"
+ assert_select "input[name='entry[name]']", count: 0
+ assert_select "input[name='entry[email]']", count: 0
+ assert_select ".bg-blue-50", text: /Submitting as/
+ end
+
+ test "should create entry request with valid data" do
+ assert_difference(["User.count", "Entry.count"], 1) do
+ post requests_path, params: {
+ entry: {
+ name: "New Requester",
+ email: "newrequester@example.com",
+ category: "word",
+ fi: "uusi sana",
+ en: "new word",
+ notes: "Please add this word"
+ }
+ }
+ end
+
+ entry = Entry.last
+ user = User.last
+
+ assert_equal "requested", entry.status
+ assert_equal user, entry.requested_by
+ assert_equal "New Requester", user.name
+ assert_equal "newrequester@example.com", user.email
+ assert_equal "contributor", user.role
+ assert_nil user.invitation_token
+ assert_redirected_to root_path
+ assert_match(/thank you for your request/i, flash[:notice])
+ end
+
+ test "should require at least one translation" do
+ assert_no_difference(["User.count", "Entry.count"]) do
+ post requests_path, params: {
+ entry: {
+ name: "New Requester",
+ email: "newrequester@example.com",
+ category: "word",
+ notes: "No translations provided"
+ }
+ }
+ end
+
+ assert_response :unprocessable_entity
+ assert_select ".bg-red-50", text: /At least one language translation is required/
+ end
+
+ test "should redirect to login if email already exists" do
+ existing_user = users(:contributor_user)
+
+ assert_no_difference(["User.count", "Entry.count"]) do
+ post requests_path, params: {
+ entry: {
+ name: "Test User",
+ email: existing_user.email,
+ category: "word",
+ fi: "sana",
+ en: "word"
+ }
+ }
+ end
+
+ assert_redirected_to login_path
+ assert_equal "An account with this email already exists. Please log in.", flash[:alert]
+ end
+
+ test "should show pending count for email with existing requests" do
+ requester = users(:requester_user)
+ get new_request_path, params: { email: requester.email }
+
+ assert_response :success
+ # User has one requested entry from fixtures
+ assert_select ".bg-blue-50", text: /1 pending request/
+ end
+
+ test "should create entry with only one translation" do
+ assert_difference(["User.count", "Entry.count"], 1) do
+ post requests_path, params: {
+ entry: {
+ name: "Single Translation",
+ email: "single@example.com",
+ category: "word",
+ fi: "vain suomeksi"
+ }
+ }
+ end
+
+ entry = Entry.last
+ assert_equal "vain suomeksi", entry.fi
+ assert_nil entry.en
+ assert_nil entry.sv
+ assert_redirected_to root_path
+ end
+
+ test "should create entry with multiple translations" do
+ assert_difference(["User.count", "Entry.count"], 1) do
+ post requests_path, params: {
+ entry: {
+ name: "Multi Lingual",
+ email: "multilingual@example.com",
+ category: "phrase",
+ fi: "hyvää päivää",
+ en: "good day",
+ sv: "god dag",
+ no: "god dag",
+ ru: "добрый день",
+ de: "guten Tag"
+ }
+ }
+ end
+
+ entry = Entry.last
+ assert_equal "hyvää päivää", entry.fi
+ assert_equal "good day", entry.en
+ assert_equal "god dag", entry.sv
+ assert_equal "god dag", entry.no
+ assert_equal "добрый день", entry.ru
+ assert_equal "guten Tag", entry.de
+ assert_redirected_to root_path
+ end
+
+ test "logged-in user can submit request without providing name/email" do
+ user = users(:contributor_user)
+ login_as(user)
+
+ assert_no_difference("User.count") do
+ assert_difference("Entry.count", 1) do
+ post requests_path, params: {
+ entry: {
+ category: "word",
+ fi: "kirjautunut käyttäjä",
+ en: "logged in user"
+ }
+ }
+ end
+ end
+
+ entry = Entry.last
+ assert_equal user, entry.requested_by
+ assert_equal "requested", entry.status
+ assert_redirected_to root_path
+ assert_match(/thank you for your request/i, flash[:notice])
+ end
+
+ test "should not modify existing user when they submit request" do
+ user = users(:contributor_user)
+ original_name = user.name
+ original_updated_at = user.updated_at
+ login_as(user)
+
+ post requests_path, params: {
+ entry: {
+ category: "word",
+ fi: "testi"
+ }
+ }
+
+ user.reload
+ assert_equal original_name, user.name
+ assert_equal original_updated_at.to_i, user.updated_at.to_i
+ end
+
+ test "should reuse existing pending user without modifying them" do
+ # Create a user without accepted invitation
+ existing_user = User.create!(
+ name: "Pending User",
+ email: "pending_test@example.com",
+ password: SecureRandom.alphanumeric(32),
+ role: :contributor
+ )
+ original_name = existing_user.name
+ original_updated_at = existing_user.updated_at
+
+ # Create first entry
+ Entry.create!(
+ category: :word,
+ fi: "first",
+ status: :requested,
+ requested_by: existing_user
+ )
+
+ # Submit second request with same email but different name
+ assert_no_difference("User.count") do
+ assert_difference("Entry.count", 1) do
+ post requests_path, params: {
+ entry: {
+ name: "Different Name", # This should be ignored
+ email: existing_user.email,
+ category: "word",
+ fi: "second"
+ }
+ }
+ end
+ end
+
+ existing_user.reload
+ assert_equal original_name, existing_user.name # Name should not change
+ assert_equal original_updated_at.to_i, existing_user.updated_at.to_i # Should not be updated
+ assert_equal 2, existing_user.requested_entries.count
+ end
+end
diff --git a/test/fixtures/entries.yml b/test/fixtures/entries.yml
index f427c21..9a80db3 100644
--- a/test/fixtures/entries.yml
+++ b/test/fixtures/entries.yml
@@ -12,6 +12,7 @@ one:
verified: false
created_by: admin_user
updated_by: admin_user
+ status: 2 # active
two:
category: 1
@@ -25,3 +26,34 @@ two:
verified: false
created_by: contributor_user
updated_by: contributor_user
+ status: 2 # active
+
+requested_entry:
+ category: 0 # word
+ fi: "testisana"
+ en: "testword"
+ sv: ~
+ 'no': ~
+ ru: ~
+ de: ~
+ notes: "This is a test entry request"
+ verified: false
+ created_by: ~
+ updated_by: ~
+ status: 0 # requested
+ requested_by: requester_user
+
+approved_entry:
+ category: 1 # phrase
+ fi: "hyväksytty fraasi"
+ en: "approved phrase"
+ sv: ~
+ 'no': ~
+ ru: ~
+ de: ~
+ notes: "This entry has been approved"
+ verified: false
+ created_by: ~
+ updated_by: ~
+ status: 1 # approved
+ requested_by: pending_invitation
diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml
index d81a52d..6a0725b 100644
--- a/test/fixtures/users.yml
+++ b/test/fixtures/users.yml
@@ -44,3 +44,14 @@ pending_invitation:
invitation_sent_at: <%= 2.days.ago %>
invitation_accepted_at: ~
invited_by: admin_user
+
+requester_user:
+ email: "requester@example.com"
+ password_digest: <%= BCrypt::Password.create('password123456') %>
+ name: "Entry Requester"
+ role: 0 # contributor
+ primary_language: "en"
+ invitation_token: ~
+ invitation_sent_at: ~
+ invitation_accepted_at: ~
+ invited_by: ~
diff --git a/test/integration/entry_request_flow_test.rb b/test/integration/entry_request_flow_test.rb
new file mode 100644
index 0000000..21a3b78
--- /dev/null
+++ b/test/integration/entry_request_flow_test.rb
@@ -0,0 +1,240 @@
+require "test_helper"
+
+class EntryRequestFlowTest < ActionDispatch::IntegrationTest
+ test "complete flow: request -> admin approve -> user accepts -> entry active" do
+ # Step 1: Public user submits entry request
+ assert_difference(["User.count", "Entry.count"], 1) do
+ post requests_path, params: {
+ entry: {
+ name: "Flow Test User",
+ email: "flowtest@example.com",
+ category: "word",
+ fi: "testi",
+ en: "test",
+ notes: "Testing complete flow"
+ }
+ }
+ end
+
+ assert_redirected_to root_path
+ follow_redirect!
+ assert_response :success
+
+ entry = Entry.last
+ user = User.last
+
+ assert_equal "requested", entry.status
+ assert_equal user, entry.requested_by
+ assert_nil user.invitation_token
+
+ # Step 2: Admin reviews and approves request
+ admin = users(:admin_user)
+ login_as(admin)
+
+ get admin_requests_path
+ assert_response :success
+ assert_select "td", text: entry.fi
+
+ # View the request
+ get admin_request_path(entry)
+ assert_response :success
+ assert_select "div", text: entry.fi
+
+ # Approve the request
+ assert_enqueued_emails 1 do
+ post approve_admin_request_path(entry)
+ end
+
+ entry.reload
+ user.reload
+
+ assert_equal "approved", entry.status
+ assert_not_nil user.invitation_token
+ assert_not_nil user.invitation_sent_at
+ assert_equal admin, user.invited_by
+
+ # Verify the invitation email includes entry information
+ perform_enqueued_jobs
+ sent_email = ActionMailer::Base.deliveries.last
+ assert_match "Your entry request has been approved", sent_email.subject
+ assert_match entry.fi, sent_email.body.encoded
+ assert_match entry.en, sent_email.body.encoded
+
+ logout
+
+ # Step 3: User accepts invitation
+ get invitation_path(user.invitation_token)
+ assert_response :success
+
+ patch accept_invitation_path(user.invitation_token), params: {
+ user: {
+ password: "securepassword123",
+ password_confirmation: "securepassword123"
+ }
+ }
+
+ entry.reload
+ user.reload
+
+ # Entry should now be active
+ assert_equal "active", entry.status
+ assert_not_nil user.invitation_accepted_at
+ assert_nil user.invitation_token
+ assert_equal user.id, session[:user_id]
+
+ # Step 4: Verify entry is visible on public site
+ get root_path
+ assert_response :success
+ # Entry should be in search results
+ end
+
+ test "rejected request removes entry and user" do
+ # Create a request
+ post requests_path, params: {
+ entry: {
+ name: "To Be Rejected",
+ email: "rejected@example.com",
+ category: "word",
+ fi: "hylätty",
+ en: "rejected"
+ }
+ }
+
+ assert_redirected_to root_path
+
+ entry = Entry.last
+ user = User.last
+ entry_id = entry.id
+ user_id = user.id
+
+ # Admin rejects it
+ login_as(users(:admin_user))
+
+ assert_difference(["User.count", "Entry.count"], -1) do
+ delete reject_admin_request_path(entry)
+ end
+
+ assert_not Entry.exists?(entry_id)
+ assert_not User.exists?(user_id)
+ end
+
+ test "requested and approved entries not visible on public site" do
+ requested = entries(:requested_entry)
+ approved = entries(:approved_entry)
+ active = entries(:one)
+
+ get root_path
+ assert_response :success
+
+ # Active entry should be counted
+ assert_match /#{Entry.active_entries.count}/, response.body
+
+ # Verify counts exclude requested/approved entries
+ total_entries = Entry.count
+ active_entries = Entry.active_entries.count
+
+ assert total_entries > active_entries, "Should have non-active entries in fixtures"
+ end
+
+ test "multiple entries by same requester all activated on invitation acceptance" do
+ # Create user with multiple approved entries
+ user = User.create!(
+ name: "Multi Entry User",
+ email: "multi@example.com",
+ password: SecureRandom.alphanumeric(32),
+ role: :contributor,
+ invitation_token: "multi_token_123",
+ invitation_sent_at: 1.day.ago,
+ invited_by: users(:admin_user)
+ )
+
+ entry1 = Entry.create!(
+ category: :word,
+ fi: "sana1",
+ en: "word1",
+ status: :approved,
+ requested_by: user
+ )
+
+ entry2 = Entry.create!(
+ category: :phrase,
+ fi: "fraasi1",
+ en: "phrase1",
+ status: :approved,
+ requested_by: user
+ )
+
+ # User accepts invitation
+ patch accept_invitation_path("multi_token_123"), params: {
+ user: {
+ password: "securepassword123",
+ password_confirmation: "securepassword123"
+ }
+ }
+
+ entry1.reload
+ entry2.reload
+
+ assert_equal "active", entry1.status
+ assert_equal "active", entry2.status
+ end
+
+ test "admin can edit entry details before approval" do
+ # Create a request
+ post requests_path, params: {
+ entry: {
+ name: "Needs Editing",
+ email: "edit@example.com",
+ category: "word",
+ fi: "väärin kirjoitettu",
+ en: "wrong spelling"
+ }
+ }
+
+ assert_redirected_to root_path
+
+ entry = Entry.last
+
+ # Admin edits the entry
+ login_as(users(:admin_user))
+
+ get edit_admin_request_path(entry)
+ assert_response :success
+
+ patch admin_request_path(entry), params: {
+ entry: {
+ fi: "oikein kirjoitettu",
+ en: "correct spelling",
+ category: "phrase"
+ }
+ }
+
+ entry.reload
+ assert_equal "oikein kirjoitettu", entry.fi
+ assert_equal "correct spelling", entry.en
+ assert_equal "phrase", entry.category
+
+ # Then approve with corrected details
+ post approve_admin_request_path(entry)
+
+ entry.reload
+ assert_equal "approved", entry.status
+ assert_equal "oikein kirjoitettu", entry.fi
+ end
+
+ test "cannot submit request with existing user email" do
+ existing_user = users(:contributor_user)
+
+ post requests_path, params: {
+ entry: {
+ name: "Test",
+ email: existing_user.email,
+ category: "word",
+ fi: "test"
+ }
+ }
+
+ assert_redirected_to login_path
+ assert_match(/already exists/i, flash[:alert])
+ end
+end
diff --git a/test/mailers/invitation_mailer_test.rb b/test/mailers/invitation_mailer_test.rb
index d31ddae..83682e5 100644
--- a/test/mailers/invitation_mailer_test.rb
+++ b/test/mailers/invitation_mailer_test.rb
@@ -35,4 +35,50 @@ class InvitationMailerTest < ActionMailer::TestCase
assert_equal "text/plain", mail.text_part.content_type.split(";").first
assert_equal "text/html", mail.html_part.content_type.split(";").first
end
+
+ test "invite with approved_entry includes entry details" do
+ user = users(:requester_user)
+ user.update!(
+ invitation_token: SecureRandom.urlsafe_base64(32),
+ invitation_sent_at: Time.current
+ )
+ entry = entries(:requested_entry)
+ mail = InvitationMailer.invite(user, approved_entry: entry)
+
+ assert_equal "Your entry request has been approved - Join Sanasto Wiki", mail.subject
+ assert_equal [ user.email ], mail.to
+
+ # Check that entry details are included
+ assert_match entry.fi, mail.body.encoded
+ assert_match entry.en, mail.body.encoded
+ assert_match entry.category.to_s.humanize, mail.body.encoded
+ assert_match "approved", mail.body.encoded.downcase
+ end
+
+ test "invite with approved_entry shows correct message" do
+ user = users(:requester_user)
+ user.update!(
+ invitation_token: SecureRandom.urlsafe_base64(32),
+ invitation_sent_at: Time.current
+ )
+ entry = entries(:requested_entry)
+ mail = InvitationMailer.invite(user, approved_entry: entry)
+
+ # HTML part should contain the entry box
+ assert_match "Your Approved Entry", mail.html_part.body.encoded
+ assert_match "entry-box", mail.html_part.body.encoded
+
+ # Text part should contain entry details
+ assert_match "YOUR APPROVED ENTRY", mail.text_part.body.encoded
+ assert_match "Category:", mail.text_part.body.encoded
+ end
+
+ test "invite without approved_entry uses standard message" do
+ user = users(:pending_invitation)
+ mail = InvitationMailer.invite(user)
+
+ assert_equal "You've been invited to join Sanasto Wiki", mail.subject
+ assert_match "you can contribute to this work", mail.body.encoded.downcase
+ assert_no_match "approved", mail.body.encoded.downcase
+ end
end
diff --git a/test/models/entry_request_test.rb b/test/models/entry_request_test.rb
new file mode 100644
index 0000000..494175b
--- /dev/null
+++ b/test/models/entry_request_test.rb
@@ -0,0 +1,128 @@
+require "test_helper"
+
+class EntryRequestTest < ActiveSupport::TestCase
+ test "entry requires at least one translation" do
+ entry = Entry.new(
+ category: :word,
+ status: :requested
+ )
+
+ assert_not entry.valid?
+ assert_includes entry.errors[:base], "At least one language translation is required"
+ end
+
+ test "entry is valid with one translation" do
+ entry = Entry.new(
+ category: :word,
+ fi: "sana",
+ status: :requested
+ )
+
+ assert entry.valid?
+ end
+
+ test "entry is valid with multiple translations" do
+ entry = Entry.new(
+ category: :word,
+ fi: "sana",
+ en: "word",
+ sv: "ord",
+ status: :requested
+ )
+
+ assert entry.valid?
+ end
+
+ test "entry status defaults to active" do
+ entry = Entry.new(
+ category: :word,
+ fi: "test"
+ )
+
+ assert_equal "active", entry.status
+ end
+
+ test "requested scope returns only requested entries" do
+ requested_entries = Entry.requested
+ assert_includes requested_entries, entries(:requested_entry)
+ assert_not_includes requested_entries, entries(:one)
+ assert_not_includes requested_entries, entries(:approved_entry)
+ end
+
+ test "approved scope returns only approved entries" do
+ approved_entries = Entry.approved
+ assert_includes approved_entries, entries(:approved_entry)
+ assert_not_includes approved_entries, entries(:one)
+ assert_not_includes approved_entries, entries(:requested_entry)
+ end
+
+ test "active_entries scope returns only active entries" do
+ active_entries = Entry.active_entries
+ assert_includes active_entries, entries(:one)
+ assert_includes active_entries, entries(:two)
+ assert_not_includes active_entries, entries(:requested_entry)
+ assert_not_includes active_entries, entries(:approved_entry)
+ end
+
+ test "requested_by association" do
+ entry = entries(:requested_entry)
+ requester = users(:requester_user)
+
+ assert_equal requester, entry.requested_by
+ end
+
+ test "user has requested_entries association" do
+ requester = users(:requester_user)
+
+ assert_includes requester.requested_entries, entries(:requested_entry)
+ end
+
+ test "can create entry with requested status" do
+ user = users(:requester_user)
+ entry = Entry.create!(
+ category: :word,
+ fi: "uusi",
+ en: "new",
+ status: :requested,
+ requested_by: user
+ )
+
+ assert_equal "requested", entry.status
+ assert_equal user, entry.requested_by
+ end
+
+ test "can transition entry from requested to approved to active" do
+ entry = entries(:requested_entry)
+
+ assert_equal "requested", entry.status
+
+ entry.update!(status: :approved)
+ assert_equal "approved", entry.status
+
+ entry.update!(status: :active)
+ assert_equal "active", entry.status
+ end
+
+ test "entries count does not include requested or approved" do
+ total = Entry.count
+ active = Entry.active_entries.count
+ requested = Entry.requested.count
+ approved = Entry.approved.count
+
+ assert_equal total, active + requested + approved
+ assert requested > 0, "Should have at least one requested entry in fixtures"
+ assert approved > 0, "Should have at least one approved entry in fixtures"
+ end
+
+ test "blank translations are not counted as having translation" do
+ entry = Entry.new(
+ category: :word,
+ fi: "",
+ en: " ",
+ sv: nil
+ )
+
+ assert_not entry.valid?
+ assert_includes entry.errors[:base], "At least one language translation is required"
+ end
+end
diff --git a/test/models/entry_test.rb b/test/models/entry_test.rb
index 8321cf2..e4235bc 100644
--- a/test/models/entry_test.rb
+++ b/test/models/entry_test.rb
@@ -1,8 +1,8 @@
require "test_helper"
class EntryTest < ActiveSupport::TestCase
- test "should be valid with a category" do
- entry = Entry.new(category: :word)
+ test "should be valid with a category and at least one translation" do
+ entry = Entry.new(category: :word, fi: "test")
assert entry.valid?
end