Finished phase005
CI / scan_ruby (push) Failing after 1m5s
CI / scan_js (push) Failing after 6s
CI / lint (push) Failing after 1m29s

This commit is contained in:
2026-09-16 15:34:50 -06:00
parent a3a65c24ed
commit 0826d267f2
38 changed files with 1596 additions and 9 deletions
+4
View File
@@ -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.
+1
View File
@@ -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.
+1 -1
View File
@@ -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"
+52
View File
@@ -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
+52
View File
@@ -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
+81
View File
@@ -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
}
}
+65
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
class Current < ActiveSupport::CurrentAttributes
attribute :user, :user_session
end
+8
View File
@@ -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
View File
@@ -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
+28
View File
@@ -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
+5 -1
View File
@@ -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
+9
View File
@@ -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>
+29
View File
@@ -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>
+62
View File
@@ -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 %>
+7
View File
@@ -0,0 +1,7 @@
<main>
<h1>Edit Vehicle</h1>
<%= render "form", vehicle: @vehicle %>
<p><%= link_to "Back to vehicle", @vehicle %></p>
</main>
+38
View File
@@ -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>
+7
View File
@@ -0,0 +1,7 @@
<main>
<h1>New Vehicle</h1>
<%= render "form", vehicle: @vehicle %>
<p><%= link_to "Back to vehicles", vehicles_path %></p>
</main>
+243
View File
@@ -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
View File
@@ -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
View File
@@ -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
+2
View File
@@ -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
+36
View File
@@ -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.
+49
View File
@@ -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`
+51
View File
@@ -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`
+53
View File
@@ -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
+22
View File
@@ -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
+21
View File
@@ -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
+8
View File
@@ -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")
+9 -2
View File
@@ -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
+79
View File
@@ -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
+376
View File
@@ -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
+11 -3
View File
@@ -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