Aggiunge gestione account con cambio password su web e app.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
module Api
|
||||
module V1
|
||||
class AccountsController < ApplicationController
|
||||
def show
|
||||
render json: user_json(current_user)
|
||||
end
|
||||
|
||||
def update
|
||||
name = params[:name].to_s.strip
|
||||
if name.blank?
|
||||
return render json: { error: "Name is required" }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if current_user.update(name: name)
|
||||
render json: user_json(current_user)
|
||||
else
|
||||
render json: { error: current_user.errors.full_messages.join(", ") }, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def password
|
||||
result = Users::ChangePassword.call(
|
||||
user: current_user,
|
||||
current_password: params[:current_password],
|
||||
password: params[:password],
|
||||
password_confirmation: params[:password_confirmation]
|
||||
)
|
||||
|
||||
unless result.ok?
|
||||
return render json: { error: password_error_message(result.error) }, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
render json: { message: "Password updated" }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def user_json(user)
|
||||
{
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
name: user.name,
|
||||
role: user.role
|
||||
}
|
||||
end
|
||||
|
||||
def password_error_message(code)
|
||||
case code
|
||||
when :current_incorrect then "Current password is incorrect"
|
||||
when :too_short then "Password must be at least 8 characters"
|
||||
when :mismatch then "Passwords do not match"
|
||||
else "Unable to update password"
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,7 +1,7 @@
|
||||
module Api
|
||||
module V1
|
||||
class AuthController < ApplicationController
|
||||
skip_before_action :authenticate_request!, only: %i[login refresh register]
|
||||
skip_before_action :authenticate_request!, only: %i[login refresh register forgot_password]
|
||||
|
||||
def register
|
||||
user = User.new(
|
||||
@@ -42,6 +42,13 @@ module Api
|
||||
render json: user_json(current_user)
|
||||
end
|
||||
|
||||
def forgot_password
|
||||
Users::RequestPasswordReset.call(email: params[:email])
|
||||
render json: {
|
||||
message: "If the email is registered, you will receive a password reset link shortly."
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def token_response(user)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
module Public
|
||||
class AccountsController < WebBaseController
|
||||
before_action :require_login!
|
||||
|
||||
def show
|
||||
end
|
||||
|
||||
def update
|
||||
name = params[:name].to_s.strip
|
||||
if name.blank?
|
||||
flash.now[:alert] = t("flash.accounts.name_required")
|
||||
return render :show, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
if current_user.update(name: name)
|
||||
redirect_to public_account_path, notice: t("flash.accounts.profile_updated")
|
||||
else
|
||||
flash.now[:alert] = current_user.errors.full_messages.to_sentence
|
||||
render :show, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def update_password
|
||||
result = Users::ChangePassword.call(
|
||||
user: current_user,
|
||||
current_password: params[:current_password],
|
||||
password: params[:password],
|
||||
password_confirmation: params[:password_confirmation]
|
||||
)
|
||||
|
||||
unless result.ok?
|
||||
flash.now[:alert] = t("flash.accounts.password_#{result.error}")
|
||||
return render :show, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
redirect_to public_account_path, notice: t("flash.accounts.password_updated")
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -4,11 +4,7 @@ module Public
|
||||
end
|
||||
|
||||
def create
|
||||
user = User.find_by(email: params[:email]&.downcase&.strip)
|
||||
if user
|
||||
token = user.generate_password_reset!
|
||||
UserMailer.password_reset(user, token).deliver_now
|
||||
end
|
||||
Users::RequestPasswordReset.call(email: params[:email])
|
||||
|
||||
redirect_to public_login_path,
|
||||
notice: t("flash.password_resets.email_sent")
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
module Users
|
||||
class ChangePassword
|
||||
Result = Struct.new(:ok?, :error, keyword_init: true)
|
||||
|
||||
def self.call(user:, current_password:, password:, password_confirmation:)
|
||||
new(
|
||||
user: user,
|
||||
current_password: current_password,
|
||||
password: password,
|
||||
password_confirmation: password_confirmation
|
||||
).call
|
||||
end
|
||||
|
||||
def initialize(user:, current_password:, password:, password_confirmation:)
|
||||
@user = user
|
||||
@current_password = current_password.to_s
|
||||
@password = password.to_s
|
||||
@password_confirmation = password_confirmation.to_s
|
||||
end
|
||||
|
||||
def call
|
||||
unless @user.authenticate(@current_password)
|
||||
return Result.new(ok?: false, error: :current_incorrect)
|
||||
end
|
||||
|
||||
if @password.blank? || @password.length < 8
|
||||
return Result.new(ok?: false, error: :too_short)
|
||||
end
|
||||
|
||||
if @password != @password_confirmation
|
||||
return Result.new(ok?: false, error: :mismatch)
|
||||
end
|
||||
|
||||
@user.update!(password: @password)
|
||||
@user.clear_password_reset!
|
||||
Result.new(ok?: true)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
module Users
|
||||
class RequestPasswordReset
|
||||
def self.call(email:)
|
||||
new(email: email).call
|
||||
end
|
||||
|
||||
def initialize(email:)
|
||||
@email = email.to_s.downcase.strip
|
||||
end
|
||||
|
||||
def call
|
||||
user = User.find_by(email: @email)
|
||||
return if user.nil?
|
||||
|
||||
token = user.generate_password_reset!
|
||||
UserMailer.password_reset(user, token).deliver_now
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -41,6 +41,7 @@
|
||||
<% elsif current_user.manageable_teams.any? %>
|
||||
· <%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first) %>
|
||||
<% end %>
|
||||
· <%= link_to t("nav.account"), public_account_path %>
|
||||
· <%= button_to t("nav.logout"), public_logout_path, method: :delete, form: { style: "display:inline" }, class: "btn btn-secondary", style: "padding:6px 12px;font-size:0.85rem" %>
|
||||
<% else %>
|
||||
· <%= link_to t("nav.login"), public_login_path %>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
<% content_for :title, t("auth.account.meta_title") %>
|
||||
<% content_for :meta_description, t("auth.account.meta_description") %>
|
||||
<% content_for :robots, "noindex, nofollow" %>
|
||||
|
||||
<section class="auth-page">
|
||||
<h1><%= t("auth.account.title") %></h1>
|
||||
|
||||
<div class="card" style="margin-bottom:1.5rem">
|
||||
<h2 style="font-size:1.1rem;margin-top:0"><%= t("auth.account.profile_heading") %></h2>
|
||||
<%= form_with url: public_account_path, method: :patch, local: true do %>
|
||||
<p>
|
||||
<label for="account_email"><%= t("auth.email") %></label><br>
|
||||
<input type="email" id="account_email" value="<%= current_user.email %>" readonly
|
||||
class="input" autocomplete="username"
|
||||
style="width:100%;opacity:0.75;cursor:not-allowed">
|
||||
</p>
|
||||
<p class="muted" style="margin-top:-0.5rem;font-size:0.9rem">
|
||||
<%= t("auth.account.role_label", role: current_user.role) %>
|
||||
</p>
|
||||
<%= render "shared/input_toggle",
|
||||
name: :name,
|
||||
label: t("auth.account.name_label"),
|
||||
value: current_user.name,
|
||||
required: true,
|
||||
autocomplete: "name",
|
||||
masked: false %>
|
||||
<%= submit_tag t("auth.account.save_profile"), class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2 style="font-size:1.1rem;margin-top:0"><%= t("auth.account.password_heading") %></h2>
|
||||
<%= form_with url: public_account_password_path, method: :patch, local: true do %>
|
||||
<%= render "shared/input_toggle",
|
||||
name: :current_password,
|
||||
label: t("auth.account.current_password_label"),
|
||||
input_type: "password",
|
||||
required: true,
|
||||
autocomplete: "current-password" %>
|
||||
<%= render "shared/input_toggle",
|
||||
name: :password,
|
||||
label: t("auth.account.new_password_label"),
|
||||
input_type: "password",
|
||||
required: true,
|
||||
autocomplete: "new-password" %>
|
||||
<%= render "shared/input_toggle",
|
||||
name: :password_confirmation,
|
||||
label: t("auth.password_confirmation"),
|
||||
input_type: "password",
|
||||
required: true,
|
||||
autocomplete: "new-password" %>
|
||||
<%= submit_tag t("auth.account.save_password"), class: "btn btn-primary" %>
|
||||
<% end %>
|
||||
</div>
|
||||
</section>
|
||||
@@ -34,6 +34,7 @@
|
||||
<%= link_to t("nav.my_team"), public_team_details_path(current_user.manageable_teams.first), class: "nav-link-item" %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
<%= link_to t("nav.account"), public_account_path, class: (request.path == "/account" ? "nav-link-item nav-active" : "nav-link-item") %>
|
||||
<%= button_to t("nav.logout"), public_logout_path, method: :delete, class: "btn btn-secondary nav-btn" %>
|
||||
<% else %>
|
||||
<%= link_to t("nav.login"), public_login_path, class: "nav-link-item" %>
|
||||
|
||||
@@ -606,6 +606,13 @@ de:
|
||||
password_min_length: Das Passwort muss mindestens 8 Zeichen lang sein
|
||||
password_mismatch: Die Passwörter stimmen nicht überein
|
||||
password_updated: Passwort aktualisiert. Du kannst dich jetzt anmelden.
|
||||
accounts:
|
||||
name_required: Der Name ist erforderlich
|
||||
profile_updated: Profil aktualisiert.
|
||||
password_current_incorrect: Das aktuelle Passwort ist falsch
|
||||
password_too_short: Das Passwort muss mindestens 8 Zeichen haben
|
||||
password_mismatch: Die Passwörter stimmen nicht überein
|
||||
password_updated: Passwort aktualisiert.
|
||||
replay:
|
||||
download_unavailable: Download nicht verfügbar
|
||||
not_available: Replay nicht verfügbar
|
||||
|
||||
@@ -606,6 +606,13 @@ en:
|
||||
password_min_length: Password must be at least 8 characters
|
||||
password_mismatch: Passwords don't match
|
||||
password_updated: Password updated. You can now log in.
|
||||
accounts:
|
||||
name_required: Name is required
|
||||
profile_updated: Profile updated.
|
||||
password_current_incorrect: Current password is incorrect
|
||||
password_too_short: Password must be at least 8 characters
|
||||
password_mismatch: Passwords do not match
|
||||
password_updated: Password updated.
|
||||
replay:
|
||||
download_unavailable: Download not available
|
||||
not_available: Replay not available
|
||||
|
||||
@@ -606,6 +606,13 @@ es:
|
||||
password_min_length: La contraseña debe tener al menos 8 caracteres
|
||||
password_mismatch: Las contraseñas no coinciden
|
||||
password_updated: Contraseña actualizada. Ya puedes iniciar sesión.
|
||||
accounts:
|
||||
name_required: El nombre es obligatorio
|
||||
profile_updated: Perfil actualizado.
|
||||
password_current_incorrect: La contraseña actual no es correcta
|
||||
password_too_short: La contraseña debe tener al menos 8 caracteres
|
||||
password_mismatch: Las contraseñas no coinciden
|
||||
password_updated: Contraseña actualizada.
|
||||
replay:
|
||||
download_unavailable: Descarga no disponible
|
||||
not_available: Repetición no disponible
|
||||
|
||||
@@ -606,6 +606,13 @@ fr:
|
||||
password_min_length: Le mot de passe doit comporter au moins 8 caractères
|
||||
password_mismatch: Les mots de passe ne correspondent pas
|
||||
password_updated: Mot de passe mis à jour. Tu peux maintenant te connecter.
|
||||
accounts:
|
||||
name_required: Le nom est obligatoire
|
||||
profile_updated: Profil mis à jour.
|
||||
password_current_incorrect: Le mot de passe actuel est incorrect
|
||||
password_too_short: Le mot de passe doit contenir au moins 8 caractères
|
||||
password_mismatch: Les mots de passe ne correspondent pas
|
||||
password_updated: Mot de passe mis à jour.
|
||||
replay:
|
||||
download_unavailable: Téléchargement non disponible
|
||||
not_available: Replay non disponible
|
||||
|
||||
@@ -606,6 +606,13 @@ it:
|
||||
password_min_length: La password deve avere almeno 8 caratteri
|
||||
password_mismatch: Le password non coincidono
|
||||
password_updated: Password aggiornata. Ora puoi accedere.
|
||||
accounts:
|
||||
name_required: Il nome è obbligatorio
|
||||
profile_updated: Profilo aggiornato.
|
||||
password_current_incorrect: La password attuale non è corretta
|
||||
password_too_short: La password deve avere almeno 8 caratteri
|
||||
password_mismatch: Le password non coincidono
|
||||
password_updated: Password aggiornata.
|
||||
replay:
|
||||
download_unavailable: Download non disponibile
|
||||
not_available: Replay non disponibile
|
||||
|
||||
@@ -16,6 +16,7 @@ de:
|
||||
my_team: Mein Team
|
||||
login: Anmelden
|
||||
logout: Abmelden
|
||||
account: Konto
|
||||
signup: Team registrieren
|
||||
footer:
|
||||
tagline: Jedes Spiel, jedes Event, für alle, die nicht dabei sein können
|
||||
@@ -118,6 +119,18 @@ de:
|
||||
signup_link: registrieren Sie sich
|
||||
mobile_app_label: "Mobile App:"
|
||||
open_in_app: Einladung in der App öffnen
|
||||
account:
|
||||
meta_title: "Dein Konto — Match Live TV"
|
||||
meta_description: Name und Passwort deines Match Live TV-Kontos verwalten.
|
||||
title: Dein Konto
|
||||
profile_heading: Profil
|
||||
password_heading: Passwort ändern
|
||||
name_label: Name
|
||||
role_label: "Rolle: %{role}"
|
||||
current_password_label: Aktuelles Passwort
|
||||
new_password_label: "Neues Passwort (mind. 8 Zeichen)"
|
||||
save_profile: Profil speichern
|
||||
save_password: Passwort aktualisieren
|
||||
common:
|
||||
privacy: Datenschutz
|
||||
cookies: Cookies
|
||||
|
||||
@@ -16,6 +16,7 @@ en:
|
||||
my_team: My team
|
||||
login: Log in
|
||||
logout: Log out
|
||||
account: Account
|
||||
signup: Register a team
|
||||
footer:
|
||||
tagline: Every match, every event, for those who can't be there
|
||||
@@ -118,6 +119,18 @@ en:
|
||||
signup_link: sign up
|
||||
mobile_app_label: "Mobile app:"
|
||||
open_in_app: Open invitation in the app
|
||||
account:
|
||||
meta_title: "Your account — Match Live TV"
|
||||
meta_description: Manage your Match Live TV account name and password.
|
||||
title: Your account
|
||||
profile_heading: Profile
|
||||
password_heading: Change password
|
||||
name_label: Name
|
||||
role_label: "Role: %{role}"
|
||||
current_password_label: Current password
|
||||
new_password_label: "New password (min. 8 characters)"
|
||||
save_profile: Save profile
|
||||
save_password: Update password
|
||||
common:
|
||||
privacy: Privacy
|
||||
cookies: Cookies
|
||||
|
||||
@@ -16,6 +16,7 @@ es:
|
||||
my_team: Mi equipo
|
||||
login: Acceder
|
||||
logout: Salir
|
||||
account: Cuenta
|
||||
signup: Registrar equipo
|
||||
footer:
|
||||
tagline: Cada partido, cada evento, para quien no puede estar
|
||||
@@ -118,6 +119,18 @@ es:
|
||||
signup_link: regístrate
|
||||
mobile_app_label: "App móvil:"
|
||||
open_in_app: Abrir invitación en la app
|
||||
account:
|
||||
meta_title: "Tu cuenta — Match Live TV"
|
||||
meta_description: Gestiona el nombre y la contraseña de tu cuenta Match Live TV.
|
||||
title: Tu cuenta
|
||||
profile_heading: Perfil
|
||||
password_heading: Cambiar contraseña
|
||||
name_label: Nombre
|
||||
role_label: "Rol: %{role}"
|
||||
current_password_label: Contraseña actual
|
||||
new_password_label: "Nueva contraseña (mín. 8 caracteres)"
|
||||
save_profile: Guardar perfil
|
||||
save_password: Actualizar contraseña
|
||||
common:
|
||||
privacy: Privacidad
|
||||
cookies: Cookies
|
||||
|
||||
@@ -16,6 +16,7 @@ fr:
|
||||
my_team: Mon équipe
|
||||
login: Connexion
|
||||
logout: Déconnexion
|
||||
account: Compte
|
||||
signup: Inscrire une équipe
|
||||
footer:
|
||||
tagline: Chaque match, chaque événement, pour ceux qui ne peuvent pas être là
|
||||
@@ -118,6 +119,18 @@ fr:
|
||||
signup_link: inscrivez-vous
|
||||
mobile_app_label: "Application mobile :"
|
||||
open_in_app: Ouvrir l'invitation dans l'application
|
||||
account:
|
||||
meta_title: "Votre compte — Match Live TV"
|
||||
meta_description: Gérez le nom et le mot de passe de votre compte Match Live TV.
|
||||
title: Votre compte
|
||||
profile_heading: Profil
|
||||
password_heading: Changer le mot de passe
|
||||
name_label: Nom
|
||||
role_label: "Rôle : %{role}"
|
||||
current_password_label: Mot de passe actuel
|
||||
new_password_label: "Nouveau mot de passe (min. 8 caractères)"
|
||||
save_profile: Enregistrer le profil
|
||||
save_password: Mettre à jour le mot de passe
|
||||
common:
|
||||
privacy: Confidentialité
|
||||
cookies: Cookies
|
||||
|
||||
@@ -16,6 +16,7 @@ it:
|
||||
my_team: La mia squadra
|
||||
login: Accedi
|
||||
logout: Esci
|
||||
account: Account
|
||||
signup: Registra squadra
|
||||
footer:
|
||||
tagline: Ogni partita, ogni evento, per chi non può esserci
|
||||
@@ -118,6 +119,18 @@ it:
|
||||
signup_link: registrati
|
||||
mobile_app_label: "App mobile:"
|
||||
open_in_app: Apri invito nell'app
|
||||
account:
|
||||
meta_title: "Il tuo account — Match Live TV"
|
||||
meta_description: Gestisci nome e password del tuo account Match Live TV.
|
||||
title: Il tuo account
|
||||
profile_heading: Profilo
|
||||
password_heading: Cambia password
|
||||
name_label: Nome
|
||||
role_label: "Ruolo: %{role}"
|
||||
current_password_label: Password attuale
|
||||
new_password_label: "Nuova password (min. 8 caratteri)"
|
||||
save_profile: Salva profilo
|
||||
save_password: Aggiorna password
|
||||
common:
|
||||
privacy: Privacy
|
||||
cookies: Cookie
|
||||
|
||||
@@ -11,6 +11,10 @@ Rails.application.routes.draw do
|
||||
post "auth/logout", to: "auth#logout"
|
||||
post "auth/refresh", to: "auth#refresh"
|
||||
get "auth/me", to: "auth#me"
|
||||
post "auth/password/forgot", to: "auth#forgot_password"
|
||||
get "account", to: "accounts#show"
|
||||
patch "account", to: "accounts#update"
|
||||
patch "account/password", to: "accounts#password"
|
||||
get "sports", to: "sports#index"
|
||||
|
||||
get "invitations/:token", to: "invitations#show"
|
||||
@@ -163,6 +167,9 @@ Rails.application.routes.draw do
|
||||
post "password/forgot", to: "password_resets#create"
|
||||
get "password/reset", to: "password_resets#edit", as: :password_reset
|
||||
patch "password/reset", to: "password_resets#update"
|
||||
get "account", to: "accounts#show", as: :account
|
||||
patch "account", to: "accounts#update"
|
||||
patch "account/password", to: "accounts#update_password", as: :account_password
|
||||
get "clubs/new", to: "clubs#new", as: :new_club
|
||||
post "clubs", to: "clubs#create"
|
||||
get "clubs/:id", to: "clubs#show", as: :club
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Account API", type: :request do
|
||||
let!(:user) { User.create!(email: "account@example.com", name: "Account User", password: "password123", role: "coach") }
|
||||
let(:auth_headers) do
|
||||
post "/api/v1/auth/login", params: { email: user.email, password: "password123" }
|
||||
token = JSON.parse(response.body).fetch("access_token")
|
||||
{ "Authorization" => "Bearer #{token}" }
|
||||
end
|
||||
|
||||
describe "GET /api/v1/account" do
|
||||
it "returns the current user profile" do
|
||||
get "/api/v1/account", headers: auth_headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
body = JSON.parse(response.body)
|
||||
expect(body).to include("email" => user.email, "name" => "Account User", "role" => "coach")
|
||||
end
|
||||
|
||||
it "requires authentication" do
|
||||
get "/api/v1/account"
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
end
|
||||
end
|
||||
|
||||
describe "PATCH /api/v1/account" do
|
||||
it "updates the name" do
|
||||
patch "/api/v1/account", params: { name: "Nuovo Nome" }, headers: auth_headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(JSON.parse(response.body)["name"]).to eq("Nuovo Nome")
|
||||
expect(user.reload.name).to eq("Nuovo Nome")
|
||||
end
|
||||
|
||||
it "rejects a blank name" do
|
||||
patch "/api/v1/account", params: { name: " " }, headers: auth_headers
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
end
|
||||
end
|
||||
|
||||
describe "PATCH /api/v1/account/password" do
|
||||
it "changes the password with the current password" do
|
||||
patch "/api/v1/account/password",
|
||||
params: {
|
||||
current_password: "password123",
|
||||
password: "newpass123",
|
||||
password_confirmation: "newpass123"
|
||||
},
|
||||
headers: auth_headers
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(user.reload.authenticate("newpass123")).to be_truthy
|
||||
end
|
||||
|
||||
it "rejects an incorrect current password" do
|
||||
patch "/api/v1/account/password",
|
||||
params: {
|
||||
current_password: "wrong",
|
||||
password: "newpass123",
|
||||
password_confirmation: "newpass123"
|
||||
},
|
||||
headers: auth_headers
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(user.reload.authenticate("password123")).to be_truthy
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /api/v1/auth/password/forgot" do
|
||||
it "always returns ok and sends mail when the user exists" do
|
||||
expect {
|
||||
post "/api/v1/auth/password/forgot", params: { email: user.email }
|
||||
}.to change { ActionMailer::Base.deliveries.size }.by(1)
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(user.reload.password_reset_digest).to be_present
|
||||
end
|
||||
|
||||
it "returns the same message for unknown emails" do
|
||||
expect {
|
||||
post "/api/v1/auth/password/forgot", params: { email: "nobody@example.com" }
|
||||
}.not_to change { ActionMailer::Base.deliveries.size }
|
||||
expect(response).to have_http_status(:ok)
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user