From 0826d267f262e57f38d78b0c138da9818ba4c097 Mon Sep 17 00:00:00 2001 From: bionickatana Date: Wed, 16 Sep 2026 15:34:50 -0600 Subject: [PATCH] Finished phase005 --- AGENTS.md | 4 + DESIGN.md | 1 + Gemfile | 2 +- app/controllers/application_controller.rb | 52 +++ app/controllers/fuel_entries_controller.rb | 46 +++ .../maintenance_entries_controller.rb | 61 +++ app/controllers/sessions_controller.rb | 52 +++ app/controllers/vehicles_controller.rb | 81 ++++ ...maintenance_odometer_warning_controller.js | 20 + app/models/access_key.rb | 65 +++ app/models/current.rb | 3 + app/models/fuel_entry.rb | 8 + app/models/user.rb | 1 + app/models/user_session.rb | 28 ++ app/models/vehicle.rb | 6 +- app/views/layouts/application.html.erb | 9 + app/views/sessions/new.html.erb | 29 ++ app/views/vehicles/_form.html.erb | 62 +++ app/views/vehicles/edit.html.erb | 7 + app/views/vehicles/index.html.erb | 38 ++ app/views/vehicles/new.html.erb | 7 + app/views/vehicles/show.html.erb | 243 +++++++++++ config/routes.rb | 11 +- .../20260916103646_create_access_keys.rb | 12 + .../20260916103647_create_user_sessions.rb | 13 + db/schema.rb | 22 +- db/seeds.rb | 2 + executions/phase003.md | 36 ++ executions/phase004.md | 49 +++ executions/phase005.md | 51 +++ spec/models/access_key_spec.rb | 53 +++ spec/models/fuel_entry_spec.rb | 22 + spec/models/user_session_spec.rb | 21 + spec/models/vehicle_spec.rb | 8 + spec/requests/home_spec.rb | 11 +- spec/requests/sessions_spec.rb | 79 ++++ spec/requests/vehicles_spec.rb | 376 ++++++++++++++++++ spec/system/home_spec.rb | 14 +- 38 files changed, 1596 insertions(+), 9 deletions(-) create mode 100644 app/controllers/fuel_entries_controller.rb create mode 100644 app/controllers/maintenance_entries_controller.rb create mode 100644 app/controllers/sessions_controller.rb create mode 100644 app/controllers/vehicles_controller.rb create mode 100644 app/javascript/controllers/maintenance_odometer_warning_controller.js create mode 100644 app/models/access_key.rb create mode 100644 app/models/current.rb create mode 100644 app/models/user_session.rb create mode 100644 app/views/sessions/new.html.erb create mode 100644 app/views/vehicles/_form.html.erb create mode 100644 app/views/vehicles/edit.html.erb create mode 100644 app/views/vehicles/index.html.erb create mode 100644 app/views/vehicles/new.html.erb create mode 100644 app/views/vehicles/show.html.erb create mode 100644 db/migrate/20260916103646_create_access_keys.rb create mode 100644 db/migrate/20260916103647_create_user_sessions.rb create mode 100644 executions/phase003.md create mode 100644 executions/phase004.md create mode 100644 executions/phase005.md create mode 100644 spec/models/access_key_spec.rb create mode 100644 spec/models/user_session_spec.rb create mode 100644 spec/requests/sessions_spec.rb create mode 100644 spec/requests/vehicles_spec.rb diff --git a/AGENTS.md b/AGENTS.md index 1d00522..68f2f1d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/DESIGN.md b/DESIGN.md index 6f713ea..1e50f62 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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. + diff --git a/Gemfile b/Gemfile index 26b1593..c496219 100644 --- a/Gemfile +++ b/Gemfile @@ -70,4 +70,4 @@ group :development do end # Trying to fix CSRF errors: -gem 'json', '< 3' +gem "json", "< 3" diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index c353756..3809ba8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -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 diff --git a/app/controllers/fuel_entries_controller.rb b/app/controllers/fuel_entries_controller.rb new file mode 100644 index 0000000..db6b5f5 --- /dev/null +++ b/app/controllers/fuel_entries_controller.rb @@ -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 diff --git a/app/controllers/maintenance_entries_controller.rb b/app/controllers/maintenance_entries_controller.rb new file mode 100644 index 0000000..be23e21 --- /dev/null +++ b/app/controllers/maintenance_entries_controller.rb @@ -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 diff --git a/app/controllers/sessions_controller.rb b/app/controllers/sessions_controller.rb new file mode 100644 index 0000000..e1205d7 --- /dev/null +++ b/app/controllers/sessions_controller.rb @@ -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 diff --git a/app/controllers/vehicles_controller.rb b/app/controllers/vehicles_controller.rb new file mode 100644 index 0000000..ee2c705 --- /dev/null +++ b/app/controllers/vehicles_controller.rb @@ -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 diff --git a/app/javascript/controllers/maintenance_odometer_warning_controller.js b/app/javascript/controllers/maintenance_odometer_warning_controller.js new file mode 100644 index 0000000..2740b6d --- /dev/null +++ b/app/javascript/controllers/maintenance_odometer_warning_controller.js @@ -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 + } +} diff --git a/app/models/access_key.rb b/app/models/access_key.rb new file mode 100644 index 0000000..bf627b8 --- /dev/null +++ b/app/models/access_key.rb @@ -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 diff --git a/app/models/current.rb b/app/models/current.rb new file mode 100644 index 0000000..f1051db --- /dev/null +++ b/app/models/current.rb @@ -0,0 +1,3 @@ +class Current < ActiveSupport::CurrentAttributes + attribute :user, :user_session +end diff --git a/app/models/fuel_entry.rb b/app/models/fuel_entry.rb index 35090e3..1a73c95 100644 --- a/app/models/fuel_entry.rb +++ b/app/models/fuel_entry.rb @@ -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 diff --git a/app/models/user.rb b/app/models/user.rb index 8878592..7955a9f 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -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 diff --git a/app/models/user_session.rb b/app/models/user_session.rb new file mode 100644 index 0000000..4602e72 --- /dev/null +++ b/app/models/user_session.rb @@ -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 diff --git a/app/models/vehicle.rb b/app/models/vehicle.rb index 6387fae..f680401 100644 --- a/app/models/vehicle.rb +++ b/app/models/vehicle.rb @@ -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 diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb index cb8c027..098f121 100644 --- a/app/views/layouts/application.html.erb +++ b/app/views/layouts/application.html.erb @@ -24,6 +24,15 @@ + <% if identified? %> +
+

