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