Finished phase005
This commit is contained in:
@@ -25,3 +25,7 @@
|
||||
- `ApplicationController` rejects non-modern browsers with `allow_browser versions: :modern` and uses `stale_when_importmap_changes`.
|
||||
- Solid Queue jobs can run with `bin/jobs`; Kamal production config currently runs Solid Queue inside Puma via `SOLID_QUEUE_IN_PUMA: true`.
|
||||
- Production Docker is intended for Kamal/production, not local development; the entrypoint runs `bin/rails db:prepare` before starting the server.
|
||||
|
||||
## Instructions
|
||||
|
||||
If you are implimenting a phase eg phaseXXX ensure that while you are modifying files you write a file in teh `executions/` folder matching using `executionXXX.md` for the filename. This is all replacing the XXX with the phase number you are on.
|
||||
|
||||
@@ -169,3 +169,4 @@ issues. For example, I already had to fix an error with the `csrf_meta_tags` in
|
||||
the erb templates. The error was nothing to do with the code but with the JSON
|
||||
gem which Rails requires to be less than version 3 and we had 3.0.2
|
||||
installed. This caused the signatures to be mis-matched and made things wrong.
|
||||
|
||||
|
||||
@@ -70,4 +70,4 @@ group :development do
|
||||
end
|
||||
|
||||
# Trying to fix CSRF errors:
|
||||
gem 'json', '< 3'
|
||||
gem "json", "< 3"
|
||||
|
||||
@@ -1,7 +1,59 @@
|
||||
class ApplicationController < ActionController::Base
|
||||
AUTH_TOKEN_COOKIE = :auth_token
|
||||
|
||||
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||
allow_browser versions: :modern
|
||||
|
||||
# Changes to the importmap will invalidate the etag for HTML responses
|
||||
stale_when_importmap_changes
|
||||
|
||||
before_action :rotate_access_key_if_needed
|
||||
before_action :load_current_user
|
||||
before_action :require_identified_user
|
||||
|
||||
helper_method :current_user, :identified?
|
||||
|
||||
private
|
||||
|
||||
def rotate_access_key_if_needed
|
||||
AccessKey.rotate_if_needed!
|
||||
end
|
||||
|
||||
def load_current_user
|
||||
Current.user_session = UserSession.find_by_token(cookies.encrypted[AUTH_TOKEN_COOKIE])
|
||||
Current.user = Current.user_session&.user
|
||||
Current.user_session&.touch(:last_used_at)
|
||||
end
|
||||
|
||||
def require_identified_user
|
||||
return if identified?
|
||||
|
||||
redirect_to identify_path, alert: "Identify yourself before using Car Tracker."
|
||||
end
|
||||
|
||||
def current_user
|
||||
Current.user
|
||||
end
|
||||
|
||||
def identified?
|
||||
current_user.present?
|
||||
end
|
||||
|
||||
def sign_in(user)
|
||||
user_session, raw_token = UserSession.create_for!(user)
|
||||
Current.user_session = user_session
|
||||
Current.user = user
|
||||
cookies.encrypted[AUTH_TOKEN_COOKIE] = {
|
||||
value: raw_token,
|
||||
httponly: true,
|
||||
same_site: :lax
|
||||
}
|
||||
end
|
||||
|
||||
def sign_out
|
||||
Current.user_session&.destroy!
|
||||
Current.user_session = nil
|
||||
Current.user = nil
|
||||
cookies.delete(AUTH_TOKEN_COOKIE)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
class FuelEntriesController < ApplicationController
|
||||
def create
|
||||
@vehicle = Vehicle.find(params[:vehicle_id])
|
||||
@fuel_entry = @vehicle.fuel_entries.build(fuel_entry_params)
|
||||
@fuel_entry.updated_by_user = Current.user
|
||||
|
||||
if save_fuel_entry
|
||||
redirect_to @vehicle, notice: "Fuel entry recorded."
|
||||
else
|
||||
prepare_vehicle_show
|
||||
render "vehicles/show", status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fuel_entry_params
|
||||
params.require(:fuel_entry).permit(:odometer, :gallons_pumped, :price_paid, :date)
|
||||
end
|
||||
|
||||
def save_fuel_entry
|
||||
saved = false
|
||||
|
||||
FuelEntry.transaction do
|
||||
if @fuel_entry.save
|
||||
update_vehicle_odometer(@fuel_entry.odometer)
|
||||
saved = true
|
||||
end
|
||||
end
|
||||
|
||||
saved
|
||||
end
|
||||
|
||||
def update_vehicle_odometer(odometer)
|
||||
return unless odometer > @vehicle.current_odometer
|
||||
|
||||
@vehicle.update!(current_odometer: odometer)
|
||||
end
|
||||
|
||||
def prepare_vehicle_show
|
||||
@maintenance_schedules = @vehicle.maintenance_schedules.order(:maintenance_type)
|
||||
@fuel_entries = @vehicle.fuel_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc)
|
||||
@maintenance_entries = @vehicle.maintenance_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc)
|
||||
@maintenance_entry = @vehicle.maintenance_entries.build(date: Date.current)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,61 @@
|
||||
class MaintenanceEntriesController < ApplicationController
|
||||
ODOMETER_WARNING_THRESHOLD = 500
|
||||
|
||||
def create
|
||||
@vehicle = Vehicle.find(params[:vehicle_id])
|
||||
original_odometer = @vehicle.current_odometer
|
||||
@maintenance_entry = @vehicle.maintenance_entries.build(maintenance_entry_params)
|
||||
@maintenance_entry.updated_by_user = Current.user
|
||||
|
||||
if save_maintenance_entry
|
||||
set_old_odometer_warning(original_odometer)
|
||||
redirect_to @vehicle, notice: "Maintenance entry recorded."
|
||||
else
|
||||
prepare_vehicle_show
|
||||
render "vehicles/show", status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def maintenance_entry_params
|
||||
params.require(:maintenance_entry).permit(:name, :notes, :odometer, :cost, :date)
|
||||
end
|
||||
|
||||
def save_maintenance_entry
|
||||
saved = false
|
||||
|
||||
MaintenanceEntry.transaction do
|
||||
if @maintenance_entry.save
|
||||
update_vehicle_odometer(@maintenance_entry.odometer)
|
||||
saved = true
|
||||
end
|
||||
end
|
||||
|
||||
saved
|
||||
end
|
||||
|
||||
def update_vehicle_odometer(odometer)
|
||||
return unless odometer > @vehicle.current_odometer
|
||||
|
||||
@vehicle.update!(current_odometer: odometer)
|
||||
end
|
||||
|
||||
def set_old_odometer_warning(original_odometer)
|
||||
return unless @maintenance_entry.odometer < original_odometer - ODOMETER_WARNING_THRESHOLD
|
||||
|
||||
message = "Maintenance odometer is more than 500 miles behind the current vehicle odometer."
|
||||
flash[:warning] = message
|
||||
Rails.logger.warn(
|
||||
"#{message} vehicle_id=#{@vehicle.id} maintenance_entry_id=#{@maintenance_entry.id} " \
|
||||
"entry_odometer=#{@maintenance_entry.odometer} vehicle_odometer=#{original_odometer}"
|
||||
)
|
||||
end
|
||||
|
||||
def prepare_vehicle_show
|
||||
@maintenance_schedules = @vehicle.maintenance_schedules.order(:maintenance_type)
|
||||
@fuel_entries = @vehicle.fuel_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc)
|
||||
@maintenance_entries = @vehicle.maintenance_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc)
|
||||
@fuel_entry = @vehicle.fuel_entries.build(date: Date.current)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,52 @@
|
||||
class SessionsController < ApplicationController
|
||||
skip_before_action :require_identified_user, only: %i[new create destroy]
|
||||
|
||||
def new
|
||||
end
|
||||
|
||||
def create
|
||||
access_key = AccessKey.current
|
||||
|
||||
unless access_key.matches?(session_params[:key])
|
||||
Rails.logger.warn("Invalid access key identification attempt for email=#{session_params[:email]}")
|
||||
flash.now[:alert] = "The access key is not valid."
|
||||
return render :new, status: :unprocessable_entity
|
||||
end
|
||||
|
||||
user = find_or_build_user
|
||||
return render :new, status: :unprocessable_entity unless user
|
||||
|
||||
sign_in(user)
|
||||
redirect_to vehicles_path, notice: "You have been identified."
|
||||
end
|
||||
|
||||
def destroy
|
||||
sign_out
|
||||
redirect_to identify_path, notice: "You have been logged out."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def session_params
|
||||
params.permit(:name, :email, :key)
|
||||
end
|
||||
|
||||
def find_or_build_user
|
||||
email = session_params[:email].to_s.strip.downcase
|
||||
name = session_params[:name].to_s.strip
|
||||
user = User.find_by(email: email)
|
||||
|
||||
if user
|
||||
return user if user.name == name
|
||||
|
||||
flash.now[:alert] = "The name does not match the existing user for that email."
|
||||
return nil
|
||||
end
|
||||
|
||||
user = User.new(name: name, email: email)
|
||||
return user if user.save
|
||||
|
||||
flash.now[:alert] = user.errors.full_messages.to_sentence
|
||||
nil
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,81 @@
|
||||
class VehiclesController < ApplicationController
|
||||
before_action :set_vehicle, only: %i[show edit update destroy]
|
||||
|
||||
def index
|
||||
@status = params[:status]
|
||||
@vehicles = vehicles_for_status(@status).order(:make, :model, :year)
|
||||
end
|
||||
|
||||
def show
|
||||
prepare_show
|
||||
end
|
||||
|
||||
def new
|
||||
@vehicle = Vehicle.new
|
||||
end
|
||||
|
||||
def create
|
||||
@vehicle = Vehicle.new(vehicle_params)
|
||||
|
||||
if @vehicle.save
|
||||
redirect_to @vehicle, notice: "Vehicle created."
|
||||
else
|
||||
render :new, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def edit
|
||||
end
|
||||
|
||||
def update
|
||||
if @vehicle.update(vehicle_params)
|
||||
redirect_to @vehicle, notice: "Vehicle updated."
|
||||
else
|
||||
render :edit, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@vehicle.update(active: false)
|
||||
redirect_to vehicles_path, notice: "Vehicle deactivated."
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_vehicle
|
||||
@vehicle = Vehicle.find(params[:id])
|
||||
end
|
||||
|
||||
def vehicles_for_status(status)
|
||||
case status
|
||||
when "inactive"
|
||||
Vehicle.where(active: false)
|
||||
when "all"
|
||||
Vehicle.all
|
||||
else
|
||||
Vehicle.where(active: true)
|
||||
end
|
||||
end
|
||||
|
||||
def vehicle_params
|
||||
params.require(:vehicle).permit(
|
||||
:make,
|
||||
:model,
|
||||
:year,
|
||||
:color,
|
||||
:vin,
|
||||
:licence_plate,
|
||||
:current_odometer,
|
||||
:fuel_tank_size,
|
||||
:active
|
||||
)
|
||||
end
|
||||
|
||||
def prepare_show
|
||||
@maintenance_schedules = @vehicle.maintenance_schedules.order(:maintenance_type)
|
||||
@fuel_entries = @vehicle.fuel_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc)
|
||||
@maintenance_entries = @vehicle.maintenance_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc)
|
||||
@fuel_entry ||= @vehicle.fuel_entries.build(date: Date.current)
|
||||
@maintenance_entry ||= @vehicle.maintenance_entries.build(date: Date.current)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Controller } from "@hotwired/stimulus"
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ["odometer", "warning"]
|
||||
static values = {
|
||||
currentOdometer: Number,
|
||||
threshold: Number
|
||||
}
|
||||
|
||||
check() {
|
||||
const odometer = Number.parseInt(this.odometerTarget.value, 10)
|
||||
|
||||
if (Number.isNaN(odometer) || odometer >= this.currentOdometerValue - this.thresholdValue) {
|
||||
this.warningTarget.hidden = true
|
||||
return
|
||||
}
|
||||
|
||||
this.warningTarget.hidden = false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
class AccessKey < ApplicationRecord
|
||||
DEFAULT_ROTATION_FREQUENCY_DAYS = 30
|
||||
KEY_LENGTH = 32
|
||||
|
||||
validates :raw_key, presence: true
|
||||
validates :generated_at, presence: true
|
||||
validates :auto_rotation_frequency_days,
|
||||
numericality: { only_integer: true, greater_than: 0 }
|
||||
|
||||
def self.current
|
||||
order(generated_at: :desc, id: :desc).first || create_current!
|
||||
end
|
||||
|
||||
def self.create_current!
|
||||
create!(
|
||||
raw_key: generate_raw_key,
|
||||
generated_at: Time.current,
|
||||
auto_rotate: true,
|
||||
auto_rotation_frequency_days: DEFAULT_ROTATION_FREQUENCY_DAYS
|
||||
)
|
||||
end
|
||||
|
||||
def self.rotate!
|
||||
previous_key = current
|
||||
|
||||
transaction do
|
||||
UserSession.delete_all
|
||||
create!(
|
||||
raw_key: generate_raw_key,
|
||||
generated_at: Time.current,
|
||||
auto_rotate: previous_key.auto_rotate,
|
||||
auto_rotation_frequency_days: previous_key.auto_rotation_frequency_days
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def self.rotate_if_needed!
|
||||
key = current
|
||||
return key unless key.auto_rotate? && key.expired?
|
||||
|
||||
rotate!
|
||||
end
|
||||
|
||||
def self.generate_raw_key
|
||||
SecureRandom.urlsafe_base64(KEY_LENGTH)
|
||||
end
|
||||
|
||||
def matches?(submitted_key)
|
||||
ActiveSupport::SecurityUtils.secure_compare(raw_key, submitted_key.to_s)
|
||||
rescue ArgumentError
|
||||
false
|
||||
end
|
||||
|
||||
def expired?
|
||||
generated_at <= auto_rotation_frequency_days.days.ago
|
||||
end
|
||||
|
||||
def disable_auto_rotation!
|
||||
update!(auto_rotate: false)
|
||||
end
|
||||
|
||||
def enable_auto_rotation!
|
||||
update!(auto_rotate: true)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,3 @@
|
||||
class Current < ActiveSupport::CurrentAttributes
|
||||
attribute :user, :user_session
|
||||
end
|
||||
@@ -7,6 +7,7 @@ class FuelEntry < ApplicationRecord
|
||||
validates :price_paid, presence: true, numericality: { greater_than: 0 }
|
||||
validates :date, presence: true
|
||||
validate :date_cannot_be_in_the_future
|
||||
validate :odometer_cannot_be_less_than_vehicle_current_odometer
|
||||
|
||||
private
|
||||
|
||||
@@ -15,4 +16,11 @@ class FuelEntry < ApplicationRecord
|
||||
|
||||
errors.add(:date, "can't be in the future")
|
||||
end
|
||||
|
||||
def odometer_cannot_be_less_than_vehicle_current_odometer
|
||||
return if vehicle.blank? || odometer.blank?
|
||||
return if odometer >= vehicle.current_odometer
|
||||
|
||||
errors.add(:odometer, "cannot be less than the vehicle's current odometer")
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class User < ApplicationRecord
|
||||
has_many :fuel_entries, foreign_key: :updated_by_user_id, dependent: :restrict_with_error, inverse_of: :updated_by_user
|
||||
has_many :maintenance_entries, foreign_key: :updated_by_user_id, dependent: :restrict_with_error, inverse_of: :updated_by_user
|
||||
has_many :user_sessions, dependent: :destroy
|
||||
|
||||
before_validation :normalize_email
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
class UserSession < ApplicationRecord
|
||||
TOKEN_LENGTH = 32
|
||||
|
||||
belongs_to :user
|
||||
|
||||
validates :token_digest, presence: true, uniqueness: true
|
||||
|
||||
def self.create_for!(user)
|
||||
raw_token = generate_raw_token
|
||||
user_session = create!(user: user, token_digest: digest(raw_token), last_used_at: Time.current)
|
||||
|
||||
[ user_session, raw_token ]
|
||||
end
|
||||
|
||||
def self.find_by_token(raw_token)
|
||||
return if raw_token.blank?
|
||||
|
||||
find_by(token_digest: digest(raw_token))
|
||||
end
|
||||
|
||||
def self.generate_raw_token
|
||||
SecureRandom.urlsafe_base64(TOKEN_LENGTH)
|
||||
end
|
||||
|
||||
def self.digest(raw_token)
|
||||
Digest::SHA256.hexdigest(raw_token.to_s)
|
||||
end
|
||||
end
|
||||
@@ -3,7 +3,7 @@ class Vehicle < ApplicationRecord
|
||||
has_many :fuel_entries, dependent: :restrict_with_error
|
||||
has_many :maintenance_entries, dependent: :restrict_with_error
|
||||
|
||||
before_validation :normalize_vin
|
||||
before_validation :normalize_vin, :normalize_licence_plate
|
||||
|
||||
validates :make, :model, :color, :vin, :licence_plate, presence: true
|
||||
validates :vin, uniqueness: true
|
||||
@@ -19,4 +19,8 @@ class Vehicle < ApplicationRecord
|
||||
def normalize_vin
|
||||
self.vin = vin.to_s.strip.upcase if vin.present?
|
||||
end
|
||||
|
||||
def normalize_licence_plate
|
||||
self.licence_plate = licence_plate.to_s.strip.upcase if licence_plate.present?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -24,6 +24,15 @@
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<% if identified? %>
|
||||
<header>
|
||||
<p>
|
||||
Identified as <%= current_user.name %> (<%= current_user.email %>)
|
||||
<%= button_to "Log out", logout_path, method: :delete %>
|
||||
</p>
|
||||
</header>
|
||||
<% end %>
|
||||
|
||||
<%= yield %>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
<main>
|
||||
<p><%= notice %></p>
|
||||
<p><%= alert %></p>
|
||||
|
||||
<h1>Identify yourself</h1>
|
||||
|
||||
<p>Enter the current access key along with your name and email.</p>
|
||||
|
||||
<%= form_with url: session_path do |form| %>
|
||||
<div>
|
||||
<%= form.label :name, "User name" %><br>
|
||||
<%= form.text_field :name, value: params[:name], required: true, autocomplete: "name" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :email %><br>
|
||||
<%= form.email_field :email, value: params[:email], required: true, autocomplete: "email" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :key, "Access key" %><br>
|
||||
<%= form.text_field :key, required: true, autocomplete: "off" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.submit "Identify" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</main>
|
||||
@@ -0,0 +1,62 @@
|
||||
<%= form_with model: vehicle do |form| %>
|
||||
<% if vehicle.errors.any? %>
|
||||
<section>
|
||||
<h2><%= pluralize(vehicle.errors.count, "error") %> prevented this vehicle from being saved</h2>
|
||||
|
||||
<ul>
|
||||
<% vehicle.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<%= form.label :make %><br>
|
||||
<%= form.text_field :make %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :model %><br>
|
||||
<%= form.text_field :model %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :year %><br>
|
||||
<%= form.number_field :year %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :color %><br>
|
||||
<%= form.text_field :color %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :vin, "VIN" %><br>
|
||||
<%= form.text_field :vin %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :licence_plate %><br>
|
||||
<%= form.text_field :licence_plate %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :current_odometer %><br>
|
||||
<%= form.number_field :current_odometer %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :fuel_tank_size %><br>
|
||||
<%= form.number_field :fuel_tank_size, step: "0.01" %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.check_box :active %>
|
||||
<%= form.label :active %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.submit %>
|
||||
</div>
|
||||
<% end %>
|
||||
@@ -0,0 +1,7 @@
|
||||
<main>
|
||||
<h1>Edit Vehicle</h1>
|
||||
|
||||
<%= render "form", vehicle: @vehicle %>
|
||||
|
||||
<p><%= link_to "Back to vehicle", @vehicle %></p>
|
||||
</main>
|
||||
@@ -0,0 +1,38 @@
|
||||
<main>
|
||||
<p><%= notice %></p>
|
||||
|
||||
<h1>Vehicles</h1>
|
||||
|
||||
<nav aria-label="Vehicle filters">
|
||||
<%= link_to "Active", vehicles_path %> |
|
||||
<%= link_to "Inactive", vehicles_path(status: "inactive") %> |
|
||||
<%= link_to "All", vehicles_path(status: "all") %>
|
||||
</nav>
|
||||
|
||||
<p><%= link_to "New vehicle", new_vehicle_path %></p>
|
||||
|
||||
<% if @vehicles.any? %>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Vehicle</th>
|
||||
<th>Licence plate</th>
|
||||
<th>Odometer</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @vehicles.each do |vehicle| %>
|
||||
<tr>
|
||||
<td><%= link_to "#{vehicle.year} #{vehicle.make} #{vehicle.model}", vehicle %></td>
|
||||
<td><%= vehicle.licence_plate %></td>
|
||||
<td><%= number_with_delimiter(vehicle.current_odometer) %></td>
|
||||
<td><%= vehicle.active? ? "Active" : "Inactive" %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p>No vehicles found.</p>
|
||||
<% end %>
|
||||
</main>
|
||||
@@ -0,0 +1,7 @@
|
||||
<main>
|
||||
<h1>New Vehicle</h1>
|
||||
|
||||
<%= render "form", vehicle: @vehicle %>
|
||||
|
||||
<p><%= link_to "Back to vehicles", vehicles_path %></p>
|
||||
</main>
|
||||
@@ -0,0 +1,243 @@
|
||||
<main>
|
||||
<p><%= notice %></p>
|
||||
<p><%= flash[:warning] %></p>
|
||||
|
||||
<p><%= link_to "Back to vehicles", vehicles_path %></p>
|
||||
|
||||
<h1><%= @vehicle.year %> <%= @vehicle.make %> <%= @vehicle.model %></h1>
|
||||
|
||||
<div>
|
||||
<%= link_to "Edit", edit_vehicle_path(@vehicle) %>
|
||||
<% if @vehicle.active? %>
|
||||
| <%= button_to "Deactivate", @vehicle, method: :delete %>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<section>
|
||||
<h2>Vehicle Details</h2>
|
||||
|
||||
<dl>
|
||||
<dt>Make</dt>
|
||||
<dd><%= @vehicle.make %></dd>
|
||||
|
||||
<dt>Model</dt>
|
||||
<dd><%= @vehicle.model %></dd>
|
||||
|
||||
<dt>Year</dt>
|
||||
<dd><%= @vehicle.year %></dd>
|
||||
|
||||
<dt>Color</dt>
|
||||
<dd><%= @vehicle.color %></dd>
|
||||
|
||||
<dt>VIN</dt>
|
||||
<dd><%= @vehicle.vin %></dd>
|
||||
|
||||
<dt>Licence plate</dt>
|
||||
<dd><%= @vehicle.licence_plate %></dd>
|
||||
|
||||
<dt>Current odometer</dt>
|
||||
<dd><%= number_with_delimiter(@vehicle.current_odometer) %></dd>
|
||||
|
||||
<dt>Fuel tank size</dt>
|
||||
<dd><%= @vehicle.fuel_tank_size %></dd>
|
||||
|
||||
<dt>Status</dt>
|
||||
<dd><%= @vehicle.active? ? "Active" : "Inactive" %></dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Maintenance Schedules</h2>
|
||||
|
||||
<% if @maintenance_schedules.any? %>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Type</th>
|
||||
<th>Mileage interval</th>
|
||||
<th>Time interval</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @maintenance_schedules.each do |schedule| %>
|
||||
<tr>
|
||||
<td><%= schedule.maintenance_type %></td>
|
||||
<td><%= number_with_delimiter(schedule.mileage_interval) %> miles</td>
|
||||
<td><%= schedule.time_interval %> days</td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p>No maintenance schedules recorded.</p>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Record Fuel</h2>
|
||||
|
||||
<%= form_with model: [@vehicle, @fuel_entry] do |form| %>
|
||||
<% if @fuel_entry.errors.any? %>
|
||||
<section>
|
||||
<h3><%= pluralize(@fuel_entry.errors.count, "error") %> prevented this fuel entry from being saved</h3>
|
||||
|
||||
<ul>
|
||||
<% @fuel_entry.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<%= form.label :odometer %><br>
|
||||
<%= form.number_field :odometer, min: @vehicle.current_odometer, required: true %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :gallons_pumped %><br>
|
||||
<%= form.number_field :gallons_pumped, min: 0.01, step: "0.01", required: true %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :price_paid %><br>
|
||||
<%= form.number_field :price_paid, min: 0.01, step: "0.01", required: true %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :date %><br>
|
||||
<%= form.date_field :date, max: Date.current, required: true %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.submit "Record fuel" %>
|
||||
</div>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Record Maintenance</h2>
|
||||
|
||||
<% if @maintenance_schedules.any? %>
|
||||
<%= form_with model: [@vehicle, @maintenance_entry], data: {
|
||||
controller: "maintenance-odometer-warning",
|
||||
maintenance_odometer_warning_current_odometer_value: @vehicle.current_odometer,
|
||||
maintenance_odometer_warning_threshold_value: 500
|
||||
} do |form| %>
|
||||
<% if @maintenance_entry.errors.any? %>
|
||||
<section>
|
||||
<h3><%= pluralize(@maintenance_entry.errors.count, "error") %> prevented this maintenance entry from being saved</h3>
|
||||
|
||||
<ul>
|
||||
<% @maintenance_entry.errors.full_messages.each do |message| %>
|
||||
<li><%= message %></li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</section>
|
||||
<% end %>
|
||||
|
||||
<div>
|
||||
<%= form.label :name, "Maintenance type" %><br>
|
||||
<%= form.select :name, options_for_select(@maintenance_schedules.map(&:maintenance_type), @maintenance_entry.name), { prompt: "Choose maintenance type" }, required: true %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :odometer %><br>
|
||||
<%= form.number_field :odometer, min: 0, required: true, data: {
|
||||
maintenance_odometer_warning_target: "odometer",
|
||||
action: "input->maintenance-odometer-warning#check change->maintenance-odometer-warning#check"
|
||||
} %>
|
||||
</div>
|
||||
|
||||
<p hidden data-maintenance-odometer-warning-target="warning">
|
||||
Warning: this maintenance odometer is more than 500 miles behind the current vehicle odometer.
|
||||
</p>
|
||||
|
||||
<div>
|
||||
<%= form.label :cost %><br>
|
||||
<%= form.number_field :cost, min: 0, step: "0.01", required: true %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :date %><br>
|
||||
<%= form.date_field :date, max: Date.current, required: true %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.label :notes %><br>
|
||||
<%= form.text_area :notes, required: true %>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<%= form.submit "Record maintenance" %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% else %>
|
||||
<p>Add a maintenance schedule before recording maintenance.</p>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Fuel History</h2>
|
||||
|
||||
<% if @fuel_entries.any? %>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Odometer</th>
|
||||
<th>Gallons</th>
|
||||
<th>Price paid</th>
|
||||
<th>Updated by</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @fuel_entries.each do |entry| %>
|
||||
<tr>
|
||||
<td><%= entry.date %></td>
|
||||
<td><%= number_with_delimiter(entry.odometer) %></td>
|
||||
<td><%= entry.gallons_pumped %></td>
|
||||
<td><%= number_to_currency(entry.price_paid) %></td>
|
||||
<td><%= entry.updated_by_user.name %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p>No fuel entries recorded.</p>
|
||||
<% end %>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Maintenance History</h2>
|
||||
|
||||
<% if @maintenance_entries.any? %>
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Name</th>
|
||||
<th>Odometer</th>
|
||||
<th>Cost</th>
|
||||
<th>Notes</th>
|
||||
<th>Updated by</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<% @maintenance_entries.each do |entry| %>
|
||||
<tr>
|
||||
<td><%= entry.date %></td>
|
||||
<td><%= entry.name %></td>
|
||||
<td><%= number_with_delimiter(entry.odometer) %></td>
|
||||
<td><%= number_to_currency(entry.cost) %></td>
|
||||
<td><%= entry.notes %></td>
|
||||
<td><%= entry.updated_by_user.name %></td>
|
||||
</tr>
|
||||
<% end %>
|
||||
</tbody>
|
||||
</table>
|
||||
<% else %>
|
||||
<p>No maintenance entries recorded.</p>
|
||||
<% end %>
|
||||
</section>
|
||||
</main>
|
||||
+10
-1
@@ -5,9 +5,18 @@ Rails.application.routes.draw do
|
||||
# Can be used by load balancers and uptime monitors to verify that the app is live.
|
||||
get "up" => "rails/health#show", as: :rails_health_check
|
||||
|
||||
get "/identify", to: "sessions#new", as: :identify
|
||||
post "/identify", to: "sessions#create", as: :session
|
||||
delete "/session", to: "sessions#destroy", as: :logout
|
||||
|
||||
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
|
||||
# get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
|
||||
# get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
|
||||
|
||||
root "home#index"
|
||||
resources :vehicles do
|
||||
resources :fuel_entries, only: :create
|
||||
resources :maintenance_entries, only: :create
|
||||
end
|
||||
|
||||
root "vehicles#index"
|
||||
end
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
class CreateAccessKeys < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :access_keys do |t|
|
||||
t.string :raw_key, null: false
|
||||
t.datetime :generated_at, null: false
|
||||
t.boolean :auto_rotate, null: false, default: true
|
||||
t.integer :auto_rotation_frequency_days, null: false, default: 30
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
class CreateUserSessions < ActiveRecord::Migration[8.1]
|
||||
def change
|
||||
create_table :user_sessions do |t|
|
||||
t.references :user, null: false, foreign_key: true
|
||||
t.string :token_digest, null: false
|
||||
t.datetime :last_used_at
|
||||
|
||||
t.timestamps
|
||||
end
|
||||
|
||||
add_index :user_sessions, :token_digest, unique: true
|
||||
end
|
||||
end
|
||||
Generated
+21
-1
@@ -10,7 +10,16 @@
|
||||
#
|
||||
# It's strongly recommended that you check this file into your version control system.
|
||||
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_09_14_132024) do
|
||||
ActiveRecord::Schema[8.1].define(version: 2026_09_16_103647) do
|
||||
create_table "access_keys", force: :cascade do |t|
|
||||
t.boolean "auto_rotate", default: true, null: false
|
||||
t.integer "auto_rotation_frequency_days", default: 30, null: false
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "generated_at", null: false
|
||||
t.string "raw_key", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
end
|
||||
|
||||
create_table "fuel_entries", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.date "date", null: false
|
||||
@@ -49,6 +58,16 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_14_132024) do
|
||||
t.index ["vehicle_id"], name: "index_maintenance_schedules_on_vehicle_id"
|
||||
end
|
||||
|
||||
create_table "user_sessions", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.datetime "last_used_at"
|
||||
t.string "token_digest", null: false
|
||||
t.datetime "updated_at", null: false
|
||||
t.integer "user_id", null: false
|
||||
t.index ["token_digest"], name: "index_user_sessions_on_token_digest", unique: true
|
||||
t.index ["user_id"], name: "index_user_sessions_on_user_id"
|
||||
end
|
||||
|
||||
create_table "users", force: :cascade do |t|
|
||||
t.datetime "created_at", null: false
|
||||
t.string "email", null: false
|
||||
@@ -78,4 +97,5 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_14_132024) do
|
||||
add_foreign_key "maintenance_entries", "users", column: "updated_by_user_id"
|
||||
add_foreign_key "maintenance_entries", "vehicles"
|
||||
add_foreign_key "maintenance_schedules", "vehicles"
|
||||
add_foreign_key "user_sessions", "users"
|
||||
end
|
||||
|
||||
@@ -7,3 +7,5 @@
|
||||
# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
|
||||
# MovieGenre.find_or_create_by!(name: genre_name)
|
||||
# end
|
||||
|
||||
AccessKey.current
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
# Phase 003 Execution
|
||||
|
||||
## Scope
|
||||
- Built the vehicle CRUD interface.
|
||||
- Made the vehicle index the root page.
|
||||
- Kept vehicle removal non-destructive by deactivating vehicles.
|
||||
- Added request specs for the vehicle UI flows.
|
||||
|
||||
## Changes
|
||||
- Added `VehiclesController` with index, show, new, create, edit, update, and destroy actions.
|
||||
- Added `resources :vehicles` and changed the root route to `vehicles#index`.
|
||||
- Added plain ERB views for vehicle index, detail, new, edit, and the shared form.
|
||||
- Added query-param filtering on the vehicle index:
|
||||
- default shows active vehicles
|
||||
- `status=inactive` shows inactive vehicles
|
||||
- `status=all` shows all vehicles
|
||||
- Implemented `destroy` as a deactivate action that sets `active` to `false`.
|
||||
- Displayed maintenance schedules, fuel history, and maintenance history on `vehicles#show`.
|
||||
- Ordered fuel and maintenance history newest first.
|
||||
- Updated home specs for the new root behavior.
|
||||
- Added vehicle request specs for successful and invalid create/update paths.
|
||||
- Added licence plate normalization to uppercase before validation.
|
||||
|
||||
## Decisions
|
||||
- Vehicles are not hard-deleted because maintenance and fuel history must be preserved.
|
||||
- The UI labels the destructive route as `Deactivate` to match actual behavior.
|
||||
- The vehicle show page is display-only for related schedules and histories in this phase.
|
||||
- Event creation remains reserved for Phase 004.
|
||||
- Maintenance schedule CRUD remains reserved for Phase 006.
|
||||
- Licence plates are standardized to uppercase before validation and storage.
|
||||
|
||||
## Verification
|
||||
- Passed: `bundle exec rspec spec/requests/vehicles_spec.rb`
|
||||
- Passed: `bundle exec rspec`
|
||||
- Passed for touched Phase 003 Ruby files: `bin/rubocop app/controllers/vehicles_controller.rb spec/requests/vehicles_spec.rb spec/requests/home_spec.rb spec/system/home_spec.rb`
|
||||
- Full `bin/rubocop` was not clean because of pre-existing unrelated `Gemfile` single-quote offenses.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Phase 004 Execution
|
||||
|
||||
## Scope
|
||||
- Added fuel and maintenance event creation from the vehicle detail page.
|
||||
- Added nested event routes and focused create controllers.
|
||||
- Added odometer update behavior for fuel and maintenance entries.
|
||||
- Added placeholder event attribution until Phase 005 authentication exists.
|
||||
- Added request and model specs for the new behavior.
|
||||
|
||||
## Changes
|
||||
- Added nested `fuel_entries#create` and `maintenance_entries#create` routes under vehicles.
|
||||
- Added `FuelEntriesController` for creating fuel entries from a vehicle page.
|
||||
- Added `MaintenanceEntriesController` for creating maintenance entries from a vehicle page.
|
||||
- Added a private `unknown_user` controller helper that looks up `unknown@example.invalid`.
|
||||
- Added an idempotent `Unknown User` seed in `db/seeds.rb` for temporary Phase 004 attribution.
|
||||
- Updated `vehicles#show` setup to build form objects and eager-load event users.
|
||||
- Added fuel and maintenance forms to `vehicles#show`.
|
||||
- Added inline validation error display for both event forms.
|
||||
- Added maintenance type selection from the vehicle's existing maintenance schedules.
|
||||
- Disabled maintenance entry creation in the UI when a vehicle has no maintenance schedules.
|
||||
- Added `Updated by` columns to fuel and maintenance history tables.
|
||||
- Added fuel-entry validation rejecting odometer readings lower than the vehicle current odometer.
|
||||
- Added vehicle odometer updates when saved fuel or maintenance entries have newer odometer readings.
|
||||
- Added a Stimulus controller that warns when a maintenance odometer is more than 500 miles behind the current vehicle odometer.
|
||||
- Added server-side flash warning and application log warning for maintenance entries more than 500 miles behind the current vehicle odometer.
|
||||
- Hardened temporary `Unknown User` attribution so event creation creates the placeholder if the seed has not been run.
|
||||
- Added a user-visible warning path for attribution setup failures instead of raising a server error.
|
||||
|
||||
## Decisions
|
||||
- Phase 004 uses `Unknown User` for event attribution because Phase 005 will add actual authentication and session identity.
|
||||
- `Unknown User` is seeded instead of silently created by controllers so missing setup is visible.
|
||||
- Controllers also create `Unknown User` as a runtime fallback because a missing seed should not produce a 404/server error for users.
|
||||
- Fuel entries below the current vehicle odometer are rejected.
|
||||
- Maintenance entries below the current vehicle odometer are allowed because paperwork may be entered later.
|
||||
- Maintenance entries update the vehicle odometer only when their odometer is newer.
|
||||
- Maintenance entry names must match an existing maintenance schedule for the vehicle.
|
||||
- Client-side maintenance odometer warnings are advisory only; server-side logging is the durable audit trail.
|
||||
|
||||
## Phase 005 Follow-Up
|
||||
- Replace `Unknown User` attribution with the authenticated/current session user.
|
||||
- Remove or stop using the temporary `Unknown User` seed once all event creation requires an identified user.
|
||||
|
||||
## Verification
|
||||
- Passed: `bundle exec rspec spec/models/fuel_entry_spec.rb spec/requests/vehicles_spec.rb`
|
||||
- Passed: `bundle exec rspec`
|
||||
- Passed: `bin/rubocop app/controllers/application_controller.rb app/controllers/vehicles_controller.rb app/controllers/fuel_entries_controller.rb app/controllers/maintenance_entries_controller.rb app/models/fuel_entry.rb spec/models/fuel_entry_spec.rb spec/requests/vehicles_spec.rb db/seeds.rb`
|
||||
- Passed after attribution hardening: `bundle exec rspec spec/requests/vehicles_spec.rb`
|
||||
- Passed after attribution hardening: `bundle exec rspec`
|
||||
- Passed after attribution hardening: `bin/rubocop app/controllers/application_controller.rb app/controllers/fuel_entries_controller.rb app/controllers/maintenance_entries_controller.rb spec/requests/vehicles_spec.rb`
|
||||
@@ -0,0 +1,51 @@
|
||||
# Phase 005 Execution
|
||||
|
||||
## Scope
|
||||
- Added lightweight internal authentication without passwords.
|
||||
- Added database-backed monthly access keys with lazy auto-rotation.
|
||||
- Added random browser tokens stored in encrypted cookies and mapped to users through server-side session records.
|
||||
- Required identification for all application pages except the identify flow and Rails health check.
|
||||
- Replaced temporary `Unknown User` event attribution with the active identified user.
|
||||
- Added request and model specs for authenticated and unauthenticated flows.
|
||||
|
||||
## Changes
|
||||
- Added `AccessKey` model and migration.
|
||||
- Added `UserSession` model and migration.
|
||||
- Added `Current` for request-local user/session access.
|
||||
- Added `SessionsController` with identify, create session, and logout behavior.
|
||||
- Added `/identify`, `POST /identify`, and `DELETE /session` routes.
|
||||
- Added `app/views/sessions/new.html.erb` for the “Identify yourself” page.
|
||||
- Updated the application layout to show the identified user and a logout button on authenticated pages.
|
||||
- Updated `ApplicationController` to lazily rotate access keys, load the encrypted-cookie auth token, and require identification by default.
|
||||
- Updated fuel and maintenance entry creation to set `updated_by_user` from `Current.user`.
|
||||
- Removed the temporary `Unknown User` seed and replaced it with initial `AccessKey.current` creation.
|
||||
- Updated vehicle, home, and session request/system specs for the new authentication requirements.
|
||||
|
||||
## Decisions
|
||||
- Access keys are stored in plaintext in the database so the server manager can retrieve the current key with `AccessKey.current.raw_key` from Rails console.
|
||||
- Browser clients store only a random auth token in an encrypted cookie.
|
||||
- The database stores only token digests, not raw session tokens.
|
||||
- `AccessKey.rotate!` invalidates all active sessions by deleting `UserSession` records.
|
||||
- Auto-rotation is lazy and runs during normal requests when enabled and expired.
|
||||
- Default auto-rotation frequency is 30 days.
|
||||
- Auto-rotation can be disabled and re-enabled with `AccessKey.current.disable_auto_rotation!` and `AccessKey.current.enable_auto_rotation!`.
|
||||
- The rotation frequency can be changed with `AccessKey.current.update!(auto_rotation_frequency_days: days)`.
|
||||
- Existing users are matched by normalized email and require an exact stored-name match.
|
||||
- `/up` remains publicly accessible for health checks.
|
||||
- `DELETE /session` uses the `logout_path` helper because `session_path` is already used for `POST /identify`.
|
||||
|
||||
## Console API
|
||||
```ruby
|
||||
AccessKey.current.raw_key
|
||||
AccessKey.rotate!
|
||||
AccessKey.current.disable_auto_rotation!
|
||||
AccessKey.current.enable_auto_rotation!
|
||||
AccessKey.current.auto_rotation_frequency_days
|
||||
AccessKey.current.update!(auto_rotation_frequency_days: 60)
|
||||
```
|
||||
|
||||
## Verification
|
||||
- Passed: `bundle exec rspec spec/models/access_key_spec.rb spec/models/user_session_spec.rb spec/requests/sessions_spec.rb spec/requests/vehicles_spec.rb`
|
||||
- Passed: `bundle exec rspec`
|
||||
- Passed: `bin/rubocop app/models/access_key.rb app/models/user_session.rb app/models/current.rb app/models/user.rb app/controllers/application_controller.rb app/controllers/sessions_controller.rb app/controllers/fuel_entries_controller.rb app/controllers/maintenance_entries_controller.rb spec/models/access_key_spec.rb spec/models/user_session_spec.rb spec/requests/sessions_spec.rb spec/requests/vehicles_spec.rb spec/requests/home_spec.rb spec/system/home_spec.rb db/seeds.rb`
|
||||
- Passed after lint cleanup: `bundle exec rspec`
|
||||
@@ -0,0 +1,53 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe AccessKey do
|
||||
it "creates a current access key when one does not exist" do
|
||||
expect { described_class.current }.to change(described_class, :count).by(1)
|
||||
expect(described_class.current.raw_key).to be_present
|
||||
end
|
||||
|
||||
it "matches the submitted raw key" do
|
||||
access_key = described_class.current
|
||||
|
||||
expect(access_key.matches?(access_key.raw_key)).to be(true)
|
||||
expect(access_key.matches?("wrong-key")).to be(false)
|
||||
end
|
||||
|
||||
it "rotates the key and invalidates user sessions" do
|
||||
old_key = described_class.current
|
||||
user = create(:user)
|
||||
UserSession.create_for!(user)
|
||||
|
||||
expect do
|
||||
described_class.rotate!
|
||||
end.to change(described_class, :count).by(1).and change(UserSession, :count).to(0)
|
||||
|
||||
expect(described_class.current.raw_key).not_to eq(old_key.raw_key)
|
||||
end
|
||||
|
||||
it "does not auto-rotate when auto rotation is disabled" do
|
||||
access_key = described_class.current
|
||||
access_key.update!(generated_at: 31.days.ago)
|
||||
access_key.disable_auto_rotation!
|
||||
|
||||
expect do
|
||||
described_class.rotate_if_needed!
|
||||
end.not_to change(described_class, :count)
|
||||
end
|
||||
|
||||
it "auto-rotates when enabled and expired" do
|
||||
access_key = described_class.current
|
||||
access_key.update!(generated_at: 31.days.ago)
|
||||
|
||||
expect do
|
||||
described_class.rotate_if_needed!
|
||||
end.to change(described_class, :count).by(1)
|
||||
end
|
||||
|
||||
it "uses the configured auto rotation frequency" do
|
||||
access_key = described_class.current
|
||||
access_key.update!(generated_at: 10.days.ago, auto_rotation_frequency_days: 7)
|
||||
|
||||
expect(access_key).to be_expired
|
||||
end
|
||||
end
|
||||
@@ -35,4 +35,26 @@ RSpec.describe FuelEntry do
|
||||
expect(entry).not_to be_valid
|
||||
expect(entry.errors[:date]).to be_present
|
||||
end
|
||||
|
||||
it "rejects odometer readings below the vehicle current odometer" do
|
||||
vehicle = build(:vehicle, current_odometer: 10_000)
|
||||
entry = build(:fuel_entry, vehicle: vehicle, odometer: 9_999)
|
||||
|
||||
expect(entry).not_to be_valid
|
||||
expect(entry.errors[:odometer]).to be_present
|
||||
end
|
||||
|
||||
it "allows odometer readings equal to the vehicle current odometer" do
|
||||
vehicle = build(:vehicle, current_odometer: 10_000)
|
||||
entry = build(:fuel_entry, vehicle: vehicle, odometer: 10_000)
|
||||
|
||||
expect(entry).to be_valid
|
||||
end
|
||||
|
||||
it "allows odometer readings above the vehicle current odometer" do
|
||||
vehicle = build(:vehicle, current_odometer: 10_000)
|
||||
entry = build(:fuel_entry, vehicle: vehicle, odometer: 10_001)
|
||||
|
||||
expect(entry).to be_valid
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe UserSession do
|
||||
it "creates a session for a user and returns the raw token" do
|
||||
user = create(:user)
|
||||
|
||||
user_session, raw_token = described_class.create_for!(user)
|
||||
|
||||
expect(user_session.user).to eq(user)
|
||||
expect(raw_token).to be_present
|
||||
expect(user_session.token_digest).not_to eq(raw_token)
|
||||
end
|
||||
|
||||
it "finds sessions by raw token" do
|
||||
user = create(:user)
|
||||
user_session, raw_token = described_class.create_for!(user)
|
||||
|
||||
expect(described_class.find_by_token(raw_token)).to eq(user_session)
|
||||
expect(described_class.find_by_token("wrong-token")).to be_nil
|
||||
end
|
||||
end
|
||||
@@ -45,6 +45,14 @@ RSpec.describe Vehicle do
|
||||
expect(vehicle.vin).to eq("ABC123")
|
||||
end
|
||||
|
||||
it "normalizes licence plate to uppercase" do
|
||||
vehicle = build(:vehicle, licence_plate: "kj7cyt")
|
||||
|
||||
vehicle.valid?
|
||||
|
||||
expect(vehicle.licence_plate).to eq("KJ7CYT")
|
||||
end
|
||||
|
||||
it "requires VIN to be unique after normalization" do
|
||||
create(:vehicle, vin: "ABC123")
|
||||
|
||||
|
||||
@@ -2,11 +2,13 @@ require "rails_helper"
|
||||
|
||||
RSpec.describe "Home" do
|
||||
describe "GET /" do
|
||||
it "returns a successful response" do
|
||||
before { identify_user }
|
||||
|
||||
it "returns the vehicle index" do
|
||||
get root_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("Car Tracker")
|
||||
expect(response.body).to include("Vehicles")
|
||||
expect(response.body).to include("stylesheet")
|
||||
expect(response.body).to include("importmap")
|
||||
end
|
||||
@@ -24,4 +26,9 @@ RSpec.describe "Home" do
|
||||
Rails.application.config.instance_variable_set(:@content_security_policy, original_policy)
|
||||
end
|
||||
end
|
||||
|
||||
def identify_user(user = create(:user))
|
||||
access_key = AccessKey.current
|
||||
post session_path, params: { name: user.name, email: user.email, key: access_key.raw_key }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Sessions" do
|
||||
describe "GET /identify" do
|
||||
it "renders the identification page" do
|
||||
get identify_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("Identify yourself")
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /identify" do
|
||||
it "identifies a new user with the current access key" do
|
||||
access_key = AccessKey.current
|
||||
|
||||
expect do
|
||||
post session_path, params: { name: "Driver One", email: "DRIVER@example.com", key: access_key.raw_key }
|
||||
end.to change(User, :count).by(1).and change(UserSession, :count).by(1)
|
||||
|
||||
expect(response).to redirect_to(vehicles_path)
|
||||
expect(User.last.email).to eq("driver@example.com")
|
||||
end
|
||||
|
||||
it "identifies an existing user when the name matches" do
|
||||
access_key = AccessKey.current
|
||||
user = create(:user, name: "Driver One", email: "driver@example.com")
|
||||
|
||||
expect do
|
||||
post session_path, params: { name: "Driver One", email: "driver@example.com", key: access_key.raw_key }
|
||||
end.to change(UserSession, :count).by(1).and change(User, :count).by(0)
|
||||
|
||||
expect(response).to redirect_to(vehicles_path)
|
||||
expect(UserSession.last.user).to eq(user)
|
||||
end
|
||||
|
||||
it "rejects an existing email when the name does not match" do
|
||||
access_key = AccessKey.current
|
||||
create(:user, name: "Driver One", email: "driver@example.com")
|
||||
|
||||
expect do
|
||||
post session_path, params: { name: "Driver Two", email: "driver@example.com", key: access_key.raw_key }
|
||||
end.not_to change(UserSession, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(response.body).to include("name does not match")
|
||||
end
|
||||
|
||||
it "rejects invalid access keys and logs the attempt" do
|
||||
AccessKey.current
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
|
||||
expect do
|
||||
post session_path, params: { name: "Driver One", email: "driver@example.com", key: "wrong-key" }
|
||||
end.not_to change(UserSession, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(response.body).to include("access key is not valid")
|
||||
expect(Rails.logger).to have_received(:warn).with(/Invalid access key identification attempt/)
|
||||
end
|
||||
end
|
||||
|
||||
describe "DELETE /session" do
|
||||
it "logs the user out" do
|
||||
identify_user
|
||||
|
||||
expect do
|
||||
delete logout_path
|
||||
end.to change(UserSession, :count).by(-1)
|
||||
|
||||
expect(response).to redirect_to(identify_path)
|
||||
end
|
||||
end
|
||||
|
||||
def identify_user(user = create(:user))
|
||||
access_key = AccessKey.current
|
||||
post session_path, params: { name: user.name, email: user.email, key: access_key.raw_key }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,376 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Vehicles" do
|
||||
describe "unauthenticated access" do
|
||||
it "redirects vehicle pages to identification" do
|
||||
get vehicles_path
|
||||
|
||||
expect(response).to redirect_to(identify_path)
|
||||
end
|
||||
|
||||
it "does not create fuel entries" do
|
||||
vehicle = create(:vehicle, current_odometer: 10_000)
|
||||
|
||||
expect do
|
||||
post vehicle_fuel_entries_path(vehicle), params: { fuel_entry: valid_fuel_entry_params }
|
||||
end.not_to change(FuelEntry, :count)
|
||||
|
||||
expect(response).to redirect_to(identify_path)
|
||||
end
|
||||
|
||||
it "does not create maintenance entries" do
|
||||
vehicle = create(:vehicle, current_odometer: 10_000)
|
||||
create(:maintenance_schedule, vehicle: vehicle, maintenance_type: "Oil Change")
|
||||
|
||||
expect do
|
||||
post vehicle_maintenance_entries_path(vehicle), params: { maintenance_entry: valid_maintenance_entry_params }
|
||||
end.not_to change(MaintenanceEntry, :count)
|
||||
|
||||
expect(response).to redirect_to(identify_path)
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /" do
|
||||
before { identify_user }
|
||||
|
||||
it "renders the active vehicle index" do
|
||||
active_vehicle = create(:vehicle, make: "Ford", model: "F-150", active: true)
|
||||
inactive_vehicle = create(:vehicle, make: "Chevrolet", model: "Silverado", active: false)
|
||||
|
||||
get root_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("Vehicles")
|
||||
expect(response.body).to include(active_vehicle.model)
|
||||
expect(response.body).not_to include(inactive_vehicle.model)
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /vehicles" do
|
||||
before { identify_user }
|
||||
|
||||
it "shows active vehicles by default" do
|
||||
active_vehicle = create(:vehicle, make: "Ford", model: "F-150", active: true)
|
||||
inactive_vehicle = create(:vehicle, make: "Chevrolet", model: "Silverado", active: false)
|
||||
|
||||
get vehicles_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include(active_vehicle.model)
|
||||
expect(response.body).not_to include(inactive_vehicle.model)
|
||||
end
|
||||
|
||||
it "shows inactive vehicles when requested" do
|
||||
active_vehicle = create(:vehicle, make: "Ford", model: "F-150", active: true)
|
||||
inactive_vehicle = create(:vehicle, make: "Chevrolet", model: "Silverado", active: false)
|
||||
|
||||
get vehicles_path(status: "inactive")
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).not_to include(active_vehicle.model)
|
||||
expect(response.body).to include(inactive_vehicle.model)
|
||||
end
|
||||
|
||||
it "shows all vehicles when requested" do
|
||||
active_vehicle = create(:vehicle, make: "Ford", model: "F-150", active: true)
|
||||
inactive_vehicle = create(:vehicle, make: "Chevrolet", model: "Silverado", active: false)
|
||||
|
||||
get vehicles_path(status: "all")
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include(active_vehicle.model)
|
||||
expect(response.body).to include(inactive_vehicle.model)
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /vehicles/:id" do
|
||||
before { identify_user }
|
||||
|
||||
it "shows vehicle details and display-only history sections" do
|
||||
vehicle = create(:vehicle, make: "Ford", model: "F-150")
|
||||
older_fuel_entry = create(:fuel_entry, vehicle: vehicle, odometer: 11_000, date: Date.current - 2.days)
|
||||
newer_fuel_entry = create(:fuel_entry, vehicle: vehicle, odometer: 12_000, date: Date.current - 1.day)
|
||||
older_maintenance_entry = create(
|
||||
:maintenance_entry,
|
||||
vehicle: vehicle,
|
||||
name: "Oil Change",
|
||||
notes: "Older oil change",
|
||||
date: Date.current - 2.days
|
||||
)
|
||||
newer_maintenance_entry = create(
|
||||
:maintenance_entry,
|
||||
vehicle: vehicle,
|
||||
name: "Oil Change",
|
||||
notes: "Newer oil change",
|
||||
date: Date.current - 1.day
|
||||
)
|
||||
|
||||
get vehicle_path(vehicle)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include(vehicle.make)
|
||||
expect(response.body).to include(vehicle.model)
|
||||
expect(response.body).to include("Maintenance Schedules")
|
||||
expect(response.body).to include("Fuel History")
|
||||
expect(response.body).to include("Maintenance History")
|
||||
expect(response.body).to include(vehicle.maintenance_schedules.first.maintenance_type)
|
||||
expect(response.body).to include("12,000")
|
||||
expect(response.body).to include("11,000")
|
||||
expect(response.body).to include(newer_maintenance_entry.notes)
|
||||
expect(response.body).to include(older_maintenance_entry.notes)
|
||||
expect(response.body.index("12,000")).to be < response.body.index("11,000")
|
||||
expect(response.body.index(newer_maintenance_entry.notes)).to be < response.body.index(older_maintenance_entry.notes)
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /vehicles/:vehicle_id/fuel_entries" do
|
||||
before { identify_user }
|
||||
|
||||
it "creates a fuel entry and records the identified user" do
|
||||
vehicle = create(:vehicle, current_odometer: 10_000)
|
||||
|
||||
expect do
|
||||
post vehicle_fuel_entries_path(vehicle), params: { fuel_entry: valid_fuel_entry_params }
|
||||
end.to change(FuelEntry, :count).by(1)
|
||||
|
||||
expect(response).to redirect_to(vehicle_path(vehicle))
|
||||
expect(FuelEntry.last.updated_by_user).to eq(@identified_user)
|
||||
end
|
||||
|
||||
it "updates the vehicle odometer when the fuel odometer is newer" do
|
||||
vehicle = create(:vehicle, current_odometer: 10_000)
|
||||
|
||||
post vehicle_fuel_entries_path(vehicle), params: { fuel_entry: valid_fuel_entry_params.merge(odometer: 10_500) }
|
||||
|
||||
expect(vehicle.reload.current_odometer).to eq(10_500)
|
||||
end
|
||||
|
||||
it "saves equal fuel odometer entries without changing the vehicle odometer" do
|
||||
vehicle = create(:vehicle, current_odometer: 10_000)
|
||||
|
||||
expect do
|
||||
post vehicle_fuel_entries_path(vehicle), params: { fuel_entry: valid_fuel_entry_params.merge(odometer: 10_000) }
|
||||
end.to change(FuelEntry, :count).by(1)
|
||||
|
||||
expect(vehicle.reload.current_odometer).to eq(10_000)
|
||||
end
|
||||
|
||||
it "rejects fuel odometer entries below the current vehicle odometer" do
|
||||
vehicle = create(:vehicle, current_odometer: 10_000)
|
||||
|
||||
expect do
|
||||
post vehicle_fuel_entries_path(vehicle), params: { fuel_entry: valid_fuel_entry_params.merge(odometer: 9_999) }
|
||||
end.not_to change(FuelEntry, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(response.body).to include("prevented this fuel entry from being saved")
|
||||
expect(vehicle.reload.current_odometer).to eq(10_000)
|
||||
end
|
||||
|
||||
it "renders validation errors for invalid fuel params" do
|
||||
vehicle = create(:vehicle, current_odometer: 10_000)
|
||||
|
||||
expect do
|
||||
post vehicle_fuel_entries_path(vehicle), params: { fuel_entry: valid_fuel_entry_params.merge(gallons_pumped: 0) }
|
||||
end.not_to change(FuelEntry, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(response.body).to include("prevented this fuel entry from being saved")
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /vehicles/:vehicle_id/maintenance_entries" do
|
||||
before { identify_user }
|
||||
|
||||
it "creates a maintenance entry and records the identified user" do
|
||||
vehicle = create(:vehicle, current_odometer: 10_000)
|
||||
create(:maintenance_schedule, vehicle: vehicle, maintenance_type: "Oil Change")
|
||||
|
||||
expect do
|
||||
post vehicle_maintenance_entries_path(vehicle), params: { maintenance_entry: valid_maintenance_entry_params }
|
||||
end.to change(MaintenanceEntry, :count).by(1)
|
||||
|
||||
expect(response).to redirect_to(vehicle_path(vehicle))
|
||||
expect(MaintenanceEntry.last.updated_by_user).to eq(@identified_user)
|
||||
end
|
||||
|
||||
it "updates the vehicle odometer when the maintenance odometer is newer" do
|
||||
vehicle = create(:vehicle, current_odometer: 10_000)
|
||||
create(:maintenance_schedule, vehicle: vehicle, maintenance_type: "Oil Change")
|
||||
|
||||
post vehicle_maintenance_entries_path(vehicle), params: { maintenance_entry: valid_maintenance_entry_params.merge(odometer: 10_500) }
|
||||
|
||||
expect(vehicle.reload.current_odometer).to eq(10_500)
|
||||
end
|
||||
|
||||
it "saves older maintenance odometers without lowering the vehicle odometer" do
|
||||
vehicle = create(:vehicle, current_odometer: 10_000)
|
||||
create(:maintenance_schedule, vehicle: vehicle, maintenance_type: "Oil Change")
|
||||
|
||||
expect do
|
||||
post vehicle_maintenance_entries_path(vehicle), params: { maintenance_entry: valid_maintenance_entry_params.merge(odometer: 9_750) }
|
||||
end.to change(MaintenanceEntry, :count).by(1)
|
||||
|
||||
expect(vehicle.reload.current_odometer).to eq(10_000)
|
||||
end
|
||||
|
||||
it "warns and logs when maintenance odometer is more than 500 miles behind" do
|
||||
vehicle = create(:vehicle, current_odometer: 10_000)
|
||||
create(:maintenance_schedule, vehicle: vehicle, maintenance_type: "Oil Change")
|
||||
allow(Rails.logger).to receive(:warn)
|
||||
|
||||
post vehicle_maintenance_entries_path(vehicle), params: { maintenance_entry: valid_maintenance_entry_params.merge(odometer: 9_499) }
|
||||
|
||||
expect(response).to redirect_to(vehicle_path(vehicle))
|
||||
expect(flash[:warning]).to include("more than 500 miles behind")
|
||||
expect(Rails.logger).to have_received(:warn).with(/vehicle_id=#{vehicle.id}.*entry_odometer=9499/)
|
||||
end
|
||||
|
||||
it "rejects maintenance entries with an unmatched schedule name" do
|
||||
vehicle = create(:vehicle, current_odometer: 10_000)
|
||||
create(:maintenance_schedule, vehicle: vehicle, maintenance_type: "Oil Change")
|
||||
|
||||
expect do
|
||||
post vehicle_maintenance_entries_path(vehicle), params: { maintenance_entry: valid_maintenance_entry_params.merge(name: "Tire Rotation") }
|
||||
end.not_to change(MaintenanceEntry, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(response.body).to include("prevented this maintenance entry from being saved")
|
||||
end
|
||||
|
||||
it "renders validation errors for invalid maintenance params" do
|
||||
vehicle = create(:vehicle, current_odometer: 10_000)
|
||||
create(:maintenance_schedule, vehicle: vehicle, maintenance_type: "Oil Change")
|
||||
|
||||
expect do
|
||||
post vehicle_maintenance_entries_path(vehicle), params: { maintenance_entry: valid_maintenance_entry_params.merge(notes: "") }
|
||||
end.not_to change(MaintenanceEntry, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(response.body).to include("prevented this maintenance entry from being saved")
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /vehicles/new" do
|
||||
before { identify_user }
|
||||
|
||||
it "renders the form" do
|
||||
get new_vehicle_path
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("New Vehicle")
|
||||
end
|
||||
end
|
||||
|
||||
describe "POST /vehicles" do
|
||||
before { identify_user }
|
||||
|
||||
it "creates a vehicle with valid params" do
|
||||
expect do
|
||||
post vehicles_path, params: { vehicle: valid_vehicle_params }
|
||||
end.to change(Vehicle, :count).by(1)
|
||||
|
||||
expect(response).to redirect_to(vehicle_path(Vehicle.last))
|
||||
expect(Vehicle.last.vin).to eq("ABC123")
|
||||
end
|
||||
|
||||
it "rejects invalid params" do
|
||||
expect do
|
||||
post vehicles_path, params: { vehicle: valid_vehicle_params.merge(make: "", vin: "") }
|
||||
end.not_to change(Vehicle, :count)
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(response.body).to include("prevented this vehicle from being saved")
|
||||
end
|
||||
end
|
||||
|
||||
describe "GET /vehicles/:id/edit" do
|
||||
before { identify_user }
|
||||
|
||||
it "renders the form" do
|
||||
vehicle = create(:vehicle)
|
||||
|
||||
get edit_vehicle_path(vehicle)
|
||||
|
||||
expect(response).to have_http_status(:ok)
|
||||
expect(response.body).to include("Edit Vehicle")
|
||||
end
|
||||
end
|
||||
|
||||
describe "PATCH /vehicles/:id" do
|
||||
before { identify_user }
|
||||
|
||||
it "updates a vehicle with valid params" do
|
||||
vehicle = create(:vehicle, make: "Ford")
|
||||
|
||||
patch vehicle_path(vehicle), params: { vehicle: valid_vehicle_params.merge(make: "Toyota") }
|
||||
|
||||
expect(response).to redirect_to(vehicle_path(vehicle))
|
||||
expect(vehicle.reload.make).to eq("Toyota")
|
||||
end
|
||||
|
||||
it "rejects invalid params" do
|
||||
vehicle = create(:vehicle, make: "Ford")
|
||||
|
||||
patch vehicle_path(vehicle), params: { vehicle: valid_vehicle_params.merge(make: "") }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_content)
|
||||
expect(vehicle.reload.make).to eq("Ford")
|
||||
expect(response.body).to include("prevented this vehicle from being saved")
|
||||
end
|
||||
end
|
||||
|
||||
describe "DELETE /vehicles/:id" do
|
||||
before { identify_user }
|
||||
|
||||
it "deactivates the vehicle instead of deleting it" do
|
||||
vehicle = create(:vehicle, active: true)
|
||||
|
||||
expect do
|
||||
delete vehicle_path(vehicle)
|
||||
end.not_to change(Vehicle, :count)
|
||||
|
||||
expect(response).to redirect_to(vehicles_path)
|
||||
expect(vehicle.reload).not_to be_active
|
||||
end
|
||||
end
|
||||
|
||||
def valid_vehicle_params
|
||||
{
|
||||
make: "Ford",
|
||||
model: "F-150",
|
||||
year: Date.current.year,
|
||||
color: "White",
|
||||
vin: "abc123",
|
||||
licence_plate: "FLT1234",
|
||||
current_odometer: 10_000,
|
||||
fuel_tank_size: 26.0,
|
||||
active: "1"
|
||||
}
|
||||
end
|
||||
|
||||
def valid_fuel_entry_params
|
||||
{
|
||||
odometer: 10_250,
|
||||
gallons_pumped: 18.25,
|
||||
price_paid: 47.62,
|
||||
date: Date.current
|
||||
}
|
||||
end
|
||||
|
||||
def valid_maintenance_entry_params
|
||||
{
|
||||
name: "Oil Change",
|
||||
notes: "Oil change performed by Fleet Service Center.",
|
||||
odometer: 10_250,
|
||||
cost: 89.95,
|
||||
date: Date.current
|
||||
}
|
||||
end
|
||||
|
||||
def identify_user(user = create(:user))
|
||||
@identified_user = user
|
||||
access_key = AccessKey.current
|
||||
post session_path, params: { name: user.name, email: user.email, key: access_key.raw_key }
|
||||
end
|
||||
end
|
||||
@@ -1,10 +1,18 @@
|
||||
require "rails_helper"
|
||||
|
||||
RSpec.describe "Home" do
|
||||
it "shows the placeholder landing page" do
|
||||
it "shows the vehicle index" do
|
||||
user = create(:user)
|
||||
access_key = AccessKey.current
|
||||
|
||||
visit root_path
|
||||
|
||||
expect(page).to have_content("Car Tracker")
|
||||
expect(page).to have_content("Vehicle maintenance and fuel tracking will be added here.")
|
||||
fill_in "User name", with: user.name
|
||||
fill_in "Email", with: user.email
|
||||
fill_in "Access key", with: access_key.raw_key
|
||||
click_on "Identify"
|
||||
|
||||
expect(page).to have_content("Vehicles")
|
||||
expect(page).to have_link("New vehicle")
|
||||
end
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user