+ Identified as <%= current_user.name %> (<%= current_user.email %>) + <%= button_to "Log out", logout_path, method: :delete %> +

+
+ <% end %> + <%= yield %> diff --git a/app/views/sessions/new.html.erb b/app/views/sessions/new.html.erb new file mode 100644 index 0000000..99c37e3 --- /dev/null +++ b/app/views/sessions/new.html.erb @@ -0,0 +1,29 @@ +
+

<%= notice %>

+

<%= alert %>

+ +

Identify yourself

+ +

Enter the current access key along with your name and email.

+ + <%= form_with url: session_path do |form| %> +
+ <%= form.label :name, "User name" %>
+ <%= form.text_field :name, value: params[:name], required: true, autocomplete: "name" %> +
+ +
+ <%= form.label :email %>
+ <%= form.email_field :email, value: params[:email], required: true, autocomplete: "email" %> +
+ +
+ <%= form.label :key, "Access key" %>
+ <%= form.text_field :key, required: true, autocomplete: "off" %> +
+ +
+ <%= form.submit "Identify" %> +
+ <% end %> +
diff --git a/app/views/vehicles/_form.html.erb b/app/views/vehicles/_form.html.erb new file mode 100644 index 0000000..c48c42c --- /dev/null +++ b/app/views/vehicles/_form.html.erb @@ -0,0 +1,62 @@ +<%= form_with model: vehicle do |form| %> + <% if vehicle.errors.any? %> +
+

<%= pluralize(vehicle.errors.count, "error") %> prevented this vehicle from being saved

+ + +
+ <% end %> + +
+ <%= form.label :make %>
+ <%= form.text_field :make %> +
+ +
+ <%= form.label :model %>
+ <%= form.text_field :model %> +
+ +
+ <%= form.label :year %>
+ <%= form.number_field :year %> +
+ +
+ <%= form.label :color %>
+ <%= form.text_field :color %> +
+ +
+ <%= form.label :vin, "VIN" %>
+ <%= form.text_field :vin %> +
+ +
+ <%= form.label :licence_plate %>
+ <%= form.text_field :licence_plate %> +
+ +
+ <%= form.label :current_odometer %>
+ <%= form.number_field :current_odometer %> +
+ +
+ <%= form.label :fuel_tank_size %>
+ <%= form.number_field :fuel_tank_size, step: "0.01" %> +
+ +
+ <%= form.check_box :active %> + <%= form.label :active %> +
+ +
+ <%= form.submit %> +
+<% end %> diff --git a/app/views/vehicles/edit.html.erb b/app/views/vehicles/edit.html.erb new file mode 100644 index 0000000..491ebae --- /dev/null +++ b/app/views/vehicles/edit.html.erb @@ -0,0 +1,7 @@ +
+

Edit Vehicle

+ + <%= render "form", vehicle: @vehicle %> + +

<%= link_to "Back to vehicle", @vehicle %>

