diff --git a/.rspec b/.rspec new file mode 100644 index 0000000..bd78e92 --- /dev/null +++ b/.rspec @@ -0,0 +1,2 @@ +--require spec_helper +--format documentation diff --git a/README.md b/README.md index c6090b8..65d5e77 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,59 @@ docker-compose exec app bundle exec whenever --update-crontab --- +## 🧪 Auditoria + Suíte de testes (15/06/2026) + +Revisão de código + criação da suíte RSpec (o projeto tinha `rspec-rails`/`factory_bot`/`capybara` no Gemfile, mas **nenhum teste** e **nenhum `spec/`**). + +### 🔴 Bugs corrigidos + +**1. Erro de sintaxe em `Admin::UsuariosController`** — `toggle_ativo` tinha `entidade: \'User\'` (aspas escapadas com barra). O arquivo não carregava → qualquer acesso a `/admin/usuarios` dava 500. ✅ corrigido. + +**2. Migration quebrava o setup do zero** — `20260610_add_fase2_fields_to_users.rb` readicionava a coluna `ativo` (já criada em `create_users`) sem guard → `PG::DuplicateColumn` num `db:migrate` limpo. ✅ guard `unless column_exists?` em `ativo` e `pin_acesso`. + +**3. `db:seed` falhava no 2º motorista** — motoristas sem e-mail colidiam no índice único de `email` (`default ""`). ✅ seeds agora geram placeholder único (`nome.parameterize@motorista.local`). + +**4. Dashboard ignorava o filtro de conta Gade** — `DashboardController` e `HistoricoEstimado.{atualizar!,calcular_ao_vivo}` contavam entregas de **todas** as contas. ✅ aplicado `.da_conta_gade`. + +**5. `valor_total` com duas fontes de verdade** — o controller somava `valor_aplicado` (capturado na classificação) e o callback do model recalculava pelos **preços atuais** de `Configuracao` → divergência quando os preços mudavam. ✅ `ConsolidacaoMotorista#recalcular_valor` agora usa `valor_aplicado` (fonte única). + +**6. Gate de finalização + classificação sem validação** +- `todas_entregas_classificadas?` usava soma agregada com `>=`, mascarando motoristas pendentes quando outro tinha entregas de sobra. ✅ agora exige cada motorista completo (`all?`) e respeita o filtro de rotas. +- `classificar`/`classificar_em_massa` aceitavam `tracking_id`/`motorista` arbitrários. ✅ validam que o motorista pertence à consolidação e que a entrega é elegível no período/rota. + +**7. Coluna órfã `pin_acesso`** — nunca usada (o sistema usa `pin_code`). ✅ removida via migration. + +**Minors:** motorista logado conseguia abrir `/dashboard` (✅ redireciona ao painel do motorista); `Date.parse(params[:data])` sem rescue dava 500 (✅ cai em `Date.current`); `arquivar!` não registrava autor (✅ novas colunas `arquivado_por`/`arquivado_em` + `belongs_to :arquivador`); `MotoristaController` + `app/views/motorista/index.html.erb` órfãos e quebrados (`current_user.name`, `where(user:)`, status inexistentes) — ✅ removidos. + +### 🆕 Migrations adicionadas (rodar `db:migrate`) +``` +db/migrate/20260615000001_remove_pin_acesso_from_users.rb +db/migrate/20260615000002_add_arquivamento_to_consolidacoes.rb # arquivado_por / arquivado_em +``` + +### ✅ Suíte de testes +``` +.rspec +config/database.yml # ENV-driven, com banco de teste separado (_test) +spec/spec_helper.rb · spec/rails_helper.rb +spec/support/{pundit,devise}.rb +spec/factories.rb +spec/models/{user,configuracao,consolidacao,consolidacao_motorista,consolidacao_entrega,auditoria_log}_spec.rb +spec/policies/consolidacao_policy_spec.rb +spec/requests/dashboard_spec.rb +``` + +Como rodar: +```bash +docker-compose up -d +docker-compose exec app env RAILS_ENV=test bundle exec rails db:create db:migrate +docker-compose exec app bundle exec rspec +``` + +> Os specs que dependem da tabela read-only `db_reem_simplerout_2026` stubam `Entrega` — o banco de teste não contém essa tabela externa. + +--- + ## 🗄️ Banco existente — referência rápida | Campo | Uso | diff --git a/app/controllers/admin/usuarios_controller.rb b/app/controllers/admin/usuarios_controller.rb index 08041e0..3366537 100644 --- a/app/controllers/admin/usuarios_controller.rb +++ b/app/controllers/admin/usuarios_controller.rb @@ -61,7 +61,7 @@ class Admin::UsuariosController < ApplicationController authorize @usuario, :update? @usuario.update!(ativo: !@usuario.ativo) status = @usuario.ativo? ? 'ativado' : 'desativado' - AuditoriaLog.registrar(user: current_user, acao: "usuario_#{status}", entidade: \'User\', dados_novos: { detalhes: "#{@usuario.email} foi #{status}" }, request: request) + AuditoriaLog.registrar(user: current_user, acao: "usuario_#{status}", entidade: 'User', dados_novos: { detalhes: "#{@usuario.email} foi #{status}" }, request: request) redirect_to admin_usuarios_path, notice: "Usuário #{status} com sucesso." end diff --git a/app/controllers/consolidacao_entregas_controller.rb b/app/controllers/consolidacao_entregas_controller.rb index c1f9dc0..fd0d1de 100644 --- a/app/controllers/consolidacao_entregas_controller.rb +++ b/app/controllers/consolidacao_entregas_controller.rb @@ -42,6 +42,8 @@ class ConsolidacaoEntregasController < ApplicationController def classificar authorize @consolidacao, :update? + return render_nao_elegivel unless entrega_elegivel?(params[:motorista], params[:tracking_id]) + ce = @consolidacao.consolidacao_entregas.find_or_initialize_by(tracking_id: params[:tracking_id]) ce.motorista_nome = params[:motorista] ce.tipo = params[:tipo] @@ -66,14 +68,12 @@ class ConsolidacaoEntregasController < ApplicationController motorista = params[:motorista] tracking_ids = Array(params[:tracking_ids]).reject(&:blank?) - # "Marcar todos como Normal" → sem tracking_ids = todas do período - if tracking_ids.empty? - entregas = Entrega.pagas.da_conta_gade - .no_periodo(@consolidacao.data_inicio, @consolidacao.data_fim) - .do_motorista(motorista) - entregas = entregas.da_rota(@consolidacao.route_ids) if @consolidacao.route_ids.present? - tracking_ids = entregas.pluck(:tracking_id) - end + return render_nao_elegivel unless motorista_na_consolidacao?(motorista) + + elegiveis = tracking_ids_elegiveis(motorista) + # "Marcar todos como Normal" → sem tracking_ids = todas as elegíveis do período. + # Com tracking_ids → mantém só as que de fato são elegíveis para o motorista. + tracking_ids = tracking_ids.empty? ? elegiveis : (tracking_ids & elegiveis) valor = valor_para_tipo(tipo) @@ -118,9 +118,8 @@ class ConsolidacaoEntregasController < ApplicationController nomes_completos = @consolidacao.consolidacao_motoristas.map(&:motorista_nome) @proximo = nomes_completos.find do |nome| next false if nome == @motorista - total = Entrega.contar_pagas(inicio: @consolidacao.data_inicio, - fim: @consolidacao.data_fim, motorista: nome) - @consolidacao.consolidacao_entregas.where(motorista_nome: nome).count < total + total = @consolidacao.entregas_elegiveis_count(nome) + @consolidacao.classificadas_count(nome) < total end end @@ -135,6 +134,40 @@ class ConsolidacaoEntregasController < ApplicationController redirect_to @consolidacao, alert: 'Consolidação finalizada — apenas admin/gerente podem editar.' end + # Motorista realmente faz parte desta consolidação? + def motorista_na_consolidacao?(nome) + nome.present? && @consolidacao.consolidacao_motoristas.exists?(motorista_nome: nome) + end + + # tracking_ids pagos/elegíveis para o motorista no período (respeita rotas) + def tracking_ids_elegiveis(motorista) + return [] unless motorista.present? + + rel = Entrega.pagas.da_conta_gade + .no_periodo(@consolidacao.data_inicio, @consolidacao.data_fim) + .do_motorista(motorista) + rel = rel.da_rota(@consolidacao.route_ids) if @consolidacao.route_ids.present? + rel.pluck(:tracking_id) + end + + # Valida que a entrega pertence ao motorista informado e ao período/rota da + # consolidação — impede classificar tracking_ids arbitrários via requisição. + def entrega_elegivel?(motorista, tracking_id) + motorista_na_consolidacao?(motorista) && + tracking_id.present? && + tracking_ids_elegiveis(motorista).include?(tracking_id) + end + + def render_nao_elegivel + respond_to do |format| + format.json { render json: { ok: false, erro: 'Entrega não elegível para este motorista.' }, status: :unprocessable_entity } + format.html do + redirect_to validar_consolidacao_consolidacao_entregas_path(@consolidacao, motorista: params[:motorista]), + alert: 'Entrega não elegível para este motorista.' + end + end + end + def valor_para_tipo(tipo) case tipo.to_s when 'entrega_normal' then Configuracao.preco_entrega diff --git a/app/controllers/consolidacoes_controller.rb b/app/controllers/consolidacoes_controller.rb index f0f1059..b160e0c 100644 --- a/app/controllers/consolidacoes_controller.rb +++ b/app/controllers/consolidacoes_controller.rb @@ -76,14 +76,8 @@ class ConsolidacoesController < ApplicationController authorize @consolidacao, :update? @motoristas = @consolidacao.consolidacao_motoristas.order(:motorista_nome).map do |cm| - total = Entrega.contar_pagas( - inicio: @consolidacao.data_inicio, - fim: @consolidacao.data_fim, - motorista: cm.motorista_nome - ) - classificadas = @consolidacao.consolidacao_entregas - .where(motorista_nome: cm.motorista_nome) - .count + total = @consolidacao.entregas_elegiveis_count(cm.motorista_nome) + classificadas = @consolidacao.classificadas_count(cm.motorista_nome) { registro: cm, total: total, diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 2e46eda..c1e8bd5 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -3,7 +3,10 @@ class DashboardController < ApplicationController def index skip_authorization - @data_referencia = params[:data] ? Date.parse(params[:data]) : Date.today + # Motorista tem painel próprio — não enxerga o dashboard administrativo + return redirect_to(motorista_dashboard_path) if current_user.motorista? + + @data_referencia = parse_data(params[:data]) @mes_inicio = @data_referencia.beginning_of_month @mes_fim = @data_referencia.end_of_month @@ -12,9 +15,17 @@ class DashboardController < ApplicationController private + # Parse tolerante: data inválida/ausente cai no dia atual em vez de estourar 500. + def parse_data(str) + return Date.current if str.blank? + Date.parse(str) + rescue ArgumentError, TypeError + Date.current + end + def carregar_dados_dashboard - # Entregas do mês atual (da tabela read-only) - entregas_mes = Entrega.do_mes(@data_referencia) + # Entregas do mês atual (da tabela read-only) — restritas à conta Gade + entregas_mes = Entrega.da_conta_gade.do_mes(@data_referencia) # Totais gerais @total_entregas = entregas_mes.count diff --git a/app/controllers/motorista_controller.rb b/app/controllers/motorista_controller.rb deleted file mode 100644 index dea828a..0000000 --- a/app/controllers/motorista_controller.rb +++ /dev/null @@ -1,42 +0,0 @@ -# app/controllers/motorista_controller.rb -class MotoristaController < ApplicationController - before_action :verificar_motorista - - def index - skip_authorization - - @data_referencia = params[:data] ? Date.parse(params[:data]) : Date.today - config = Configuracao.mapa_de_precos - - # Entregas do motorista logado - @entregas_mes = Entrega - .do_mes(@data_referencia) - .where("LOWER(driver) = ?", current_user.name.downcase) - - @total_entregas = @entregas_mes.count - @pagas = @entregas_mes.pagas.count - @pendentes = @total_entregas - @pagas - @valor_estimado = (@pagas * config[:entrega]).round(2) - - # Últimas entregas - @ultimas_entregas = @entregas_mes.pagas - .order(planned_date: :desc) - .limit(10) - - # Consolidações do motorista - @consolidacoes = ConsolidacaoMotorista - .joins(:consolidacao) - .where(user: current_user) - .where(consolidacoes: { status: [:fechada, :aprovada] }) - .order('consolidacoes.created_at DESC') - .limit(5) - end - - private - - def verificar_motorista - unless current_user&.motorista? - redirect_to dashboard_path, alert: 'Acesso restrito a motoristas.' - end - end -end diff --git a/app/models/consolidacao.rb b/app/models/consolidacao.rb index dd883a4..779c555 100644 --- a/app/models/consolidacao.rb +++ b/app/models/consolidacao.rb @@ -2,6 +2,7 @@ class Consolidacao < ApplicationRecord belongs_to :criador, class_name: 'User', foreign_key: :created_by belongs_to :finalizador, class_name: 'User', foreign_key: :finalizado_por, optional: true + belongs_to :arquivador, class_name: 'User', foreign_key: :arquivado_por, optional: true has_many :consolidacao_motoristas, dependent: :destroy has_many :consolidacao_entregas, dependent: :destroy @@ -30,7 +31,12 @@ class Consolidacao < ApplicationRecord # ── Métodos ───────────────────────────────────────────────── def arquivar!(user) - update!(deleted_at: Time.current, status: :arquivada) + update!( + deleted_at: Time.current, + status: :arquivada, + arquivado_por: user&.id, + arquivado_em: Time.current + ) end def finalizar!(user) @@ -43,12 +49,14 @@ class Consolidacao < ApplicationRecord ) end + # Só está completa quando CADA motorista tem todas as suas entregas elegíveis + # classificadas. (Antes era uma soma agregada com >=, que mascarava motoristas + # pendentes quando outro tinha entregas a mais.) def todas_entregas_classificadas? - total_entregas = ConsolidacaoMotorista - .where(consolidacao_id: id) - .sum { |cm| Entrega.contar_pagas(inicio: data_inicio, fim: data_fim, motorista: cm.motorista_nome) } - - consolidacao_entregas.count >= total_entregas + consolidacao_motoristas.all? do |cm| + total = entregas_elegiveis_count(cm.motorista_nome) + total.zero? || classificadas_count(cm.motorista_nome) >= total + end end def percentual_classificado @@ -58,10 +66,26 @@ class Consolidacao < ApplicationRecord def total_entregas_estimadas @total_entregas_estimadas ||= consolidacao_motoristas.sum do |cm| - Entrega.contar_pagas(inicio: data_inicio, fim: data_fim, motorista: cm.motorista_nome) + entregas_elegiveis_count(cm.motorista_nome) end end + # Entregas pagas elegíveis para um motorista no período, respeitando o filtro + # de rotas da consolidação (quando houver). Fonte única usada pelo wizard, + # pela revisão e pelo gate de finalização. + def entregas_elegiveis_count(motorista) + Entrega.contar_pagas( + inicio: data_inicio, + fim: data_fim, + motorista: motorista, + route_id: route_ids.presence + ) + end + + def classificadas_count(motorista) + consolidacao_entregas.where(motorista_nome: motorista).count + end + private def periodo_valido diff --git a/app/models/consolidacao_motorista.rb b/app/models/consolidacao_motorista.rb index 5ecc190..a4b8d13 100644 --- a/app/models/consolidacao_motorista.rb +++ b/app/models/consolidacao_motorista.rb @@ -12,18 +12,12 @@ class ConsolidacaoMotorista < ApplicationRecord before_save :recalcular_valor def recalcular_valor - cfg = Configuracao + # Fonte única de verdade: o valor_aplicado capturado no momento da + # classificação (igual ao ConsolidacaoEntregasController#recalcular_motorista). + # Recalcular pelos preços atuais de Configuracao divergiria caso os preços + # mudassem depois das entregas já terem sido classificadas. self.valor_total = consolidacao_entregas - .includes(:consolidacao) .where(motorista_nome: motorista_nome) - .sum do |e| - case e.tipo - when 'entrega_normal' then cfg.preco_entrega - when 'retirada' then cfg.preco_retirada - when 'bonus' then cfg.preco_bonus - when 'desconto' then -cfg.preco_desconto - else 0 - end - end + .sum { |e| e.desconto? ? -e.valor_aplicado : e.valor_aplicado } end end diff --git a/app/models/historico_estimado.rb b/app/models/historico_estimado.rb index 090b459..1f542e6 100644 --- a/app/models/historico_estimado.rb +++ b/app/models/historico_estimado.rb @@ -10,7 +10,7 @@ class HistoricoEstimado < ApplicationRecord # Chamado pelo job a cada 1 hora (Whenever gem — Fase 4) def self.atualizar! preco = Configuracao.preco_entrega - entregas = Entrega.pagas.where(planned_date: Date.current).count + entregas = Entrega.da_conta_gade.pagas.where(planned_date: Date.current).count total = entregas * preco create!( @@ -23,7 +23,7 @@ class HistoricoEstimado < ApplicationRecord # Cálculo ao vivo para o Dashboard (sem esperar o job) def self.calcular_ao_vivo(inicio: Date.current.beginning_of_month, fim: Date.current) preco = Configuracao.preco_entrega - entregas = Entrega.pagas.where(planned_date: inicio..fim).count + entregas = Entrega.da_conta_gade.pagas.where(planned_date: inicio..fim).count { valor: entregas * preco, entregas: entregas diff --git a/app/views/motorista/index.html.erb b/app/views/motorista/index.html.erb deleted file mode 100644 index 076425f..0000000 --- a/app/views/motorista/index.html.erb +++ /dev/null @@ -1,96 +0,0 @@ -<%# app/views/motorista/index.html.erb %> -
- -
-
-

