diff --git a/AGENTS.md b/AGENTS.md
index 9b5eca2..1d00522 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -9,8 +9,8 @@
## Commands
- 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`).
-- Repo CI script: `bin/ci` runs setup, RuboCop, bundler-audit, importmap audit, and strict Brakeman. It does not run specs.
-- GitHub CI also runs Brakeman, bundler-audit, importmap audit, and RuboCop only; do not assume tests ran in CI.
+- Repo CI script: `bin/ci` runs setup, RSpec, RuboCop, bundler-audit, importmap audit, and strict Brakeman.
+- 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`).
- Security checks: `bin/bundler-audit`, `bin/importmap audit`, `bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error`.
diff --git a/DESIGN.md b/DESIGN.md
index bfc487d..6f713ea 100644
--- a/DESIGN.md
+++ b/DESIGN.md
@@ -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:
* *Fields:* `name`, `email`
+* *Required uniqueness:* `email` is unique case-insensitively.
### 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:
* *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
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.
* *Fields:* `vehicle_id`, `maintenance_type`, `mileage_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
### 4. FuelEntry
@@ -45,12 +50,11 @@ Records specific refueling events.
`price_paid`, `date`, `updated_by_user_id` (Tracks who submitted)
* *Relationship:* Belongs to Vehicle
-### 5. MainetnanceEntry
+### 5. MaintenanceEntry
Records maintenance and repair history.
-* *Fields:* `vehicle_id`, `odometer` (at time of service), `description`
- (Should exactly match a `maintenance_type` value from the
- MaintenanceSchedule), `cost`, `date`, `updated_by_user_id` (Tracks who
- submitted)
+* *Fields:* `vehicle_id`, `odometer` (at time of service), `name` (Should
+ match a `maintenance_type` value from the MaintenanceSchedule), `notes`,
+ `cost`, `date`, `updated_by_user_id` (Tracks who submitted)
* *Relationship:* Belongs to Vehicle
## Data Relationships
@@ -71,7 +75,7 @@ Records maintenance and repair history.
### Scenario B: Maintenance
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.
### Scenario C: Overdue Alerts
@@ -108,7 +112,7 @@ do not need high performance. SQLite should work fantastic in this case.
## 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
@@ -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
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
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.
@@ -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
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.
+
+
+# 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.
diff --git a/Gemfile b/Gemfile
index de5c2e1..26b1593 100644
--- a/Gemfile
+++ b/Gemfile
@@ -68,3 +68,6 @@ group :development do
# Use console on exceptions pages [https://github.com/rails/web-console]
gem "web-console"
end
+
+# Trying to fix CSRF errors:
+gem 'json', '< 3'
diff --git a/Gemfile.lock b/Gemfile.lock
index 6b0cec1..b8d7782 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -148,7 +148,7 @@ GEM
jbuilder (2.15.1)
actionview (>= 7.0.0)
activesupport (>= 7.0.0)
- json (3.0.2)
+ json (2.21.2)
kamal (2.12.0)
activesupport (>= 7.0)
base64 (~> 0.2)
@@ -415,6 +415,7 @@ DEPENDENCIES
image_processing (~> 1.2)
importmap-rails
jbuilder
+ json (< 3)
kamal
propshaft
puma (>= 5.0)
@@ -487,7 +488,7 @@ CHECKSUMS
io-console (0.9.2) sha256=efa74f891dd03c0939a931dfc6e74c2813d904763d456ea9762b0525e748db08
irb (1.18.0) sha256=de9454a0703a54704b9811a5ef31a60c86949fbf4013fcf244fabc7c775248e3
jbuilder (2.15.1) sha256=2430bec28fb0cebacb5875b1009cf9d8bc3c303ccb810c4c8b062a4b51457637
- json (3.0.2) sha256=8e6d7e7b11384c21230430cef90b71f14849a34a1f4452796670f7c981bd19df
+ json (2.21.2) sha256=1f1d3b7cf2b3ba1a69beca0bb6db13d5438b80bff3cd54cdaaa620b9b07c1c6a
kamal (2.12.0) sha256=c51d1ab085e515470f98d0c0f043637122b5ebf76e8b610cb1fbbed0b7f9b8fa
language_server-protocol (3.17.0.6) sha256=5ef2c0c138f8267e1bc631d3328347d354f96724b0af22f2c79516120443b7f0
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
diff --git a/app/models/fuel_entry.rb b/app/models/fuel_entry.rb
new file mode 100644
index 0000000..35090e3
--- /dev/null
+++ b/app/models/fuel_entry.rb
@@ -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
diff --git a/app/models/maintenance_entry.rb b/app/models/maintenance_entry.rb
new file mode 100644
index 0000000..62632b8
--- /dev/null
+++ b/app/models/maintenance_entry.rb
@@ -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
diff --git a/app/models/maintenance_schedule.rb b/app/models/maintenance_schedule.rb
new file mode 100644
index 0000000..467c801
--- /dev/null
+++ b/app/models/maintenance_schedule.rb
@@ -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
diff --git a/app/models/user.rb b/app/models/user.rb
new file mode 100644
index 0000000..8878592
--- /dev/null
+++ b/app/models/user.rb
@@ -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
diff --git a/app/models/vehicle.rb b/app/models/vehicle.rb
new file mode 100644
index 0000000..6387fae
--- /dev/null
+++ b/app/models/vehicle.rb
@@ -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
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 1102f63..cb8c027 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -6,8 +6,8 @@
+
<%= csrf_meta_tags %>
- <%= csp_meta_tag %>
<%= yield :head %>
diff --git a/db/migrate/20260914132019_create_users.rb b/db/migrate/20260914132019_create_users.rb
new file mode 100644
index 0000000..8a7147c
--- /dev/null
+++ b/db/migrate/20260914132019_create_users.rb
@@ -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
diff --git a/db/migrate/20260914132020_create_vehicles.rb b/db/migrate/20260914132020_create_vehicles.rb
new file mode 100644
index 0000000..58915ea
--- /dev/null
+++ b/db/migrate/20260914132020_create_vehicles.rb
@@ -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
diff --git a/db/migrate/20260914132022_create_maintenance_schedules.rb b/db/migrate/20260914132022_create_maintenance_schedules.rb
new file mode 100644
index 0000000..6934470
--- /dev/null
+++ b/db/migrate/20260914132022_create_maintenance_schedules.rb
@@ -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
diff --git a/db/migrate/20260914132023_create_fuel_entries.rb b/db/migrate/20260914132023_create_fuel_entries.rb
new file mode 100644
index 0000000..778399b
--- /dev/null
+++ b/db/migrate/20260914132023_create_fuel_entries.rb
@@ -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
diff --git a/db/migrate/20260914132024_create_maintenance_entries.rb b/db/migrate/20260914132024_create_maintenance_entries.rb
new file mode 100644
index 0000000..1ace909
--- /dev/null
+++ b/db/migrate/20260914132024_create_maintenance_entries.rb
@@ -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
diff --git a/db/schema.rb b/db/schema.rb
new file mode 100644
index 0000000..991da20
--- /dev/null
+++ b/db/schema.rb
@@ -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
diff --git a/executions/phase002.md b/executions/phase002.md
new file mode 100644
index 0000000..90024c2
--- /dev/null
+++ b/executions/phase002.md
@@ -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`
diff --git a/spec/factories/fuel_entries.rb b/spec/factories/fuel_entries.rb
new file mode 100644
index 0000000..fc00bc0
--- /dev/null
+++ b/spec/factories/fuel_entries.rb
@@ -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
diff --git a/spec/factories/maintenance_entries.rb b/spec/factories/maintenance_entries.rb
new file mode 100644
index 0000000..3cbef46
--- /dev/null
+++ b/spec/factories/maintenance_entries.rb
@@ -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
diff --git a/spec/factories/maintenance_schedules.rb b/spec/factories/maintenance_schedules.rb
new file mode 100644
index 0000000..7a40627
--- /dev/null
+++ b/spec/factories/maintenance_schedules.rb
@@ -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
diff --git a/spec/factories/users.rb b/spec/factories/users.rb
new file mode 100644
index 0000000..3f548be
--- /dev/null
+++ b/spec/factories/users.rb
@@ -0,0 +1,6 @@
+FactoryBot.define do
+ factory :user do
+ sequence(:name) { |n| "User #{n}" }
+ sequence(:email) { |n| "user#{n}@example.com" }
+ end
+end
diff --git a/spec/factories/vehicles.rb b/spec/factories/vehicles.rb
new file mode 100644
index 0000000..e7c3e9a
--- /dev/null
+++ b/spec/factories/vehicles.rb
@@ -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
diff --git a/spec/models/fuel_entry_spec.rb b/spec/models/fuel_entry_spec.rb
new file mode 100644
index 0000000..26cdce1
--- /dev/null
+++ b/spec/models/fuel_entry_spec.rb
@@ -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
diff --git a/spec/models/maintenance_entry_spec.rb b/spec/models/maintenance_entry_spec.rb
new file mode 100644
index 0000000..5e3eabd
--- /dev/null
+++ b/spec/models/maintenance_entry_spec.rb
@@ -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
diff --git a/spec/models/maintenance_schedule_spec.rb b/spec/models/maintenance_schedule_spec.rb
new file mode 100644
index 0000000..5e5f882
--- /dev/null
+++ b/spec/models/maintenance_schedule_spec.rb
@@ -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
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
new file mode 100644
index 0000000..9c93369
--- /dev/null
+++ b/spec/models/user_spec.rb
@@ -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
diff --git a/spec/models/vehicle_spec.rb b/spec/models/vehicle_spec.rb
new file mode 100644
index 0000000..8b8d329
--- /dev/null
+++ b/spec/models/vehicle_spec.rb
@@ -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
diff --git a/spec/requests/home_spec.rb b/spec/requests/home_spec.rb
index beb6d8c..886455b 100644
--- a/spec/requests/home_spec.rb
+++ b/spec/requests/home_spec.rb
@@ -7,6 +7,21 @@ RSpec.describe "Home" do
expect(response).to have_http_status(:ok)
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