+
diff --git a/app/views/vehicles/index.html.erb b/app/views/vehicles/index.html.erb new file mode 100644 index 0000000..491ef0e --- /dev/null +++ b/app/views/vehicles/index.html.erb @@ -0,0 +1,38 @@ +
+

<%= notice %>

+ +

Vehicles

+ + + +

<%= link_to "New vehicle", new_vehicle_path %>

+ + <% if @vehicles.any? %> + + + + + + + + + + + <% @vehicles.each do |vehicle| %> + + + + + + + <% end %> + +
VehicleLicence plateOdometerStatus
<%= link_to "#{vehicle.year} #{vehicle.make} #{vehicle.model}", vehicle %><%= vehicle.licence_plate %><%= number_with_delimiter(vehicle.current_odometer) %><%= vehicle.active? ? "Active" : "Inactive" %>
+ <% else %> +

No vehicles found.

+ <% end %> +
diff --git a/app/views/vehicles/new.html.erb b/app/views/vehicles/new.html.erb new file mode 100644 index 0000000..7b5a1ff --- /dev/null +++ b/app/views/vehicles/new.html.erb @@ -0,0 +1,7 @@ +
+

New Vehicle

+ + <%= render "form", vehicle: @vehicle %> + +

<%= link_to "Back to vehicles", vehicles_path %>

+
diff --git a/app/views/vehicles/show.html.erb b/app/views/vehicles/show.html.erb new file mode 100644 index 0000000..21ee1dc --- /dev/null +++ b/app/views/vehicles/show.html.erb @@ -0,0 +1,243 @@ +
+

<%= notice %>

+

<%= flash[:warning] %>

+ +

<%= link_to "Back to vehicles", vehicles_path %>

+ +

<%= @vehicle.year %> <%= @vehicle.make %> <%= @vehicle.model %>

+ +
+ <%= link_to "Edit", edit_vehicle_path(@vehicle) %> + <% if @vehicle.active? %> + | <%= button_to "Deactivate", @vehicle, method: :delete %> + <% end %> +
+ +
+

Vehicle Details

+ +
+
Make
+
<%= @vehicle.make %>
+ +
Model
+
<%= @vehicle.model %>
+ +
Year
+
<%= @vehicle.year %>
+ +
Color
+
<%= @vehicle.color %>
+ +
VIN
+
<%= @vehicle.vin %>
+ +
Licence plate
+
<%= @vehicle.licence_plate %>
+ +
Current odometer
+
<%= number_with_delimiter(@vehicle.current_odometer) %>
+ +
Fuel tank size
+
<%= @vehicle.fuel_tank_size %>
+ +
Status
+
<%= @vehicle.active? ? "Active" : "Inactive" %>
+
+
+ +
+

Maintenance Schedules

+ + <% if @maintenance_schedules.any? %> + + + + + + + + + + <% @maintenance_schedules.each do |schedule| %> + + + + + + <% end %> + +
TypeMileage intervalTime interval
<%= schedule.maintenance_type %><%= number_with_delimiter(schedule.mileage_interval) %> miles<%= schedule.time_interval %> days
+ <% else %> +

No maintenance schedules recorded.

+ <% end %> +
+ +
+

Record Fuel

+ + <%= form_with model: [@vehicle, @fuel_entry] do |form| %> + <% if @fuel_entry.errors.any? %> +
+

<%= pluralize(@fuel_entry.errors.count, "error") %> prevented this fuel entry from being saved

+ +
    + <% @fuel_entry.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :odometer %>
+ <%= form.number_field :odometer, min: @vehicle.current_odometer, required: true %> +
+ +
+ <%= form.label :gallons_pumped %>
+ <%= form.number_field :gallons_pumped, min: 0.01, step: "0.01", required: true %> +
+ +
+ <%= form.label :price_paid %>
+ <%= form.number_field :price_paid, min: 0.01, step: "0.01", required: true %> +
+ +
+ <%= form.label :date %>
+ <%= form.date_field :date, max: Date.current, required: true %> +
+ +
+ <%= form.submit "Record fuel" %> +
+ <% end %> +
+ +
+

Record Maintenance

+ + <% 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? %> +
+

<%= pluralize(@maintenance_entry.errors.count, "error") %> prevented this maintenance entry from being saved

+ +
    + <% @maintenance_entry.errors.full_messages.each do |message| %> +
  • <%= message %>
  • + <% end %> +
+
+ <% end %> + +
+ <%= form.label :name, "Maintenance type" %>
+ <%= form.select :name, options_for_select(@maintenance_schedules.map(&:maintenance_type), @maintenance_entry.name), { prompt: "Choose maintenance type" }, required: true %> +
+ +
+ <%= form.label :odometer %>
+ <%= 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" + } %> +
+ + + +
+ <%= form.label :cost %>
+ <%= form.number_field :cost, min: 0, step: "0.01", required: true %> +
+ +
+ <%= form.label :date %>
+ <%= form.date_field :date, max: Date.current, required: true %> +
+ +
+ <%= form.label :notes %>
+ <%= form.text_area :notes, required: true %> +
+ +
+ <%= form.submit "Record maintenance" %> +
+ <% end %> + <% else %> +