- Olá, <%= current_user.nome_display %>! 👋 -

-

- Suas entregas de <%= @data_referencia.strftime('%B/%Y').capitalize %> -

-
- <%= link_to new_user_session_path, method: :delete, - data: { turbo_method: :delete }, - class: 'px-4 py-2.5 bg-[#1a1a1a] border border-white/10 hover:border-red-500/50 - text-gray-400 hover:text-red-400 rounded-xl transition-colors text-sm' do %> - Sair - <% end %> -
- - <%# Cards do motorista %> -
-
-

💰 Estimado

-

<%= moeda(@valor_estimado) %>

-
-
-

Total

-

<%= @total_entregas %>

-
-
-

✅ Pagas

-

<%= @pagas %>

-
-
-

⏳ Pendentes

-

<%= @pendentes %>

-
-
- - <%# Últimas entregas %> -
-
-

Últimas Entregas Pagas

-
- <% if @ultimas_entregas.any? %> -
- <% @ultimas_entregas.each do |entrega| %> -
-
-

<%= entrega.contact_name %>

-

NF: <%= entrega.reference_id %>

-
-
-

✅ Paga

-

<%= entrega.data_formatada %>

-
-
- <% end %> -
- <% else %> -
-
🚚
-

Nenhuma entrega paga este mês ainda.

