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`.
- Solid Queue jobs can run with `bin/jobs`; Kamal production config currently runs Solid Queue inside Puma via `SOLID_QUEUE_IN_PUMA: true`.
- Production Docker is intended for Kamal/production, not local development; the entrypoint runs `bin/rails db:prepare` before starting the server.
## Instructions
If you are implimenting a phase eg phaseXXX ensure that while you are modifying files you write a file in teh `executions/` folder matching using `executionXXX.md` for the filename. This is all replacing the XXX with the phase number you are on.
+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
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.
+1 -1
View File
@@ -70,4 +70,4 @@ group :development do
end
# Trying to fix CSRF errors:
gem 'json', '< 3'
gem "json", "< 3"
+52
View File
@@ -1,7 +1,59 @@
class ApplicationController < ActionController::Base
AUTH_TOKEN_COOKIE = :auth_token
# Only allow modern browsers supporting webp images, web push, badges, import maps, CSS nesting, and CSS :has.
allow_browser versions: :modern
# Changes to the importmap will invalidate the etag for HTML responses
stale_when_importmap_changes
before_action :rotate_access_key_if_needed
before_action :load_current_user
before_action :require_identified_user
helper_method :current_user, :identified?
private
def rotate_access_key_if_needed
AccessKey.rotate_if_needed!
end
def load_current_user
Current.user_session = UserSession.find_by_token(cookies.encrypted[AUTH_TOKEN_COOKIE])
Current.user = Current.user_session&.user
Current.user_session&.touch(:last_used_at)
end
def require_identified_user
return if identified?
redirect_to identify_path, alert: "Identify yourself before using Car Tracker."
end
def current_user
Current.user
end
def identified?
current_user.present?
end
def sign_in(user)
user_session, raw_token = UserSession.create_for!(user)
Current.user_session = user_session
Current.user = user
cookies.encrypted[AUTH_TOKEN_COOKIE] = {
value: raw_token,
httponly: true,
same_site: :lax
}
end
def sign_out
Current.user_session&.destroy!
Current.user_session = nil
Current.user = nil
cookies.delete(AUTH_TOKEN_COOKIE)
end
end
@@ -0,0 +1,46 @@
class FuelEntriesController < ApplicationController
def create
@vehicle = Vehicle.find(params[:vehicle_id])
@fuel_entry = @vehicle.fuel_entries.build(fuel_entry_params)
@fuel_entry.updated_by_user = Current.user
if save_fuel_entry
redirect_to @vehicle, notice: "Fuel entry recorded."
else
prepare_vehicle_show
render "vehicles/show", status: :unprocessable_entity
end
end
private
def fuel_entry_params
params.require(:fuel_entry).permit(:odometer, :gallons_pumped, :price_paid, :date)
end
def save_fuel_entry
saved = false
FuelEntry.transaction do
if @fuel_entry.save
update_vehicle_odometer(@fuel_entry.odometer)
saved = true
end
end
saved
end
def update_vehicle_odometer(odometer)
return unless odometer > @vehicle.current_odometer
@vehicle.update!(current_odometer: odometer)
end
def prepare_vehicle_show
@maintenance_schedules = @vehicle.maintenance_schedules.order(:maintenance_type)
@fuel_entries = @vehicle.fuel_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc)
@maintenance_entries = @vehicle.maintenance_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc)
@maintenance_entry = @vehicle.maintenance_entries.build(date: Date.current)
end
end
@@ -0,0 +1,61 @@
class MaintenanceEntriesController < ApplicationController
ODOMETER_WARNING_THRESHOLD = 500
def create
@vehicle = Vehicle.find(params[:vehicle_id])
original_odometer = @vehicle.current_odometer
@maintenance_entry = @vehicle.maintenance_entries.build(maintenance_entry_params)
@maintenance_entry.updated_by_user = Current.user
if save_maintenance_entry
set_old_odometer_warning(original_odometer)
redirect_to @vehicle, notice: "Maintenance entry recorded."
else
prepare_vehicle_show
render "vehicles/show", status: :unprocessable_entity
end
end
private
def maintenance_entry_params
params.require(:maintenance_entry).permit(:name, :notes, :odometer, :cost, :date)
end
def save_maintenance_entry
saved = false
MaintenanceEntry.transaction do
if @maintenance_entry.save
update_vehicle_odometer(@maintenance_entry.odometer)
saved = true
end
end
saved
end
def update_vehicle_odometer(odometer)
return unless odometer > @vehicle.current_odometer
@vehicle.update!(current_odometer: odometer)
end
def set_old_odometer_warning(original_odometer)
return unless @maintenance_entry.odometer < original_odometer - ODOMETER_WARNING_THRESHOLD
message = "Maintenance odometer is more than 500 miles behind the current vehicle odometer."
flash[:warning] = message
Rails.logger.warn(
"#{message} vehicle_id=#{@vehicle.id} maintenance_entry_id=#{@maintenance_entry.id} " \
"entry_odometer=#{@maintenance_entry.odometer} vehicle_odometer=#{original_odometer}"
)
end
def prepare_vehicle_show
@maintenance_schedules = @vehicle.maintenance_schedules.order(:maintenance_type)
@fuel_entries = @vehicle.fuel_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc)
@maintenance_entries = @vehicle.maintenance_entries.includes(:updated_by_user).order(date: :desc, created_at: :desc)
@fuel_entry = @vehicle.fuel_entries.build(date: Date.current)
end
end
+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 :date, presence: true
validate :date_cannot_be_in_the_future
validate :odometer_cannot_be_less_than_vehicle_current_odometer
private
@@ -15,4 +16,11 @@ class FuelEntry < ApplicationRecord
errors.add(:date, "can't be in the future")
end
def odometer_cannot_be_less_than_vehicle_current_odometer
return if vehicle.blank? || odometer.blank?
return if odometer >= vehicle.current_odometer
errors.add(:odometer, "cannot be less than the vehicle's current odometer")
end
end
+1
View File
@@ -1,6 +1,7 @@
class User < ApplicationRecord
has_many :fuel_entries, foreign_key: :updated_by_user_id, dependent: :restrict_with_error, inverse_of: :updated_by_user
has_many :maintenance_entries, foreign_key: :updated_by_user_id, dependent: :restrict_with_error, inverse_of: :updated_by_user
has_many :user_sessions, dependent: :destroy
before_validation :normalize_email
+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 :maintenance_entries, dependent: :restrict_with_error
before_validation :normalize_vin
before_validation :normalize_vin, :normalize_licence_plate
validates :make, :model, :color, :vin, :licence_plate, presence: true
validates :vin, uniqueness: true
@@ -19,4 +19,8 @@ class Vehicle < ApplicationRecord
def normalize_vin
self.vin = vin.to_s.strip.upcase if vin.present?
end
def normalize_licence_plate
self.licence_plate = licence_plate.to_s.strip.upcase if licence_plate.present?
end
end
+9
View File
@@ -24,6 +24,15 @@
</head>
<body>
<% if identified? %>
<header>
<p>
Identified as <%= current_user.name %> (<%= current_user.email %>)
<%= button_to "Log out", logout_path, method: :delete %>
</p>
</header>
<% end %>
<%= yield %>
</body>
</html>
+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.
get "up" => "rails/health#show", as: :rails_health_check
get "/identify", to: "sessions#new", as: :identify
post "/identify", to: "sessions#create", as: :session
delete "/session", to: "sessions#destroy", as: :logout
# Render dynamic PWA files from app/views/pwa/* (remember to link manifest in application.html.erb)
# get "manifest" => "rails/pwa#manifest", as: :pwa_manifest
# get "service-worker" => "rails/pwa#service_worker", as: :pwa_service_worker
root "home#index"
resources :vehicles do
resources :fuel_entries, only: :create
resources :maintenance_entries, only: :create
end
root "vehicles#index"
end
@@ -0,0 +1,12 @@
class CreateAccessKeys < ActiveRecord::Migration[8.1]
def change
create_table :access_keys do |t|
t.string :raw_key, null: false
t.datetime :generated_at, null: false
t.boolean :auto_rotate, null: false, default: true
t.integer :auto_rotation_frequency_days, null: false, default: 30
t.timestamps
end
end
end
@@ -0,0 +1,13 @@
class CreateUserSessions < ActiveRecord::Migration[8.1]
def change
create_table :user_sessions do |t|
t.references :user, null: false, foreign_key: true
t.string :token_digest, null: false
t.datetime :last_used_at
t.timestamps
end
add_index :user_sessions, :token_digest, unique: true
end
end
Generated
+21 -1
View File
@@ -10,7 +10,16 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema[8.1].define(version: 2026_09_14_132024) do
ActiveRecord::Schema[8.1].define(version: 2026_09_16_103647) do
create_table "access_keys", force: :cascade do |t|
t.boolean "auto_rotate", default: true, null: false
t.integer "auto_rotation_frequency_days", default: 30, null: false
t.datetime "created_at", null: false
t.datetime "generated_at", null: false
t.string "raw_key", null: false
t.datetime "updated_at", null: false
end
create_table "fuel_entries", force: :cascade do |t|
t.datetime "created_at", null: false
t.date "date", null: false
@@ -49,6 +58,16 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_14_132024) do
t.index ["vehicle_id"], name: "index_maintenance_schedules_on_vehicle_id"
end
create_table "user_sessions", force: :cascade do |t|
t.datetime "created_at", null: false
t.datetime "last_used_at"
t.string "token_digest", null: false
t.datetime "updated_at", null: false
t.integer "user_id", null: false
t.index ["token_digest"], name: "index_user_sessions_on_token_digest", unique: true
t.index ["user_id"], name: "index_user_sessions_on_user_id"
end
create_table "users", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "email", null: false
@@ -78,4 +97,5 @@ ActiveRecord::Schema[8.1].define(version: 2026_09_14_132024) do
add_foreign_key "maintenance_entries", "users", column: "updated_by_user_id"
add_foreign_key "maintenance_entries", "vehicles"
add_foreign_key "maintenance_schedules", "vehicles"
add_foreign_key "user_sessions", "users"
end
+2
View File
@@ -7,3 +7,5 @@
# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
# MovieGenre.find_or_create_by!(name: genre_name)
# 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.errors[:date]).to be_present
end
it "rejects odometer readings below the vehicle current odometer" do
vehicle = build(:vehicle, current_odometer: 10_000)
entry = build(:fuel_entry, vehicle: vehicle, odometer: 9_999)
expect(entry).not_to be_valid
expect(entry.errors[:odometer]).to be_present
end
it "allows odometer readings equal to the vehicle current odometer" do
vehicle = build(:vehicle, current_odometer: 10_000)
entry = build(:fuel_entry, vehicle: vehicle, odometer: 10_000)
expect(entry).to be_valid
end
it "allows odometer readings above the vehicle current odometer" do
vehicle = build(:vehicle, current_odometer: 10_000)
entry = build(:fuel_entry, vehicle: vehicle, odometer: 10_001)
expect(entry).to be_valid
end
end
+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")
end
it "normalizes licence plate to uppercase" do
vehicle = build(:vehicle, licence_plate: "kj7cyt")
vehicle.valid?
expect(vehicle.licence_plate).to eq("KJ7CYT")
end
it "requires VIN to be unique after normalization" do
create(:vehicle, vin: "ABC123")
+9 -2
View File
@@ -2,11 +2,13 @@ require "rails_helper"
RSpec.describe "Home" do
describe "GET /" do
it "returns a successful response" do
before { identify_user }
it "returns the vehicle index" do
get root_path
expect(response).to have_http_status(:ok)
expect(response.body).to include("Car Tracker")
expect(response.body).to include("Vehicles")
expect(response.body).to include("stylesheet")
expect(response.body).to include("importmap")
end
@@ -24,4 +26,9 @@ RSpec.describe "Home" do
Rails.application.config.instance_variable_set(:@content_security_policy, original_policy)
end
end
def identify_user(user = create(:user))
access_key = AccessKey.current
post session_path, params: { name: user.name, email: user.email, key: access_key.raw_key }
end
end
+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"
RSpec.describe "Home" do
it "shows the placeholder landing page" do
it "shows the vehicle index" do
user = create(:user)
access_key = AccessKey.current
visit root_path
expect(page).to have_content("Car Tracker")
expect(page).to have_content("Vehicle maintenance and fuel tracking will be added here.")
fill_in "User name", with: user.name
fill_in "Email", with: user.email
fill_in "Access key", with: access_key.raw_key
click_on "Identify"
expect(page).to have_content("Vehicles")
expect(page).to have_link("New vehicle")
end
end