Add a maintenance schedule before recording maintenance.

+ <% end %> +
+ +
+

Fuel History

+ + <% if @fuel_entries.any? %> + + + + + + + + + + + + <% @fuel_entries.each do |entry| %> + + + + + + + + <% end %> + +
DateOdometerGallonsPrice paidUpdated by
<%= entry.date %><%= number_with_delimiter(entry.odometer) %><%= entry.gallons_pumped %><%= number_to_currency(entry.price_paid) %><%= entry.updated_by_user.name %>
+ <% else %> +

No fuel entries recorded.

+ <% end %> +
+ +
+

Maintenance History

+ + <% if @maintenance_entries.any? %> + + + + + + + + + + + + + <% @maintenance_entries.each do |entry| %> + + + + + + + + + <% end %> + +
DateNameOdometerCostNotesUpdated by
<%= entry.date %><%= entry.name %><%= number_with_delimiter(entry.odometer) %><%= number_to_currency(entry.cost) %><%= entry.notes %><%= entry.updated_by_user.name %>
+ <% else %> +

No maintenance entries recorded.

+ <% end %> +
+
diff --git a/config/routes.rb b/config/routes.rb index 079b0cc..e346f47 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -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 diff --git a/db/migrate/20260916103646_create_access_keys.rb b/db/migrate/20260916103646_create_access_keys.rb new file mode 100644 index 0000000..0a93e64 --- /dev/null +++ b/db/migrate/20260916103646_create_access_keys.rb @@ -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 diff --git a/db/migrate/20260916103647_create_user_sessions.rb b/db/migrate/20260916103647_create_user_sessions.rb new file mode 100644 index 0000000..2d2fc89 --- /dev/null +++ b/db/migrate/20260916103647_create_user_sessions.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 991da20..62027bd 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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 diff --git a/db/seeds.rb b/db/seeds.rb index 4fbd6ed..3917c82 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -7,3 +7,5 @@ # ["Action", "Comedy", "Drama", "Horror"].each do |genre_name| # MovieGenre.find_or_create_by!(name: genre_name) # end + +AccessKey.current diff --git a/executions/phase003.md b/executions/phase003.md new file mode 100644 index 0000000..cb92031 --- /dev/null +++ b/executions/phase003.md @@ -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. diff --git a/executions/phase004.md b/executions/phase004.md new file mode 100644 index 0000000..9c512f1 --- /dev/null +++ b/executions/phase004.md @@ -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` diff --git a/executions/phase005.md b/executions/phase005.md new file mode 100644 index 0000000..b929846 --- /dev/null +++ b/executions/phase005.md @@ -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` diff --git a/spec/models/access_key_spec.rb b/spec/models/access_key_spec.rb new file mode 100644 index 0000000..2d379cc --- /dev/null +++ b/spec/models/access_key_spec.rb @@ -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 diff --git a/spec/models/fuel_entry_spec.rb b/spec/models/fuel_entry_spec.rb index 26cdce1..f48661a 100644 --- a/spec/models/fuel_entry_spec.rb +++ b/spec/models/fuel_entry_spec.rb @@ -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 diff --git a/spec/models/user_session_spec.rb b/spec/models/user_session_spec.rb new file mode 100644 index 0000000..8836536 --- /dev/null +++ b/spec/models/user_session_spec.rb @@ -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 diff --git a/spec/models/vehicle_spec.rb b/spec/models/vehicle_spec.rb index 8b8d329..abba019 100644 --- a/spec/models/vehicle_spec.rb +++ b/spec/models/vehicle_spec.rb @@ -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") diff --git a/spec/requests/home_spec.rb b/spec/requests/home_spec.rb index 886455b..a95eca3 100644 --- a/spec/requests/home_spec.rb +++ b/spec/requests/home_spec.rb @@ -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 diff --git a/spec/requests/sessions_spec.rb b/spec/requests/sessions_spec.rb new file mode 100644 index 0000000..72ebaef --- /dev/null +++ b/spec/requests/sessions_spec.rb @@ -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 diff --git a/spec/requests/vehicles_spec.rb b/spec/requests/vehicles_spec.rb new file mode 100644 index 0000000..4fa3dc0 --- /dev/null +++ b/spec/requests/vehicles_spec.rb @@ -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 diff --git a/spec/system/home_spec.rb b/spec/system/home_spec.rb index 48c536a..f57d3b7 100644 --- a/spec/system/home_spec.rb +++ b/spec/system/home_spec.rb @@ -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