From 395e59683bd1c2af53b8896c5c5d228e2d243eb9 Mon Sep 17 00:00:00 2001 From: Eduardo Bravo Date: Sun, 2 Aug 2026 23:45:16 -0400 Subject: [PATCH] feat: implement granular filtering for earthquake data - add database indexes for query optimization --- .rubocop.yml | 12 +- Makefile | 22 ++- app/controllers/sismos_controller.rb | 120 +++++++++++++- app/models/sismo.rb | 11 ++ ...0803034001_add_filter_indexes_to_sismos.rb | 9 ++ db/schema.rb | 5 +- .../sismos_controller_filters_test.rb | 147 ++++++++++++++++++ test/controllers/sismos_controller_test.rb | 103 ++++++++++++ test/fixtures/sismos.yml | 64 ++++++-- test/models/sismo_test.rb | 124 ++++++++++++++- 10 files changed, 593 insertions(+), 24 deletions(-) create mode 100644 db/migrate/20260803034001_add_filter_indexes_to_sismos.rb create mode 100644 test/controllers/sismos_controller_filters_test.rb diff --git a/.rubocop.yml b/.rubocop.yml index ac0529e..153a3c8 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -21,7 +21,17 @@ Style/FrozenStringLiteralComment: Metrics/MethodLength: Max: 30 -# Rake DSL uses long task blocks by nature +Metrics/ClassLength: + Exclude: + - 'app/controllers/sismos_controller.rb' + - 'test/controllers/sismos_controller_filters_test.rb' + - 'test/controllers/sismos_controller_test.rb' + - 'test/models/sismo_test.rb' + +# Rake DSL and specific test suites use long blocks by nature Metrics/BlockLength: Exclude: - 'lib/tasks/**/*.rake' + - 'test/controllers/sismos_controller_filters_test.rb' + - 'test/controllers/sismos_controller_test.rb' + - 'test/models/sismo_test.rb' diff --git a/Makefile b/Makefile index 7a30ef8..c2905fd 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: help backend install clean dev-build dev-up dev-down dev-down-clean dev-setup dev-install dev-shell-backend +.PHONY: help backend install clean lint lint-fix dev-build dev-up dev-down dev-down-clean dev-setup dev-install dev-lint dev-lint-fix dev-shell-backend # Colors for terminal output GREEN = \033[0;32m @@ -9,6 +9,8 @@ help: ## Show help information @echo " make help - Show this help message" @echo " make install - Install all dependencies (host)" @echo " make backend - Start backend server (host)" + @echo " make lint - Run RuboCop linter (host)" + @echo " make lint-fix - Run RuboCop auto-correct (host)" @echo " make clean - Clean temporary files (host)" @echo "" @echo " Docker dev commands:" @@ -18,6 +20,8 @@ help: ## Show help information @echo " make dev-down-clean - Stop containers + remove volumes (fresh start)" @echo " make dev-setup - Full setup: build, create DB, run migrations" @echo " make dev-install - Install/update dependencies inside containers" + @echo " make dev-lint - Run RuboCop linter inside backend container" + @echo " make dev-lint-fix - Run RuboCop auto-correct inside backend container" @echo " make dev-shell-backend - Open shell in backend container" install: ## Install dependencies @@ -28,6 +32,14 @@ backend: ## Start backend server @echo "$(GREEN)Starting backend server...$(NC)" @bin/rails s +lint: ## Run RuboCop linter (host) + @echo "$(GREEN)Running RuboCop...$(NC)" + @bundle exec rubocop + +lint-fix: ## Run RuboCop auto-correct (host) + @echo "$(GREEN)Running RuboCop auto-correct...$(NC)" + @bundle exec rubocop -A + clean: ## Clean temporary files @echo "$(GREEN)Cleaning temporary files...$(NC)" @rm -rf tmp/cache @@ -68,5 +80,13 @@ dev-install: ## Install/update dependencies inside containers @echo "$(GREEN)Installing backend dependencies...$(NC)" docker compose exec backend bundle install +dev-lint: ## Run RuboCop linter inside backend container + @echo "$(GREEN)Running RuboCop inside backend container...$(NC)" + docker compose exec -T backend bundle exec rubocop + +dev-lint-fix: ## Run RuboCop auto-correct inside backend container + @echo "$(GREEN)Running RuboCop auto-correct inside backend container...$(NC)" + docker compose exec -T backend bundle exec rubocop -A + dev-shell-backend: ## Open shell in backend container docker compose exec backend bash diff --git a/app/controllers/sismos_controller.rb b/app/controllers/sismos_controller.rb index 443d44d..7149103 100644 --- a/app/controllers/sismos_controller.rb +++ b/app/controllers/sismos_controller.rb @@ -2,6 +2,7 @@ class SismosController < ApplicationController MAX_PER_PAGE = 1000 def index filtered_sismos = filter_sismos + return if performed? paginated_sismos = filtered_sismos.paginate(page: params[:page], per_page: params[:per_page]) @@ -17,14 +18,127 @@ def index def filter_sismos sismos = Sismo.all - if params[:filters].present? && params[:filters][:mag_type].present? - mag_types = params[:filters][:mag_type].split(',') - sismos = sismos.where(magType: mag_types) + sismos = apply_mag_type_filter(sismos) + sismos = apply_magnitude_range_filter(sismos) + return unless sismos + + sismos = apply_date_range_filter(sismos) + return unless sismos + + apply_tsunami_filter(sismos) + end + + def apply_mag_type_filter(sismos) + return sismos unless filter_param(:mag_type).present? + + mag_types = filter_param(:mag_type).split(',') + sismos.by_mag_type(mag_types) + end + + def apply_magnitude_range_filter(sismos) + if filter_param(:mag_min).present? + mag_min = parse_float_filter(filter_param(:mag_min)) + if mag_min.nil? + render json: { error: 'Invalid value for filter: mag_min' }, status: :bad_request + return nil + end + sismos = sismos.by_mag_min(mag_min) + end + + if filter_param(:mag_max).present? + mag_max = parse_float_filter(filter_param(:mag_max)) + if mag_max.nil? + render json: { error: 'Invalid value for filter: mag_max' }, status: :bad_request + return nil + end + sismos = sismos.by_mag_max(mag_max) + end + + sismos + end + + def parse_float_filter(value) + return nil if value.blank? + + Float(value.to_s.strip) + rescue ArgumentError, TypeError + nil + end + + def apply_date_range_filter(sismos) + if filter_param(:date_from).present? + date_from = parse_date_filter(filter_param(:date_from), is_date_to: false) + unless date_from + render json: { error: 'Invalid date format for filter: date_from' }, status: :bad_request + return nil + end + sismos = sismos.by_date_from(date_from) + end + + if filter_param(:date_to).present? + date_to = parse_date_filter(filter_param(:date_to), is_date_to: true) + unless date_to + render json: { error: 'Invalid date format for filter: date_to' }, status: :bad_request + return nil + end + sismos = sismos.by_date_to(date_to) end sismos end + def parse_date_filter(value, is_date_to: false) + return nil if value.blank? + + str = value.to_s.strip + return parse_iso_date(str, is_date_to: is_date_to) if str.match?(/\A\d{4}-\d{2}-\d{2}\z/) + + parse_datetime_string(str) + end + + def parse_iso_date(str, is_date_to:) + date = Date.iso8601(str) + is_date_to ? date.in_time_zone.end_of_day : date.in_time_zone.beginning_of_day + rescue StandardError + nil + end + + def parse_datetime_string(str) + parsed = Time.zone.parse(str) + return nil if parsed.nil? || parsed.year < 1000 || parsed.year > 9999 + + parsed + rescue StandardError + nil + end + + def apply_tsunami_filter(sismos) + tsunami_param = filter_param(:tsunami) + return sismos if tsunami_param.blank? + + tsunami_val = parse_boolean_filter(tsunami_param) + if tsunami_val.nil? + render json: { error: 'Invalid value for filter: tsunami' }, status: :bad_request + return nil + end + + sismos.by_tsunami(tsunami_val) + end + + def parse_boolean_filter(value) + str = value.to_s.strip.downcase + return nil unless %w[true false 1 0 t f].include?(str) + + ActiveModel::Type::Boolean.new.cast(str) + end + + def filter_param(key) + filters = params[:filters] + return nil unless filters.is_a?(ActionController::Parameters) + + filters[key] + end + def serialize_sismos(sismos) serialized_sismos = sismos.map do |sismo| { diff --git a/app/models/sismo.rb b/app/models/sismo.rb index a61f799..a8e27ef 100644 --- a/app/models/sismo.rb +++ b/app/models/sismo.rb @@ -5,4 +5,15 @@ class Sismo < ApplicationRecord validates :mag, inclusion: { in: -1.0..10.0 } validates :latitude, inclusion: { in: -90.0..90.0 } validates :longitude, inclusion: { in: -180.0..180.0 } + + # ── Filter scopes ────────────────────────────────────────────────── + scope :by_mag_type, ->(types) { where(magType: types) } + scope :by_mag_min, ->(min) { where('mag >= ?', Float(min)) } + scope :by_mag_max, ->(max) { where('mag <= ?', Float(max)) } + scope :by_date_from, ->(date) { where('created_at >= ?', date) } + scope :by_date_to, ->(date) { where('created_at <= ?', date) } + scope :by_tsunami, lambda { |val| + tsunami = ActiveModel::Type::Boolean.new.cast(val) + tsunami ? where(tsunami: true) : where(tsunami: [false, nil]) + } end diff --git a/db/migrate/20260803034001_add_filter_indexes_to_sismos.rb b/db/migrate/20260803034001_add_filter_indexes_to_sismos.rb new file mode 100644 index 0000000..4e97920 --- /dev/null +++ b/db/migrate/20260803034001_add_filter_indexes_to_sismos.rb @@ -0,0 +1,9 @@ +class AddFilterIndexesToSismos < ActiveRecord::Migration[7.2] + disable_ddl_transaction! + + def change + add_index :sismos, :mag, algorithm: :concurrently + add_index :sismos, :magType, algorithm: :concurrently + add_index :sismos, :created_at, algorithm: :concurrently + end +end diff --git a/db/schema.rb b/db/schema.rb index c48fa15..c1e0ac8 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[7.2].define(version: 2026_08_02_195830) do +ActiveRecord::Schema[7.2].define(version: 2026_08_03_034001) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -35,6 +35,9 @@ t.datetime "updated_at", null: false t.boolean "tsunami" t.string "external_id" + t.index ["created_at"], name: "index_sismos_on_created_at" + t.index ["mag"], name: "index_sismos_on_mag" + t.index ["magType"], name: "index_sismos_on_magType" end add_foreign_key "reports", "sismos" diff --git a/test/controllers/sismos_controller_filters_test.rb b/test/controllers/sismos_controller_filters_test.rb new file mode 100644 index 0000000..5f80a8f --- /dev/null +++ b/test/controllers/sismos_controller_filters_test.rb @@ -0,0 +1,147 @@ +require 'test_helper' + +class SismosControllerFiltersTest < ActionDispatch::IntegrationTest + # ── Date range filters ──────────────────────────────────────────── + test 'filters by date_from' do + get sismos_url, params: { filters: { date_from: 2.days.ago.iso8601 } } + json = JSON.parse(response.body) + + assert_response :success + assert json['data'].length < Sismo.count + json['data'].each do |sismo| + assert Time.parse(sismo['attributes']['time']) >= 2.days.ago.beginning_of_day + end + end + + test 'filters by date_to' do + get sismos_url, params: { filters: { date_to: 5.days.ago.iso8601 } } + json = JSON.parse(response.body) + + assert_response :success + assert_equal 2, json['data'].length + json['data'].each do |sismo| + assert Time.parse(sismo['attributes']['time']) <= 5.days.ago.end_of_day + end + end + + test 'filters by date range (date_from and date_to combined)' do + get sismos_url, params: { filters: { date_from: 5.days.ago.iso8601, date_to: Time.current.iso8601 } } + json = JSON.parse(response.body) + + assert_response :success + assert json['pagination']['total'] >= 1 + end + + test 'normalizes date-only date_to filter to end of day' do + target_date = 1.day.ago.to_date.iso8601 + get sismos_url, params: { filters: { date_to: target_date } } + json = JSON.parse(response.body) + + assert_response :success + assert(json['data'].any? { |sismo| sismo['id'] == sismos(:two).id }) + end + + test 'supports date-only date_from filter' do + target_date = 3.days.ago.to_date.iso8601 + get sismos_url, params: { filters: { date_from: target_date } } + json = JSON.parse(response.body) + + assert_response :success + json['data'].each do |sismo| + assert Time.parse(sismo['attributes']['time']) >= 3.days.ago.beginning_of_day + end + end + + test 'returns 400 bad request for malformed date_from' do + get sismos_url, params: { filters: { date_from: 'invalid-date' } } + assert_response :bad_request + + json = JSON.parse(response.body) + assert_equal 'Invalid date format for filter: date_from', json['error'] + end + + test 'returns 400 bad request for malformed date_to' do + get sismos_url, params: { filters: { date_to: '2026-99-99' } } + assert_response :bad_request + + json = JSON.parse(response.body) + assert_equal 'Invalid date format for filter: date_to', json['error'] + end + + # ── Tsunami filter ──────────────────────────────────────────────── + test 'filters by tsunami true' do + get sismos_url, params: { filters: { tsunami: 'true' } } + json = JSON.parse(response.body) + + assert_response :success + assert_equal 1, json['data'].length + json['data'].each do |sismo| + assert_equal true, sismo['attributes']['tsunami'] + end + end + + test 'filters by tsunami false' do + get sismos_url, params: { filters: { tsunami: 'false' } } + json = JSON.parse(response.body) + + assert_response :success + assert_equal 3, json['data'].length + json['data'].each do |sismo| + assert_equal false, sismo['attributes']['tsunami'] + end + end + + test 'treats blank tsunami filter as absent' do + get sismos_url, params: { filters: { tsunami: '' } } + json = JSON.parse(response.body) + + assert_response :success + assert_equal Sismo.count, json['pagination']['total'] + end + + test 'returns 400 bad request for invalid tsunami filter value' do + get sismos_url, params: { filters: { tsunami: 'unsupported' } } + assert_response :bad_request + + json = JSON.parse(response.body) + assert_equal 'Invalid value for filter: tsunami', json['error'] + end + + # ── Combined filters ────────────────────────────────────────────── + test 'applies multiple filters simultaneously' do + get sismos_url, params: { filters: { mag_type: 'ml', mag_min: '1.0', tsunami: 'false' } } + json = JSON.parse(response.body) + + assert_response :success + assert_equal 1, json['data'].length + json['data'].each do |sismo| + assert_equal 'ml', sismo['attributes']['mag_type'] + assert_operator sismo['attributes']['magnitude'], :>=, 1.0 + assert_equal false, sismo['attributes']['tsunami'] + end + end + + # ── Pagination with filters ─────────────────────────────────────── + test 'pagination metadata is correct with filters' do + get sismos_url, params: { filters: { mag_type: 'ml' }, page: 1, per_page: 1 } + json = JSON.parse(response.body) + + assert_response :success + assert_equal 1, json['pagination']['current_page'] + assert_equal 1, json['pagination']['per_page'] + assert_equal 1, json['data'].length + end + + # ── JSON envelope structure ──────────────────────────────────────── + test 'response follows the JSON envelope structure' do + get sismos_url + json = JSON.parse(response.body) + + first = json['data'].first + assert first.key?('id') + assert_equal 'feature', first['type'] + assert first.key?('attributes') + assert first.key?('links') + assert first['attributes'].key?('coordinates') + end +end diff --git a/test/controllers/sismos_controller_test.rb b/test/controllers/sismos_controller_test.rb index e561c95..c95d4a1 100644 --- a/test/controllers/sismos_controller_test.rb +++ b/test/controllers/sismos_controller_test.rb @@ -1,8 +1,111 @@ require 'test_helper' class SismosControllerTest < ActionDispatch::IntegrationTest + # ── Basic endpoint ───────────────────────────────────────────────── test 'should get index' do get sismos_url assert_response :success + + json = JSON.parse(response.body) + assert json.key?('data') + assert json.key?('pagination') + end + + test 'index returns all sismos when no filters applied' do + get sismos_url + json = JSON.parse(response.body) + assert_equal Sismo.count, json['pagination']['total'] + end + + # ── mag_type filter ──────────────────────────────────────────────── + test 'filters by single mag_type' do + get sismos_url, params: { filters: { mag_type: 'ml' } } + json = JSON.parse(response.body) + + assert_response :success + assert_equal 2, json['data'].length + json['data'].each do |sismo| + assert_equal 'ml', sismo['attributes']['mag_type'] + end + end + + test 'filters by multiple mag_types (comma-separated)' do + get sismos_url, params: { filters: { mag_type: 'ml,mww' } } + json = JSON.parse(response.body) + + assert_response :success + assert_equal 3, json['data'].length + json['data'].each do |sismo| + assert_includes %w[ml mww], sismo['attributes']['mag_type'] + end + end + + test 'returns empty data for unknown mag_type' do + get sismos_url, params: { filters: { mag_type: 'nonexistent' } } + json = JSON.parse(response.body) + + assert_response :success + assert_empty json['data'] + assert_equal 0, json['pagination']['total'] + end + + # ── Magnitude range filters ─────────────────────────────────────── + test 'filters by mag_min' do + get sismos_url, params: { filters: { mag_min: '5.0' } } + json = JSON.parse(response.body) + + assert_response :success + assert_equal 2, json['data'].length + json['data'].each do |sismo| + assert_operator sismo['attributes']['magnitude'], :>=, 5.0 + end + end + + test 'filters by mag_max' do + get sismos_url, params: { filters: { mag_max: '3.0' } } + json = JSON.parse(response.body) + + assert_response :success + assert_equal 2, json['data'].length + json['data'].each do |sismo| + assert_operator sismo['attributes']['magnitude'], :<=, 3.0 + end + end + + test 'filters by magnitude range (mag_min and mag_max combined)' do + get sismos_url, params: { filters: { mag_min: '2.0', mag_max: '6.0' } } + json = JSON.parse(response.body) + + assert_response :success + assert_equal 2, json['data'].length + json['data'].each do |sismo| + mag = sismo['attributes']['magnitude'] + assert_operator mag, :>=, 2.0 + assert_operator mag, :<=, 6.0 + end + end + + test 'returns empty data when magnitude range excludes all records' do + get sismos_url, params: { filters: { mag_min: '9.5', mag_max: '10.0' } } + json = JSON.parse(response.body) + + assert_response :success + assert_empty json['data'] + end + + test 'returns 400 bad request for malformed mag_min' do + get sismos_url, params: { filters: { mag_min: 'abc' } } + assert_response :bad_request + + json = JSON.parse(response.body) + assert_equal 'Invalid value for filter: mag_min', json['error'] + end + + test 'returns 400 bad request for malformed mag_max' do + get sismos_url, params: { filters: { mag_max: '1abc' } } + assert_response :bad_request + + json = JSON.parse(response.body) + assert_equal 'Invalid value for filter: mag_max', json['error'] end end diff --git a/test/fixtures/sismos.yml b/test/fixtures/sismos.yml index 2b79c97..94d2f49 100644 --- a/test/fixtures/sismos.yml +++ b/test/fixtures/sismos.yml @@ -1,19 +1,53 @@ -# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html +# Realistic earthquake fixtures for filter testing one: - title: MyString - url: MyString - place: MyString - magType: MyString - mag: 1.5 - latitude: 1.5 - longitude: 1.5 + title: "M 5.2 - 30km S of Atacama, Chile" + url: "https://earthquake.usgs.gov/earthquakes/eventpage/us7000example1" + place: "30km S of Atacama, Chile" + magType: ml + mag: 5.2 + latitude: -27.5 + longitude: -70.3 + tsunami: false + external_id: us7000example1 + created_at: <%= 3.days.ago.iso8601 %> + updated_at: <%= 3.days.ago.iso8601 %> two: - title: MyString - url: MyString - place: MyString - magType: MyString - mag: 1.5 - latitude: 1.5 - longitude: 1.5 + title: "M 7.8 - Near the coast of Hokkaido, Japan" + url: "https://earthquake.usgs.gov/earthquakes/eventpage/us7000example2" + place: "Near the coast of Hokkaido, Japan" + magType: mww + mag: 7.8 + latitude: 42.9 + longitude: 145.1 + tsunami: true + external_id: us7000example2 + created_at: <%= 1.day.ago.iso8601 %> + updated_at: <%= 1.day.ago.iso8601 %> + +three: + title: "M 2.1 - 5km NW of The Geysers, CA" + url: "https://earthquake.usgs.gov/earthquakes/eventpage/nc7000example3" + place: "5km NW of The Geysers, CA" + magType: md + mag: 2.1 + latitude: 38.8 + longitude: -122.8 + tsunami: false + external_id: nc7000example3 + created_at: <%= 10.days.ago.iso8601 %> + updated_at: <%= 10.days.ago.iso8601 %> + +four: + title: "M 0.5 - 10km SE of Ridgecrest, CA" + url: "https://earthquake.usgs.gov/earthquakes/eventpage/ci7000example4" + place: "10km SE of Ridgecrest, CA" + magType: ml + mag: 0.5 + latitude: 35.6 + longitude: -117.6 + tsunami: false + external_id: ci7000example4 + created_at: <%= 30.days.ago.iso8601 %> + updated_at: <%= 30.days.ago.iso8601 %> diff --git a/test/models/sismo_test.rb b/test/models/sismo_test.rb index 925e429..ee27257 100644 --- a/test/models/sismo_test.rb +++ b/test/models/sismo_test.rb @@ -1,7 +1,125 @@ require 'test_helper' class SismoTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + # ── Validations ──────────────────────────────────────────────────── + test 'valid sismo is valid' do + sismo = sismos(:one) + assert sismo.valid? + end + + test 'requires title' do + sismo = Sismo.new(url: 'http://example.com', place: 'Test', magType: 'ml', mag: 1.0, latitude: 0, longitude: 0) + assert_not sismo.valid? + assert_includes sismo.errors[:title], "can't be blank" + end + + test 'rejects magnitude out of range' do + sismo = sismos(:one) + sismo.mag = 11.0 + assert_not sismo.valid? + end + + test 'rejects latitude out of range' do + sismo = sismos(:one) + sismo.latitude = 95.0 + assert_not sismo.valid? + end + + test 'rejects longitude out of range' do + sismo = sismos(:one) + sismo.longitude = 200.0 + assert_not sismo.valid? + end + + # ── Scopes ───────────────────────────────────────────────────────── + test 'by_mag_type returns matching records' do + results = Sismo.by_mag_type(['ml']) + assert(results.all? { |s| s.magType == 'ml' }) + assert_equal 2, results.count + end + + test 'by_mag_type with multiple types' do + results = Sismo.by_mag_type(%w[ml mww]) + assert(results.all? { |s| %w[ml mww].include?(s.magType) }) + assert_equal 3, results.count + end + + test 'by_mag_min filters correctly' do + results = Sismo.by_mag_min(5.0) + assert(results.all? { |s| s.mag >= 5.0 }) + assert_equal 2, results.count + end + + test 'by_mag_max filters correctly' do + results = Sismo.by_mag_max(3.0) + assert(results.all? { |s| s.mag <= 3.0 }) + assert_equal 2, results.count + end + + test 'by_mag_min raises ArgumentError for malformed string' do + assert_raises(ArgumentError) do + Sismo.by_mag_min('abc').to_a + end + end + + test 'by_mag_max raises ArgumentError for malformed string' do + assert_raises(ArgumentError) do + Sismo.by_mag_max('1abc').to_a + end + end + + test 'by_mag_min and by_mag_max compose correctly' do + results = Sismo.by_mag_min(2.0).by_mag_max(6.0) + assert(results.all? { |s| s.mag.between?(2.0, 6.0) }) + assert_equal 2, results.count + end + + test 'by_date_from filters correctly' do + results = Sismo.by_date_from(2.days.ago) + assert(results.all? { |s| s.created_at >= 2.days.ago }) + assert_equal 1, results.count + end + + test 'by_date_to filters correctly' do + results = Sismo.by_date_to(5.days.ago) + assert(results.all? { |s| s.created_at <= 5.days.ago }) + assert_equal 2, results.count + end + + test 'by_tsunami true returns only tsunami events' do + results = Sismo.by_tsunami('true') + assert(results.all?(&:tsunami?)) + assert_equal 1, results.count + end + + test 'by_tsunami false returns non-tsunami events including nil tsunami' do + sismo_nil = Sismo.create!( + title: 'M 3.0 - Test NULL tsunami', + url: 'https://example.com/test_null', + place: 'Test Place', + magType: 'ml', + mag: 3.0, + latitude: 0, + longitude: 0, + tsunami: nil + ) + + results = Sismo.by_tsunami('false') + assert(results.none?(&:tsunami?)) + assert_includes results, sismo_nil + assert_equal 4, results.count + end + + # ── Association ──────────────────────────────────────────────────── + test 'has many reports' do + sismo = sismos(:one) + assert_respond_to sismo, :reports + end + + test 'destroying sismo destroys associated reports' do + sismo = sismos(:one) + assert_difference('Report.count', -sismo.reports.count) do + sismo.destroy + end + end end