invitation emails
This commit is contained in:
@@ -0,0 +1,46 @@
|
||||
# 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.
|
||||
|
||||
### 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.
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -21,4 +21,23 @@ class User < ApplicationRecord
|
||||
|
||||
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
|
||||
|
||||
@@ -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>You are invited to 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.
|
||||
@@ -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>
|
||||
@@ -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"
|
||||
|
||||
+10
-5
@@ -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
|
||||
@@ -60,7 +60,7 @@
|
||||
|
||||
- [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 +126,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)
|
||||
|
||||
@@ -22,6 +22,18 @@ class Admin::InvitationsControllerTest < ActionDispatch::IntegrationTest
|
||||
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
|
||||
@@ -48,6 +60,21 @@ class Admin::InvitationsControllerTest < ActionDispatch::IntegrationTest
|
||||
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))
|
||||
|
||||
|
||||
@@ -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,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
|
||||
Reference in New Issue
Block a user