-
- <% end %> -
- - <%# Consolidações do motorista %> - <% if @consolidacoes.any? %> -
-
-

Seus Fechamentos

-
-
- <% @consolidacoes.each do |cm| %> -
-
-

<%= cm.consolidacao.mes_label %>

-

<%= cm.consolidacao_entregas.count %> entregas

-
-
- <%= badge_status(cm.consolidacao.status) %> - <% if cm.consolidacao.aprovada? %> - <%= link_to '#', class: 'text-[#f97316] text-sm hover:underline' do %> - 📄 PDF - <% end %> - <% end %> -
-
- <% end %> -
-
- <% end %> - -
diff --git a/db/migrate/20260610_add_fase2_fields_to_users.rb b/db/migrate/20260610_add_fase2_fields_to_users.rb index 43bbc02..809924d 100644 --- a/db/migrate/20260610_add_fase2_fields_to_users.rb +++ b/db/migrate/20260610_add_fase2_fields_to_users.rb @@ -3,11 +3,13 @@ class AddFase2FieldsToUsers < ActiveRecord::Migration[7.1] def change # PIN de acesso (motoristas) - add_column :users, :pin_acesso, :string, limit: 4 - add_index :users, :pin_acesso, unique: true, where: "pin_acesso IS NOT NULL" + unless column_exists?(:users, :pin_acesso) + add_column :users, :pin_acesso, :string, limit: 4 + add_index :users, :pin_acesso, unique: true, where: "pin_acesso IS NOT NULL" + end - # Status ativo/inativo - add_column :users, :ativo, :boolean, default: true, null: false + # Status ativo/inativo — já criado em CreateUsers; guarda contra duplicação + add_column :users, :ativo, :boolean, default: true, null: false unless column_exists?(:users, :ativo) # Acompanhamento de login (Devise :trackable) add_column :users, :sign_in_count, :integer, default: 0, null: false unless column_exists?(:users, :sign_in_count) diff --git a/db/migrate/20260615000001_remove_pin_acesso_from_users.rb b/db/migrate/20260615000001_remove_pin_acesso_from_users.rb new file mode 100644 index 0000000..5b2e152 --- /dev/null +++ b/db/migrate/20260615000001_remove_pin_acesso_from_users.rb @@ -0,0 +1,14 @@ +# Remove a coluna órfã pin_acesso. O sistema usa pin_code (model/seeds/login); +# pin_acesso nunca foi referenciado em lugar algum. +class RemovePinAcessoFromUsers < ActiveRecord::Migration[7.1] + def up + remove_column :users, :pin_acesso if column_exists?(:users, :pin_acesso) + end + + def down + return if column_exists?(:users, :pin_acesso) + + add_column :users, :pin_acesso, :string, limit: 4 + add_index :users, :pin_acesso, unique: true, where: "pin_acesso IS NOT NULL" + end +end diff --git a/db/migrate/20260615000002_add_arquivamento_to_consolidacoes.rb b/db/migrate/20260615000002_add_arquivamento_to_consolidacoes.rb new file mode 100644 index 0000000..e231430 --- /dev/null +++ b/db/migrate/20260615000002_add_arquivamento_to_consolidacoes.rb @@ -0,0 +1,7 @@ +# Registra QUEM e QUANDO arquivou uma consolidação (antes só havia deleted_at). +class AddArquivamentoToConsolidacoes < ActiveRecord::Migration[7.1] + def change + add_column :consolidacoes, :arquivado_por, :integer unless column_exists?(:consolidacoes, :arquivado_por) + add_column :consolidacoes, :arquivado_em, :datetime unless column_exists?(:consolidacoes, :arquivado_em) + end +end diff --git a/db/seeds.rb b/db/seeds.rb index 42b7981..d1cb621 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -55,6 +55,9 @@ puts " ⚠️ ALTERE A SENHA DO ADMIN NO PRIMEIRO ACESSO!" u = User.find_or_initialize_by(nome: m[:nome], role: :motorista) u.pin_code = m[:pin] u.ativo = true + # A coluna email tem índice único e NOT NULL (default ""). Motoristas não + # têm e-mail real, então geramos um placeholder único para não violar o índice. + u.email = "#{m[:nome].parameterize}@motorista.local" if u.email.blank? u.save!(validate: false) puts " ✅ Motorista: #{m[:nome]} / PIN: #{m[:pin]}" end diff --git a/spec/factories.rb b/spec/factories.rb new file mode 100644 index 0000000..e52dba6 --- /dev/null +++ b/spec/factories.rb @@ -0,0 +1,55 @@ +FactoryBot.define do + factory :user do + sequence(:nome) { |n| "Usuario Teste #{n}" } + sequence(:email) { |n| "user#{n}@gade.com" } + password { 'Gade@2026!' } + role { :operador } + ativo { true } + + factory :admin do role { :admin } end + factory :gerente do role { :gerente } end + factory :operador do role { :operador } end + + factory :motorista do + role { :motorista } + # email é NOT NULL + único no banco; motorista real não tem e-mail, então + # usamos um placeholder único (mesma estratégia do db/seeds.rb). + sequence(:email) { |n| "motorista#{n}@motorista.local" } + password { nil } + sequence(:nome) { |n| "Motorista #{n}" } + sequence(:pin_code) { |n| format('%04d', n % 10000) } + end + end + + factory :configuracao do + chave { 'empresa_nome' } + valor { 'Reem Transporte' } + end + + factory :consolidacao do + sequence(:nome) { |n| "Consolidacao #{n}" } + data_inicio { Date.new(2026, 6, 1) } + data_fim { Date.new(2026, 6, 30) } + association :criador, factory: :gerente + status { :rascunho } + end + + factory :consolidacao_motorista do + association :consolidacao + sequence(:motorista_nome) { |n| "Motorista #{n}" } + end + + factory :consolidacao_entrega do + association :consolidacao + sequence(:tracking_id) { |n| "TRK-#{n}" } + motorista_nome { 'Joao Silva' } + tipo { :entrega_normal } + valor_aplicado { 15.0 } + end + + factory :historico_estimado do + data_hora { Time.current } + valor_total_estimado { 150.0 } + entregas_contadas { 10 } + end +end diff --git a/spec/models/auditoria_log_spec.rb b/spec/models/auditoria_log_spec.rb new file mode 100644 index 0000000..0c3468c --- /dev/null +++ b/spec/models/auditoria_log_spec.rb @@ -0,0 +1,24 @@ +require 'rails_helper' + +RSpec.describe AuditoriaLog, type: :model do + describe '.registrar' do + it 'cria um log com os dados informados' do + user = create(:admin) + expect { + AuditoriaLog.registrar(user: user, acao: :login, entidade: 'User', entidade_id: user.id) + }.to change(AuditoriaLog, :count).by(1) + + log = AuditoriaLog.last + expect(log.acao).to eq('login') + expect(log.entidade).to eq('User') + expect(log.user_id).to eq(user.id) + end + + it 'não levanta exceção quando a gravação falha (apenas loga)' do + allow(AuditoriaLog).to receive(:create!).and_raise(StandardError, 'boom') + expect { + AuditoriaLog.registrar(user: nil, acao: :login, entidade: 'User') + }.not_to raise_error + end + end +end diff --git a/spec/models/configuracao_spec.rb b/spec/models/configuracao_spec.rb new file mode 100644 index 0000000..2938f86 --- /dev/null +++ b/spec/models/configuracao_spec.rb @@ -0,0 +1,46 @@ +require 'rails_helper' + +RSpec.describe Configuracao, type: :model do + describe 'validações' do + it 'só aceita chaves conhecidas' do + expect(build(:configuracao, chave: 'chave_invalida')).not_to be_valid + end + + it 'exige valor' do + expect(build(:configuracao, valor: nil)).not_to be_valid + end + + it 'não permite chave duplicada' do + create(:configuracao, chave: 'empresa_nome') + expect(build(:configuracao, chave: 'empresa_nome')).not_to be_valid + end + end + + describe 'helpers de preço' do + before do + create(:configuracao, chave: 'preco_entrega', valor: '15.00') + create(:configuracao, chave: 'preco_retirada', valor: '20.00') + end + + it 'converte o valor armazenado em float' do + expect(Configuracao.preco_entrega).to eq(15.0) + expect(Configuracao.preco_retirada).to eq(20.0) + end + + it 'retorna 0.0 quando a chave não existe' do + expect(Configuracao.preco_bonus).to eq(0.0) + end + end + + describe '#valor_formatado' do + it 'formata chaves de moeda em reais' do + cfg = build(:configuracao, chave: 'preco_entrega', valor: '15.5') + expect(cfg.valor_formatado).to eq('R$ 15,50') + end + + it 'devolve o valor cru para chaves não-monetárias' do + cfg = build(:configuracao, chave: 'empresa_nome', valor: 'Reem') + expect(cfg.valor_formatado).to eq('Reem') + end + end +end diff --git a/spec/models/consolidacao_entrega_spec.rb b/spec/models/consolidacao_entrega_spec.rb new file mode 100644 index 0000000..7f6f0cc --- /dev/null +++ b/spec/models/consolidacao_entrega_spec.rb @@ -0,0 +1,37 @@ +require 'rails_helper' + +RSpec.describe ConsolidacaoEntrega, type: :model do + describe 'validações' do + it 'exige tracking_id, motorista_nome, tipo e valor_aplicado' do + ce = ConsolidacaoEntrega.new + ce.valid? + expect(ce.errors[:tracking_id]).to be_present + expect(ce.errors[:motorista_nome]).to be_present + expect(ce.errors[:valor_aplicado]).to be_present + end + + it 'rejeita valor_aplicado negativo' do + expect(build(:consolidacao_entrega, valor_aplicado: -1)).not_to be_valid + end + + it 'exige tracking_id único por consolidação' do + consolidacao = create(:consolidacao) + create(:consolidacao_entrega, consolidacao: consolidacao, tracking_id: 'TRK-1') + dup = build(:consolidacao_entrega, consolidacao: consolidacao, tracking_id: 'TRK-1') + expect(dup).not_to be_valid + end + + it 'permite o mesmo tracking_id em consolidações diferentes' do + create(:consolidacao_entrega, tracking_id: 'TRK-X') + outra = build(:consolidacao_entrega, tracking_id: 'TRK-X') + expect(outra).to be_valid + end + end + + describe '#tipo_cor' do + it 'retorna o mapa de cores do tipo' do + ce = build(:consolidacao_entrega, tipo: :retirada) + expect(ce.tipo_cor[:label]).to eq('Retirada') + end + end +end diff --git a/spec/models/consolidacao_motorista_spec.rb b/spec/models/consolidacao_motorista_spec.rb new file mode 100644 index 0000000..c4b145b --- /dev/null +++ b/spec/models/consolidacao_motorista_spec.rb @@ -0,0 +1,49 @@ +require 'rails_helper' + +RSpec.describe ConsolidacaoMotorista, type: :model do + describe 'validações' do + it 'exige motorista_nome' do + expect(build(:consolidacao_motorista, motorista_nome: nil)).not_to be_valid + end + + it 'não permite o mesmo motorista duas vezes na consolidação' do + consolidacao = create(:consolidacao) + create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'Joao') + dup = build(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'Joao') + expect(dup).not_to be_valid + end + end + + describe '#recalcular_valor (fonte única: valor_aplicado)' do + let(:consolidacao) { create(:consolidacao) } + + before do + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'Joao', + tipo: :entrega_normal, valor_aplicado: 15) + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'Joao', + tipo: :bonus, valor_aplicado: 10) + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'Joao', + tipo: :desconto, valor_aplicado: 5) + end + + it 'soma usando valor_aplicado e trata desconto como negativo' do + cm = create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'Joao') + # 15 + 10 - 5 = 20 + expect(cm.valor_total).to eq(20) + end + + it 'NÃO recalcula pelos preços atuais de Configuracao' do + # Mesmo que os preços de Configuracao mudem, o total usa o valor_aplicado capturado. + allow(Configuracao).to receive(:preco_entrega).and_return(999) + cm = create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'Joao') + expect(cm.valor_total).to eq(20) + end + + it 'considera apenas as entregas do próprio motorista' do + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'Maria', + tipo: :entrega_normal, valor_aplicado: 100) + cm = create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'Joao') + expect(cm.valor_total).to eq(20) + end + end +end diff --git a/spec/models/consolidacao_spec.rb b/spec/models/consolidacao_spec.rb new file mode 100644 index 0000000..aca583c --- /dev/null +++ b/spec/models/consolidacao_spec.rb @@ -0,0 +1,102 @@ +require 'rails_helper' + +RSpec.describe Consolidacao, type: :model do + describe 'validação de período' do + it 'rejeita data_fim anterior a data_inicio' do + c = build(:consolidacao, data_inicio: Date.new(2026, 6, 10), data_fim: Date.new(2026, 6, 1)) + expect(c).not_to be_valid + expect(c.errors[:data_fim]).to be_present + end + + it 'aceita período válido' do + expect(build(:consolidacao)).to be_valid + end + end + + describe '#recalcular_valor_total (before_save)' do + it 'soma os valores dos motoristas da consolidação' do + consolidacao = create(:consolidacao) + create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'A') + create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'B') + consolidacao.consolidacao_motoristas.update_all(valor_total: 30) + + consolidacao.save! + expect(consolidacao.reload.valor_total).to eq(60) + end + end + + describe '#finalizar!' do + let(:consolidacao) { create(:consolidacao) } + let(:gerente) { create(:gerente) } + + it 'não finaliza quando há entregas não classificadas' do + allow(consolidacao).to receive(:todas_entregas_classificadas?).and_return(false) + expect(consolidacao.finalizar!(gerente)).to be_falsey + expect(consolidacao.reload).to be_rascunho + end + + it 'finaliza e registra finalizador quando tudo está classificado' do + allow(consolidacao).to receive(:todas_entregas_classificadas?).and_return(true) + consolidacao.finalizar!(gerente) + consolidacao.reload + expect(consolidacao).to be_finalizada + expect(consolidacao.finalizado_por).to eq(gerente.id) + expect(consolidacao.finalizado_em).to be_present + end + end + + describe '#arquivar!' do + it 'marca deleted_at, status arquivada e registra quem arquivou' do + gerente = create(:gerente) + consolidacao = create(:consolidacao) + consolidacao.arquivar!(gerente) + consolidacao.reload + expect(consolidacao).to be_arquivada + expect(consolidacao.deleted_at).to be_present + expect(consolidacao.arquivado_por).to eq(gerente.id) + expect(consolidacao.arquivado_em).to be_present + expect(consolidacao.arquivador).to eq(gerente) + end + end + + describe '#todas_entregas_classificadas? (gate por motorista)' do + let(:consolidacao) { create(:consolidacao) } + + before do + create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'A') + create(:consolidacao_motorista, consolidacao: consolidacao, motorista_nome: 'B') + end + + it 'é falso quando UM motorista tem pendências, mesmo que outro tenha entregas de sobra' do + allow(consolidacao).to receive(:entregas_elegiveis_count).and_return(2) + # A: 0 classificadas | B: 4 classificadas. A soma agregada (4 >= 4) passaria, + # mas a checagem por motorista deve reprovar. + create_list(:consolidacao_entrega, 4, consolidacao: consolidacao, motorista_nome: 'B') + expect(consolidacao.todas_entregas_classificadas?).to be false + end + + it 'é verdadeiro quando cada motorista está completo' do + allow(consolidacao).to receive(:entregas_elegiveis_count).and_return(1) + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'A') + create(:consolidacao_entrega, consolidacao: consolidacao, motorista_nome: 'B') + expect(consolidacao.todas_entregas_classificadas?).to be true + end + + it 'ignora motoristas sem entregas elegíveis (total zero)' do + allow(consolidacao).to receive(:entregas_elegiveis_count).and_return(0) + expect(consolidacao.todas_entregas_classificadas?).to be true + end + end + + describe 'escopos de soft delete' do + it 'separa ativas de arquivadas' do + ativa = create(:consolidacao) + arquivada = create(:consolidacao) + arquivada.arquivar!(create(:gerente)) + + expect(Consolidacao.ativas).to include(ativa) + expect(Consolidacao.ativas).not_to include(arquivada) + expect(Consolidacao.arquivadas).to include(arquivada) + end + end +end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb new file mode 100644 index 0000000..75b4dff --- /dev/null +++ b/spec/models/user_spec.rb @@ -0,0 +1,60 @@ +require 'rails_helper' + +RSpec.describe User, type: :model do + describe 'validações de nome' do + it 'exige nome com ao menos 3 caracteres' do + user = build(:operador, nome: 'ab') + expect(user).not_to be_valid + expect(user.errors[:nome]).to be_present + end + end + + describe 'e-mail e senha por papel' do + it 'exige e-mail para não-motoristas' do + expect(build(:operador).email_required?).to be true + expect(build(:operador, email: nil)).not_to be_valid + end + + it 'NÃO exige e-mail nem senha para motoristas (login por PIN)' do + motorista = build(:motorista, email: nil, password: nil) + expect(motorista.email_required?).to be false + expect(motorista.password_required?).to be false + expect(motorista).to be_valid + end + end + + describe 'validação de PIN (somente motoristas)' do + it 'aceita PIN de 4 dígitos numéricos' do + expect(build(:motorista, pin_code: '0420')).to be_valid + end + + it 'rejeita PIN com tamanho diferente de 4' do + expect(build(:motorista, pin_code: '123')).not_to be_valid + end + + it 'rejeita PIN não numérico' do + expect(build(:motorista, pin_code: '12ab')).not_to be_valid + end + + it 'ignora a validação de PIN para não-motoristas' do + expect(build(:operador, pin_code: 'xyz')).to be_valid + end + end + + describe 'PIN único entre motoristas' do + it 'impede dois motoristas com o mesmo PIN' do + create(:motorista, pin_code: '4242') + duplicado = build(:motorista, pin_code: '4242') + expect(duplicado).not_to be_valid + expect(duplicado.errors[:pin_code]).to be_present + end + end + + describe '#regenerar_login_token!' do + it 'gera e persiste um token novo' do + motorista = create(:motorista) + expect { motorista.regenerar_login_token! } + .to change { motorista.reload.login_token }.from(nil) + end + end +end diff --git a/spec/policies/consolidacao_policy_spec.rb b/spec/policies/consolidacao_policy_spec.rb new file mode 100644 index 0000000..dafd43e --- /dev/null +++ b/spec/policies/consolidacao_policy_spec.rb @@ -0,0 +1,42 @@ +require 'rails_helper' + +RSpec.describe ConsolidacaoPolicy do + subject { described_class } + + let(:consolidacao) { build(:consolidacao, status: :rascunho) } + let(:consolidacao_finalizada) { build(:consolidacao, status: :finalizada) } + + permissions :index?, :show?, :create? do + it 'libera para admin, gerente e operador' do + %i[admin gerente operador].each do |papel| + expect(subject).to permit(build(papel), Consolidacao) + end + end + + it 'nega para motorista' do + expect(subject).not_to permit(build(:motorista), Consolidacao) + end + end + + permissions :update? do + it 'permite operador editar rascunho' do + expect(subject).to permit(build(:operador), consolidacao) + end + + it 'impede operador de editar consolidação finalizada' do + expect(subject).not_to permit(build(:operador), consolidacao_finalizada) + end + + it 'permite gerente editar consolidação finalizada' do + expect(subject).to permit(build(:gerente), consolidacao_finalizada) + end + end + + permissions :destroy? do + it 'só permite admin e gerente' do + expect(subject).to permit(build(:admin), consolidacao) + expect(subject).to permit(build(:gerente), consolidacao) + expect(subject).not_to permit(build(:operador), consolidacao) + end + end +end diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb new file mode 100644 index 0000000..9109631 --- /dev/null +++ b/spec/rails_helper.rb @@ -0,0 +1,22 @@ +require 'spec_helper' +ENV['RAILS_ENV'] ||= 'test' +require_relative '../config/environment' + +abort("The Rails environment is running in production mode!") if Rails.env.production? + +require 'rspec/rails' + +# Carrega os arquivos de suporte (factories etc.) +Dir[Rails.root.join('spec', 'support', '**', '*.rb')].sort.each { |f| require f } + +# NÃO usamos maintain_test_schema! porque o projeto não versiona schema.rb +# (as migrations são a fonte de verdade). Rode antes da suíte: +# RAILS_ENV=test bundle exec rails db:create db:migrate +RSpec.configure do |config| + config.fixture_paths = [Rails.root.join('spec', 'fixtures')] if config.respond_to?(:fixture_paths) + config.use_transactional_fixtures = true + config.infer_spec_type_from_file_location! + config.filter_rails_from_backtrace! + + config.include FactoryBot::Syntax::Methods +end diff --git a/spec/requests/dashboard_spec.rb b/spec/requests/dashboard_spec.rb new file mode 100644 index 0000000..cc4518a --- /dev/null +++ b/spec/requests/dashboard_spec.rb @@ -0,0 +1,14 @@ +require 'rails_helper' + +RSpec.describe 'Dashboard', type: :request do + it 'redireciona usuário não autenticado para o login' do + get dashboard_path + expect(response).to redirect_to(new_user_session_path) + end + + it 'redireciona motorista para o painel exclusivo de motorista' do + sign_in create(:motorista) + get dashboard_path + expect(response).to redirect_to(motorista_dashboard_path) + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..2a9bf14 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,16 @@ +RSpec.configure do |config| + config.expect_with :rspec do |expectations| + expectations.include_chain_clauses_in_custom_matcher_descriptions = true + end + + config.mock_with :rspec do |mocks| + mocks.verify_partial_doubles = true + end + + config.shared_context_metadata_behavior = :apply_to_host_groups + config.filter_run_when_matching :focus + config.example_status_persistence_file_path = "spec/examples.txt" + config.disable_monkey_patching! + config.order = :random + Kernel.srand config.seed +end diff --git a/spec/support/devise.rb b/spec/support/devise.rb new file mode 100644 index 0000000..f384a6e --- /dev/null +++ b/spec/support/devise.rb @@ -0,0 +1,4 @@ +RSpec.configure do |config| + config.include Devise::Test::IntegrationHelpers, type: :request + config.include Devise::Test::ControllerHelpers, type: :controller +end diff --git a/spec/support/pundit.rb b/spec/support/pundit.rb new file mode 100644 index 0000000..37fbf4d --- /dev/null +++ b/spec/support/pundit.rb @@ -0,0 +1 @@ +require 'pundit/rspec'