phase002 complete

This commit is contained in:
2026-09-15 20:28:07 -06:00
parent 9793cd08bc
commit a3a65c24ed
28 changed files with 656 additions and 13 deletions
+2 -2
View File
@@ -9,8 +9,8 @@
## Commands ## Commands
- Setup/update dependencies and DB: `bin/setup --skip-server`. Omit `--skip-server` only when you want it to start `bin/dev` afterward. - Setup/update dependencies and DB: `bin/setup --skip-server`. Omit `--skip-server` only when you want it to start `bin/dev` afterward.
- Run the app: `bin/dev` (it just execs `bin/rails server`). - Run the app: `bin/dev` (it just execs `bin/rails server`).
- Repo CI script: `bin/ci` runs setup, RuboCop, bundler-audit, importmap audit, and strict Brakeman. It does not run specs. - Repo CI script: `bin/ci` runs setup, RSpec, RuboCop, bundler-audit, importmap audit, and strict Brakeman.
- GitHub CI also runs Brakeman, bundler-audit, importmap audit, and RuboCop only; do not assume tests ran in CI. - GitHub CI also runs the repo CI script; do not assume tests ran unless `bin/ci` completed successfully.
- Style check: `bin/rubocop` (`rubocop-rails-omakase` via `.rubocop.yml`). - Style check: `bin/rubocop` (`rubocop-rails-omakase` via `.rubocop.yml`).
- Security checks: `bin/bundler-audit`, `bin/importmap audit`, `bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error`. - Security checks: `bin/bundler-audit`, `bin/importmap audit`, `bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error`.
+26 -8
View File
@@ -20,6 +20,7 @@ This table will contain information about the users of the app. Mostly used to
know who updated what. This table will include: know who updated what. This table will include:
* *Fields:* `name`, `email` * *Fields:* `name`, `email`
* *Required uniqueness:* `email` is unique case-insensitively.
### 2. Vehicle ### 2. Vehicle
@@ -29,7 +30,9 @@ track. They have no strict ownership relationship as they can be used/driven by
multiple operators. The vehicle information will include: multiple operators. The vehicle information will include:
* *Fields:* `make`, `model`, `year`, `color`, `vin`, * *Fields:* `make`, `model`, `year`, `color`, `vin`,
`licence_place`, `current_odometer`, `fuel_tank_size` `licence_plate`, `current_odometer`, `fuel_tank_size`, `active`
* *Required uniqueness:* `vin` is unique and normalized to uppercase;
`licence_plate` is unique case-insensitively.
### 3. MaintenanceSchedule ### 3. MaintenanceSchedule
Configures the maintenance rules for a specific vehicle. This allows the Configures the maintenance rules for a specific vehicle. This allows the
@@ -37,6 +40,8 @@ Configures the maintenance rules for a specific vehicle. This allows the
actual history. actual history.
* *Fields:* `vehicle_id`, `maintenance_type`, `mileage_interval`, * *Fields:* `vehicle_id`, `maintenance_type`, `mileage_interval`,
`time_interval` `time_interval`
* *Note:* `time_interval` is stored as integer days. The UI can display the
value as months or days later.
* *Relationship:* Belongs to Vehicle * *Relationship:* Belongs to Vehicle
### 4. FuelEntry ### 4. FuelEntry
@@ -45,12 +50,11 @@ Records specific refueling events.
`price_paid`, `date`, `updated_by_user_id` (Tracks who submitted) `price_paid`, `date`, `updated_by_user_id` (Tracks who submitted)
* *Relationship:* Belongs to Vehicle * *Relationship:* Belongs to Vehicle
### 5. MainetnanceEntry ### 5. MaintenanceEntry
Records maintenance and repair history. Records maintenance and repair history.
* *Fields:* `vehicle_id`, `odometer` (at time of service), `description` * *Fields:* `vehicle_id`, `odometer` (at time of service), `name` (Should
(Should exactly match a `maintenance_type` value from the match a `maintenance_type` value from the MaintenanceSchedule), `notes`,
MaintenanceSchedule), `cost`, `date`, `updated_by_user_id` (Tracks who `cost`, `date`, `updated_by_user_id` (Tracks who submitted)
submitted)
* *Relationship:* Belongs to Vehicle * *Relationship:* Belongs to Vehicle
## Data Relationships ## Data Relationships
@@ -71,7 +75,7 @@ Records maintenance and repair history.
### Scenario B: Maintenance ### Scenario B: Maintenance
1. User navigates to `Vehicle#show`. 1. User navigates to `Vehicle#show`.
2. User fills form with Description, Cost, Date. 2. User fills form with Name, Notes, Cost, Date.
3. User submits -> `MaintenanceEntry` is saved to DB. 3. User submits -> `MaintenanceEntry` is saved to DB.
### Scenario C: Overdue Alerts ### Scenario C: Overdue Alerts
@@ -108,7 +112,7 @@ do not need high performance. SQLite should work fantastic in this case.
## Test framework ## Test framework
The test framework is rspec. You can run the tests with `bin/rails test` The test framework is rspec. You can run the tests with `bundle exec rspec`.
## Vehicle ownership ## Vehicle ownership
@@ -142,6 +146,10 @@ can leave stubs though for that to happen. The notification service just
requires a curl request so it is simple to add later. For now, just log it into requires a curl request so it is simple to add later. For now, just log it into
the console so that we can test it is working. the console so that we can test it is working.
Vehicles should not be deleted because maintenance and fuel history must be
preserved. If a vehicle should no longer appear in normal workflows, mark it
inactive instead.
For data access, it would be nice to have 'admin' and 'regular' users however For data access, it would be nice to have 'admin' and 'regular' users however
that is a distinction that we do not yet need. Remember to keep things simple that is a distinction that we do not yet need. Remember to keep things simple
but flexible in the future. Do not wall us in with how code is done. but flexible in the future. Do not wall us in with how code is done.
@@ -151,3 +159,13 @@ user editing the same entry at any time. The only possible issue that I can see
is if we have two users in the same vehicle and they both submit a fuel is if we have two users in the same vehicle and they both submit a fuel
entry. In this case, we would just delete one of the entries. At no point in entry. In this case, we would just delete one of the entries. At no point in
time should two users be editing the same record. time should two users be editing the same record.
# Platform information
Note that we are using Ruby 4.0.6 which is still "experimental" with
Rails 8. This means that sometimes odd isues can come from gem version
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.
+3
View File
@@ -68,3 +68,6 @@ group :development do
# Use console on exceptions pages [https://github.com/rails/web-console] # Use console on exceptions pages [https://github.com/rails/web-console]
gem "web-console" gem "web-console"
end end
# Trying to fix CSRF errors:
gem 'json', '< 3'
+3 -2
View File
@@ -148,7 +148,7 @@ GEM
jbuilder (2.15.1) jbuilder (2.15.1)
actionview (>= 7.0.0) actionview (>= 7.0.0)
activesupport (>= 7.0.0) activesupport (>= 7.0.0)
json (3.0.2) json (2.21.2)
kamal (2.12.0) kamal (2.12.0)
activesupport (>= 7.0) activesupport (>= 7.0)
base64 (~> 0.2) base64 (~> 0.2)
@@ -415,6 +415,7 @@ DEPENDENCIES
image_processing (~> 1.2) image_processing (~> 1.2)
importmap-rails importmap-rails
jbuilder jbuilder
json (< 3)
kamal kamal
propshaft propshaft
puma (>= 5.0) puma (>= 5.0)
@@ -487,7 +488,7 @@ CHECKSUMS
io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08 io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3 irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
jbuilder (2.15.1) sha256=2430bec28fb0cebacb5875b1009cf9d8bc3c303ccb810c4c8b062a4b51457637 jbuilder (2.15.1) sha256=2430bec28fb0cebacb5875b1009cf9d8bc3c303ccb810c4c8b062a4b51457637
json (3.0.2) sha256=8e6d7e7b11384c21230430cef90b71f14849a34a1f4452796670f7c981bd19df json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
kamal (2.12.0) sha256=c51d1ab085e515470f98d0c0f043637122b5ebf76e8b610cb1fbbed0b7f9b8fa kamal (2.12.0) sha256=c51d1ab085e515470f98d0c0f043637122b5ebf76e8b610cb1fbbed0b7f9b8fa
language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0 language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87 lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
+18
View File
@@ -0,0 +1,18 @@
class FuelEntry < ApplicationRecord
belongs_to :vehicle
belongs_to :updated_by_user, class_name: "User"
validates :odometer, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :gallons_pumped, presence: true, numericality: { greater_than: 0 }
validates :price_paid, presence: true, numericality: { greater_than: 0 }
validates :date, presence: true
validate :date_cannot_be_in_the_future
private
def date_cannot_be_in_the_future
return if date.blank? || date <= Date.current
errors.add(:date, "can't be in the future")
end
end
+30
View File
@@ -0,0 +1,30 @@
class MaintenanceEntry < ApplicationRecord
belongs_to :vehicle
belongs_to :updated_by_user, class_name: "User"
validates :name, :notes, :date, presence: true
validates :odometer, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :cost, presence: true, numericality: { greater_than_or_equal_to: 0 }
validate :date_cannot_be_in_the_future
validate :name_matches_vehicle_schedule
private
def date_cannot_be_in_the_future
return if date.blank? || date <= Date.current
errors.add(:date, "can't be in the future")
end
def name_matches_vehicle_schedule
return if vehicle.blank? || name.blank?
scheduled = vehicle.maintenance_schedules.any? do |schedule|
schedule.maintenance_type.casecmp?(name)
end
scheduled ||= vehicle.maintenance_schedules.where("lower(maintenance_type) = ?", name.downcase).exists? if vehicle.persisted?
return if scheduled
errors.add(:name, "must match a maintenance schedule for the vehicle")
end
end
+7
View File
@@ -0,0 +1,7 @@
class MaintenanceSchedule < ApplicationRecord
belongs_to :vehicle
validates :maintenance_type, presence: true, uniqueness: { scope: :vehicle_id, case_sensitive: false }
validates :mileage_interval, presence: true, numericality: { only_integer: true, greater_than: 0 }
validates :time_interval, presence: true, numericality: { only_integer: true, greater_than: 0 }
end
+15
View File
@@ -0,0 +1,15 @@
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
before_validation :normalize_email
validates :name, presence: true
validates :email, presence: true, uniqueness: { case_sensitive: false }
private
def normalize_email
self.email = email.to_s.strip.downcase if email.present?
end
end
+22
View File
@@ -0,0 +1,22 @@
class Vehicle < ApplicationRecord
has_many :maintenance_schedules, dependent: :restrict_with_error
has_many :fuel_entries, dependent: :restrict_with_error
has_many :maintenance_entries, dependent: :restrict_with_error
before_validation :normalize_vin
validates :make, :model, :color, :vin, :licence_plate, presence: true
validates :vin, uniqueness: true
validates :licence_plate, uniqueness: { case_sensitive: false }
validates :year, presence: true,
numericality: { only_integer: true, greater_than_or_equal_to: 1980, less_than_or_equal_to: ->(_vehicle) { Date.current.year } }
validates :current_odometer, presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 }
validates :fuel_tank_size, presence: true, numericality: { greater_than: 0 }
validates :active, inclusion: { in: [ true, false ] }
private
def normalize_vin
self.vin = vin.to_s.strip.upcase if vin.present?
end
end
+1 -1
View File
@@ -6,8 +6,8 @@
<meta name="apple-mobile-web-app-capable" content="yes"> <meta name="apple-mobile-web-app-capable" content="yes">
<meta name="application-name" content="Car Tracker"> <meta name="application-name" content="Car Tracker">
<meta name="mobile-web-app-capable" content="yes"> <meta name="mobile-web-app-capable" content="yes">
<%= csrf_meta_tags %> <%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= yield :head %> <%= yield :head %>
+12
View File
@@ -0,0 +1,12 @@
class CreateUsers < ActiveRecord::Migration[8.1]
def change
create_table :users do |t|
t.string :name, null: false
t.string :email, null: false
t.timestamps
end
add_index :users, "lower(email)", unique: true, name: "index_users_on_lower_email"
end
end
@@ -0,0 +1,20 @@
class CreateVehicles < ActiveRecord::Migration[8.1]
def change
create_table :vehicles do |t|
t.string :make, null: false
t.string :model, null: false
t.integer :year, null: false
t.string :color, null: false
t.string :vin, null: false
t.string :licence_plate, null: false
t.integer :current_odometer, null: false
t.decimal :fuel_tank_size, precision: 8, scale: 2, null: false
t.boolean :active, null: false, default: true
t.timestamps
end
add_index :vehicles, :vin, unique: true
add_index :vehicles, "lower(licence_plate)", unique: true, name: "index_vehicles_on_lower_licence_plate"
end
end
@@ -0,0 +1,17 @@
class CreateMaintenanceSchedules < ActiveRecord::Migration[8.1]
def change
create_table :maintenance_schedules do |t|
t.references :vehicle, null: false, foreign_key: true
t.string :maintenance_type, null: false
t.integer :mileage_interval, null: false
t.integer :time_interval, null: false
t.timestamps
end
add_index :maintenance_schedules,
"vehicle_id, lower(maintenance_type)",
unique: true,
name: "index_maintenance_schedules_on_vehicle_and_lower_type"
end
end
@@ -0,0 +1,14 @@
class CreateFuelEntries < ActiveRecord::Migration[8.1]
def change
create_table :fuel_entries do |t|
t.references :vehicle, null: false, foreign_key: true
t.references :updated_by_user, null: false, foreign_key: { to_table: :users }
t.integer :odometer, null: false
t.decimal :gallons_pumped, precision: 8, scale: 2, null: false
t.decimal :price_paid, precision: 10, scale: 2, null: false
t.date :date, null: false
t.timestamps
end
end
end
@@ -0,0 +1,15 @@
class CreateMaintenanceEntries < ActiveRecord::Migration[8.1]
def change
create_table :maintenance_entries do |t|
t.references :vehicle, null: false, foreign_key: true
t.references :updated_by_user, null: false, foreign_key: { to_table: :users }
t.string :name, null: false
t.text :notes, null: false
t.integer :odometer, null: false
t.decimal :cost, precision: 10, scale: 2, null: false
t.date :date, null: false
t.timestamps
end
end
end
Generated
+81
View File
@@ -0,0 +1,81 @@
# This file is auto-generated from the current state of the database. Instead
# of editing this file, please use the migrations feature of Active Record to
# incrementally modify your database, and then regenerate this schema definition.
#
# This file is the source Rails uses to define your schema when running `bin/rails
# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
# be faster and is potentially less error prone than running all of your
# migrations from scratch. Old migrations may fail to apply correctly if those
# migrations use external dependencies or application code.
#
# 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
create_table "fuel_entries", force: :cascade do |t|
t.datetime "created_at", null: false
t.date "date", null: false
t.decimal "gallons_pumped", precision: 8, scale: 2, null: false
t.integer "odometer", null: false
t.decimal "price_paid", precision: 10, scale: 2, null: false
t.datetime "updated_at", null: false
t.integer "updated_by_user_id", null: false
t.integer "vehicle_id", null: false
t.index ["updated_by_user_id"], name: "index_fuel_entries_on_updated_by_user_id"
t.index ["vehicle_id"], name: "index_fuel_entries_on_vehicle_id"
end
create_table "maintenance_entries", force: :cascade do |t|
t.decimal "cost", precision: 10, scale: 2, null: false
t.datetime "created_at", null: false
t.date "date", null: false
t.string "name", null: false
t.text "notes", null: false
t.integer "odometer", null: false
t.datetime "updated_at", null: false
t.integer "updated_by_user_id", null: false
t.integer "vehicle_id", null: false
t.index ["updated_by_user_id"], name: "index_maintenance_entries_on_updated_by_user_id"
t.index ["vehicle_id"], name: "index_maintenance_entries_on_vehicle_id"
end
create_table "maintenance_schedules", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "maintenance_type", null: false
t.integer "mileage_interval", null: false
t.integer "time_interval", null: false
t.datetime "updated_at", null: false
t.integer "vehicle_id", null: false
t.index "vehicle_id, lower(maintenance_type)", name: "index_maintenance_schedules_on_vehicle_and_lower_type", unique: true
t.index ["vehicle_id"], name: "index_maintenance_schedules_on_vehicle_id"
end
create_table "users", force: :cascade do |t|
t.datetime "created_at", null: false
t.string "email", null: false
t.string "name", null: false
t.datetime "updated_at", null: false
t.index "lower(email)", name: "index_users_on_lower_email", unique: true
end
create_table "vehicles", force: :cascade do |t|
t.boolean "active", default: true, null: false
t.string "color", null: false
t.datetime "created_at", null: false
t.integer "current_odometer", null: false
t.decimal "fuel_tank_size", precision: 8, scale: 2, null: false
t.string "licence_plate", null: false
t.string "make", null: false
t.string "model", null: false
t.datetime "updated_at", null: false
t.string "vin", null: false
t.integer "year", null: false
t.index "lower(licence_plate)", name: "index_vehicles_on_lower_licence_plate", unique: true
t.index ["vin"], name: "index_vehicles_on_vin", unique: true
end
add_foreign_key "fuel_entries", "users", column: "updated_by_user_id"
add_foreign_key "fuel_entries", "vehicles"
add_foreign_key "maintenance_entries", "users", column: "updated_by_user_id"
add_foreign_key "maintenance_entries", "vehicles"
add_foreign_key "maintenance_schedules", "vehicles"
end
+40
View File
@@ -0,0 +1,40 @@
# Phase 002 Execution
## Scope
- Added the core relational data model for users, vehicles, maintenance schedules, fuel entries, and maintenance entries.
- Added validations, factories, and model specs for the new models.
- Updated project documentation for confirmed Phase 002 decisions.
## Changes
- Added migrations for `users`, `vehicles`, `maintenance_schedules`, `fuel_entries`, and `maintenance_entries`.
- Added required fields and foreign keys for all Phase 002 tables.
- Added unique indexes for user email, vehicle VIN, vehicle licence plate, and per-vehicle maintenance schedule type.
- Added `vehicles.active` with default `true`.
- Added ActiveRecord models and associations.
- Restricted deletion of users and vehicles when historical entries reference them.
- Added model validations for presence, uniqueness, numeric bounds, realistic vehicle years, and future-date rejection.
- Added VIN normalization to uppercase.
- Added maintenance entry validation requiring `name` to match a vehicle maintenance schedule case-insensitively.
- Added factories for all new models.
- Added model specs for factories, validations, uniqueness, normalization, schedule matching, and restricted deletion.
- Updated `AGENTS.md` to reflect that `bin/ci` runs RSpec.
- Updated `DESIGN.md` for corrected names and confirmed data-model rules.
## Decisions
- All Phase 002 table fields are required with `null: false`.
- Email and licence plate uniqueness are case-insensitive.
- VIN is normalized to uppercase before validation and storage.
- `MaintenanceSchedule#time_interval` stores integer days; display conversion can happen later.
- `MaintenanceEntry#name` is structured and must match a scheduled maintenance type; `notes` stores free-form details.
- Fuel price must be greater than zero; maintenance cost may be zero.
- Fuel and maintenance entry dates cannot be in the future.
- Vehicles are not deleted; they can be marked inactive later.
## Verification
- Passed: `bundle exec rails db:migrate`
- Initial `bundle exec rspec` and `bin/rubocop` failed because `spec/models/maintenance_entry_spec.rb` had an extra `end`.
- After fixing the syntax error, `bundle exec rspec` failed because the `maintenance_entry` factory created matching schedules during negative validation specs.
- Updated the factory with an `ensure_matching_schedule` transient option and disabled it in negative specs.
- Passed: `bundle exec rspec`
- Passed: `bin/rubocop`
- Passed: `bin/ci`
+10
View File
@@ -0,0 +1,10 @@
FactoryBot.define do
factory :fuel_entry do
vehicle
association :updated_by_user, factory: :user
odometer { 10_500 }
gallons_pumped { 18.25 }
price_paid { 47.62 }
date { Date.current }
end
end
+32
View File
@@ -0,0 +1,32 @@
FactoryBot.define do
factory :maintenance_entry do
vehicle
association :updated_by_user, factory: :user
name { "Oil Change" }
notes { "Oil change performed by Fleet Service Center." }
odometer { 10_500 }
cost { 89.95 }
date { Date.current }
transient do
ensure_matching_schedule { true }
end
after(:build) do |maintenance_entry, evaluator|
next unless evaluator.ensure_matching_schedule
next if maintenance_entry.vehicle.blank?
scheduled = maintenance_entry.vehicle.maintenance_schedules.any? do |schedule|
schedule.maintenance_type.casecmp?(maintenance_entry.name)
end
next if scheduled
maintenance_entry.vehicle.maintenance_schedules.build(
maintenance_type: maintenance_entry.name,
mileage_interval: 5_000,
time_interval: 180
)
end
end
end
+8
View File
@@ -0,0 +1,8 @@
FactoryBot.define do
factory :maintenance_schedule do
vehicle
maintenance_type { "Oil Change" }
mileage_interval { 5_000 }
time_interval { 180 }
end
end
+6
View File
@@ -0,0 +1,6 @@
FactoryBot.define do
factory :user do
sequence(:name) { |n| "User #{n}" }
sequence(:email) { |n| "user#{n}@example.com" }
end
end
+13
View File
@@ -0,0 +1,13 @@
FactoryBot.define do
factory :vehicle do
make { "Ford" }
model { "F-150" }
year { Date.current.year }
color { "White" }
sequence(:vin) { |n| "1FTFW1E#{n.to_s.rjust(10, '0')}" }
sequence(:licence_plate) { |n| "FLT#{n.to_s.rjust(4, '0')}" }
current_odometer { 10_000 }
fuel_tank_size { 26.0 }
active { true }
end
end
+38
View File
@@ -0,0 +1,38 @@
require "rails_helper"
RSpec.describe FuelEntry do
it "has a valid factory" do
expect(build(:fuel_entry)).to be_valid
end
it "requires all fields" do
entry = build(
:fuel_entry,
vehicle: nil,
updated_by_user: nil,
odometer: nil,
gallons_pumped: nil,
price_paid: nil,
date: nil
)
expect(entry).not_to be_valid
expect(entry.errors.attribute_names).to include(:vehicle, :updated_by_user, :odometer, :gallons_pumped, :price_paid, :date)
end
it "requires valid numeric values" do
entry = build(:fuel_entry, odometer: -1, gallons_pumped: 0, price_paid: 0)
expect(entry).not_to be_valid
expect(entry.errors[:odometer]).to be_present
expect(entry.errors[:gallons_pumped]).to be_present
expect(entry.errors[:price_paid]).to be_present
end
it "rejects future dates" do
entry = build(:fuel_entry, date: Date.current + 1.day)
expect(entry).not_to be_valid
expect(entry.errors[:date]).to be_present
end
end
+53
View File
@@ -0,0 +1,53 @@
require "rails_helper"
RSpec.describe MaintenanceEntry do
it "has a valid factory" do
expect(build(:maintenance_entry)).to be_valid
end
it "requires all fields" do
entry = build(
:maintenance_entry,
vehicle: nil,
updated_by_user: nil,
name: nil,
notes: nil,
odometer: nil,
cost: nil,
date: nil,
ensure_matching_schedule: false
)
expect(entry).not_to be_valid
expect(entry.errors.attribute_names).to include(:vehicle, :updated_by_user, :name, :notes, :odometer, :cost, :date)
end
it "requires valid numeric values and allows zero cost" do
invalid_entry = build(:maintenance_entry, odometer: -1, cost: -1)
no_cost_entry = build(:maintenance_entry, cost: 0)
expect(invalid_entry).not_to be_valid
expect(invalid_entry.errors[:odometer]).to be_present
expect(invalid_entry.errors[:cost]).to be_present
expect(no_cost_entry).to be_valid
end
it "rejects future dates" do
entry = build(:maintenance_entry, date: Date.current + 1.day)
expect(entry).not_to be_valid
expect(entry.errors[:date]).to be_present
end
it "requires name to match a vehicle maintenance schedule case-insensitively" do
vehicle = create(:vehicle)
create(:maintenance_schedule, vehicle: vehicle, maintenance_type: "Oil Change")
matching_entry = build(:maintenance_entry, vehicle: vehicle, name: "oil change")
unknown_entry = build(:maintenance_entry, vehicle: vehicle, name: "Tire Rotation", ensure_matching_schedule: false)
expect(matching_entry).to be_valid
expect(unknown_entry).not_to be_valid
expect(unknown_entry.errors[:name]).to be_present
end
end
+34
View File
@@ -0,0 +1,34 @@
require "rails_helper"
RSpec.describe MaintenanceSchedule do
it "has a valid factory" do
expect(build(:maintenance_schedule)).to be_valid
end
it "requires all fields" do
schedule = build(:maintenance_schedule, vehicle: nil, maintenance_type: nil, mileage_interval: nil, time_interval: nil)
expect(schedule).not_to be_valid
expect(schedule.errors.attribute_names).to include(:vehicle, :maintenance_type, :mileage_interval, :time_interval)
end
it "requires positive integer intervals" do
schedule = build(:maintenance_schedule, mileage_interval: 0, time_interval: 0)
expect(schedule).not_to be_valid
expect(schedule.errors[:mileage_interval]).to be_present
expect(schedule.errors[:time_interval]).to be_present
end
it "requires maintenance type to be unique per vehicle case-insensitively" do
vehicle = create(:vehicle)
create(:maintenance_schedule, vehicle: vehicle, maintenance_type: "Oil Change")
duplicate = build(:maintenance_schedule, vehicle: vehicle, maintenance_type: "oil change")
other_vehicle_schedule = build(:maintenance_schedule, maintenance_type: "oil change")
expect(duplicate).not_to be_valid
expect(duplicate.errors[:maintenance_type]).to be_present
expect(other_vehicle_schedule).to be_valid
end
end
+32
View File
@@ -0,0 +1,32 @@
require "rails_helper"
RSpec.describe User do
it "has a valid factory" do
expect(build(:user)).to be_valid
end
it "requires a name and email" do
user = build(:user, name: nil, email: nil)
expect(user).not_to be_valid
expect(user.errors[:name]).to be_present
expect(user.errors[:email]).to be_present
end
it "requires email to be unique case-insensitively" do
create(:user, email: "driver@example.com")
user = build(:user, email: "DRIVER@example.com")
expect(user).not_to be_valid
expect(user.errors[:email]).to be_present
end
it "cannot be destroyed when entries reference it" do
user = create(:user)
create(:fuel_entry, updated_by_user: user)
expect(user.destroy).to be(false)
expect(user.errors[:base]).to be_present
end
end
+89
View File
@@ -0,0 +1,89 @@
require "rails_helper"
RSpec.describe Vehicle do
it "has a valid factory" do
expect(build(:vehicle)).to be_valid
end
it "defaults to active" do
expect(described_class.new.active).to be(true)
end
it "requires all fields" do
vehicle = build(
:vehicle,
make: nil,
model: nil,
year: nil,
color: nil,
vin: nil,
licence_plate: nil,
current_odometer: nil,
fuel_tank_size: nil,
active: nil
)
expect(vehicle).not_to be_valid
expect(vehicle.errors.attribute_names).to include(
:make,
:model,
:year,
:color,
:vin,
:licence_plate,
:current_odometer,
:fuel_tank_size,
:active
)
end
it "normalizes VIN to uppercase" do
vehicle = build(:vehicle, vin: "abc123")
vehicle.valid?
expect(vehicle.vin).to eq("ABC123")
end
it "requires VIN to be unique after normalization" do
create(:vehicle, vin: "ABC123")
vehicle = build(:vehicle, vin: "abc123")
expect(vehicle).not_to be_valid
expect(vehicle.errors[:vin]).to be_present
end
it "requires licence plate to be unique case-insensitively" do
create(:vehicle, licence_plate: "ABC123")
vehicle = build(:vehicle, licence_plate: "abc123")
expect(vehicle).not_to be_valid
expect(vehicle.errors[:licence_plate]).to be_present
end
it "requires a realistic year" do
too_old = build(:vehicle, year: 1979)
future = build(:vehicle, year: Date.current.year + 1)
expect(too_old).not_to be_valid
expect(future).not_to be_valid
end
it "requires numeric mileage and fuel tank values" do
vehicle = build(:vehicle, current_odometer: -1, fuel_tank_size: 0)
expect(vehicle).not_to be_valid
expect(vehicle.errors[:current_odometer]).to be_present
expect(vehicle.errors[:fuel_tank_size]).to be_present
end
it "cannot be destroyed when entries reference it" do
vehicle = create(:vehicle)
create(:maintenance_schedule, vehicle: vehicle)
expect(vehicle.destroy).to be(false)
expect(vehicle.errors[:base]).to be_present
end
end
+15
View File
@@ -7,6 +7,21 @@ RSpec.describe "Home" do
expect(response).to have_http_status(:ok) expect(response).to have_http_status(:ok)
expect(response.body).to include("Car Tracker") expect(response.body).to include("Car Tracker")
expect(response.body).to include("stylesheet")
expect(response.body).to include("importmap")
end
it "renders successfully when a content security policy is configured" do
original_policy = Rails.application.config.content_security_policy
Rails.application.config.content_security_policy do |policy|
policy.default_src :self
end
get root_path
expect(response).to have_http_status(:ok)
ensure
Rails.application.config.instance_variable_set(:@content_security_policy, original_policy)
end end
end end
end end