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`.
|
- `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`.
|
- 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.
|
- 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
|
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
|
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.
|
installed. This caused the signatures to be mis-matched and made things wrong.
|
||||||
|
|
||||||
|
|||||||
@@ -70,4 +70,4 @@ group :development do
|
|||||||
end
|
end
|
||||||
|
|
||||||
# Trying to fix CSRF errors:
|
# Trying to fix CSRF errors:
|
||||||
gem 'json', '< 3'
|
gem "json", "< 3"
|
||||||
|
|||||||
@@ -1,7 +1,59 @@
|
|||||||
class ApplicationController < ActionController::Base
|
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.
|
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
|
||||||
allow_browser versions: :modern
|
allow_browser versions: :modern
|
||||||
|
|
||||||
# Changes to the importmap will invalidate the etag for HTML responses
|
# Changes to the importmap will invalidate the etag for HTML responses
|
||||||
stale_when_importmap_changes
|
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
|
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 :price_paid, presence: true, numericality: { greater_than: 0 }
|
||||||
validates :date, presence: true
|
validates :date, presence: true
|
||||||
validate :date_cannot_be_in_the_future
|
validate :date_cannot_be_in_the_future
|
||||||
|
validate :odometer_cannot_be_less_than_vehicle_current_odometer
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
@@ -15,4 +16,11 @@ class FuelEntry < ApplicationRecord
|
|||||||
|
|
||||||
errors.add(:date, "can't be in the future")
|
errors.add(:date, "can't be in the future")
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
class User < ApplicationRecord
|
class User < ApplicationRecord
|
||||||
has_many :fuel_entries, foreign_key: :updated_by_user_id, dependent: :restrict_with_error, inverse_of: :updated_by_user
|
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 :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
|
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 :fuel_entries, dependent: :restrict_with_error
|
||||||
has_many :maintenance_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 :make, :model, :color, :vin, :licence_plate, presence: true
|
||||||
validates :vin, uniqueness: true
|
validates :vin, uniqueness: true
|
||||||
@@ -19,4 +19,8 @@ class Vehicle < ApplicationRecord
|
|||||||
def normalize_vin
|
def normalize_vin
|
||||||
self.vin = vin.to_s.strip.upcase if vin.present?
|
self.vin = vin.to_s.strip.upcase if vin.present?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def normalize_licence_plate
|
||||||
|
self.licence_plate = licence_plate.to_s.strip.upcase if licence_plate.present?
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -24,6 +24,15 @@
|
|||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<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 %>
|
<%= yield %>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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.
|
# 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 "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)
|
# 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 "manifest" => "rails/pwa#manifest", as: :pwa_manifest
|
||||||
# get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
|
# 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
|
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.
|
# 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|
|
create_table "fuel_entries", force: :cascade do |t|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.date "date", 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"
|
t.index ["vehicle_id"], name: "index_maintenance_schedules_on_vehicle_id"
|
||||||
end
|
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|
|
create_table "users", force: :cascade do |t|
|
||||||
t.datetime "created_at", null: false
|
t.datetime "created_at", null: false
|
||||||
t.string "email", 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", "users", column: "updated_by_user_id"
|
||||||
add_foreign_key "maintenance_entries", "vehicles"
|
add_foreign_key "maintenance_entries", "vehicles"
|
||||||
add_foreign_key "maintenance_schedules", "vehicles"
|
add_foreign_key "maintenance_schedules", "vehicles"
|
||||||
|
add_foreign_key "user_sessions", "users"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -7,3 +7,5 @@
|
|||||||
# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
|
# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
|
||||||
# MovieGenre.find_or_create_by!(name: genre_name)
|
# MovieGenre.find_or_create_by!(name: genre_name)
|
||||||
# end
|
# 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).not_to be_valid
|
||||||
expect(entry.errors[:date]).to be_present
|
expect(entry.errors[:date]).to be_present
|
||||||
end
|
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
|
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")
|
expect(vehicle.vin).to eq("ABC123")
|
||||||
end
|
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
|
it "requires VIN to be unique after normalization" do
|
||||||
create(:vehicle, vin: "ABC123")
|
create(:vehicle, vin: "ABC123")
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ require "rails_helper"
|
|||||||
|
|
||||||
RSpec.describe "Home" do
|
RSpec.describe "Home" do
|
||||||
describe "GET /" do
|
describe "GET /" do
|
||||||
it "returns a successful response" do
|
before { identify_user }
|
||||||
|
|
||||||
|
it "returns the vehicle index" do
|
||||||
get root_path
|
get root_path
|
||||||
|
|
||||||
expect(response).to have_http_status(:ok)
|
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("stylesheet")
|
||||||
expect(response.body).to include("importmap")
|
expect(response.body).to include("importmap")
|
||||||
end
|
end
|
||||||
@@ -24,4 +26,9 @@ RSpec.describe "Home" do
|
|||||||
Rails.application.config.instance_variable_set(:@content_security_policy, original_policy)
|
Rails.application.config.instance_variable_set(:@content_security_policy, original_policy)
|
||||||
end
|
end
|
||||||
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
|
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"
|
require "rails_helper"
|
||||||
|
|
||||||
RSpec.describe "Home" do
|
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
|
visit root_path
|
||||||
|
|
||||||
expect(page).to have_content("Car Tracker")
|
fill_in "User name", with: user.name
|
||||||
expect(page).to have_content("Vehicle maintenance and fuel tracking will be added here.")
|
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
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user