From c615ef2a111c29da1fc0bfe6ca4f1954b60e712d Mon Sep 17 00:00:00 2001 From: Goulven Champenois Date: Thu, 13 Feb 2025 09:27:59 +0100 Subject: [PATCH 1/5] Add friendly_id gem to generate slugs --- Gemfile | 2 + Gemfile.lock | 3 + config/initializers/friendly_id.rb | 106 ++++++++++++++++++ ...20250213082348_create_friendly_id_slugs.rb | 21 ++++ db/schema.rb | 12 +- 5 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 config/initializers/friendly_id.rb create mode 100644 db/migrate/20250213082348_create_friendly_id_slugs.rb diff --git a/Gemfile b/Gemfile index 044627f..856ee91 100644 --- a/Gemfile +++ b/Gemfile @@ -49,6 +49,8 @@ gem "dsfr-assets" gem "dsfr-form_builder" gem "dsfr-view-components" +gem "friendly_id" + group :development, :test do gem "pry-rails" diff --git a/Gemfile.lock b/Gemfile.lock index 2580a93..8d48dc3 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -194,6 +194,8 @@ GEM ffi (1.17.1-x86_64-linux-gnu) ffi (1.17.1-x86_64-linux-musl) formatador (1.1.0) + friendly_id (5.5.1) + activerecord (>= 4.0.0) fugit (1.11.1) et-orbi (~> 1, >= 1.2.11) raabro (~> 1.4) @@ -557,6 +559,7 @@ DEPENDENCIES dsfr-view-components factory_bot_rails faker + friendly_id guard guard-cucumber guard-rspec diff --git a/config/initializers/friendly_id.rb b/config/initializers/friendly_id.rb new file mode 100644 index 0000000..ee0a08f --- /dev/null +++ b/config/initializers/friendly_id.rb @@ -0,0 +1,106 @@ +# FriendlyId Global Configuration +# +# Use this to set up shared configuration options for your entire application. +# Any of the configuration options shown here can also be applied to single +# models by passing arguments to the `friendly_id` class method or defining +# methods in your model. +# +# To learn more, check out the guide: +# +# http://norman.github.io/friendly_id/file.Guide.html + +FriendlyId.defaults do |config| + # ## Reserved Words + # + # Some words could conflict with Rails's routes when used as slugs, or are + # undesirable to allow as slugs. Edit this list as needed for your app. + config.use :reserved + + config.reserved_words = ["new", "edit", "index", "session", "login", "logout", "users", "admin", "stylesheets", "assets", "javascripts", "images"] + + # This adds an option to treat reserved words as conflicts rather than exceptions. + # When there is no good candidate, a UUID will be appended, matching the existing + # conflict behavior. + + # config.treat_reserved_as_conflict = true + + # ## Friendly Finders + # + # Uncomment this to use friendly finders in all models. By default, if + # you wish to find a record by its friendly id, you must do: + # + # MyModel.friendly.find('foo') + # + # If you uncomment this, you can do: + # + # MyModel.find('foo') + # + # This is significantly more convenient but may not be appropriate for + # all applications, so you must explicitly opt-in to this behavior. You can + # always also configure it on a per-model basis if you prefer. + # + # Something else to consider is that using the :finders addon boosts + # performance because it will avoid Rails-internal code that makes runtime + # calls to `Module.extend`. + # + # config.use :finders + # + # ## Slugs + # + # Most applications will use the :slugged module everywhere. If you wish + # to do so, uncomment the following line. + + config.use :slugged + + # By default, FriendlyId's :slugged addon expects the slug column to be named + # 'slug', but you can change it if you wish. + # + # config.slug_column = 'slug' + # + # By default, slug has no size limit, but you can change it if you wish. + # + # config.slug_limit = 255 + # + # When FriendlyId can not generate a unique ID from your base method, it appends + # a UUID, separated by a single dash. You can configure the character used as the + # separator. If you're upgrading from FriendlyId 4, you may wish to replace this + # with two dashes. + # + # config.sequence_separator = '-' + # + # Note that you must use the :slugged addon **prior** to the line which + # configures the sequence separator, or else FriendlyId will raise an undefined + # method error. + # + # ## Tips and Tricks + # + # ### Controlling when slugs are generated + # + # As of FriendlyId 5.0, new slugs are generated only when the slug field is + # nil, but if you're using a column as your base method can change this + # behavior by overriding the `should_generate_new_friendly_id?` method that + # FriendlyId adds to your model. The change below makes FriendlyId 5.0 behave + # more like 4.0. + # Note: Use(include) Slugged module in the config if using the anonymous module. + # If you have `friendly_id :name, use: slugged` in the model, Slugged module + # is included after the anonymous module defined in the initializer, so it + # overrides the `should_generate_new_friendly_id?` method from the anonymous module. + # + # config.use :slugged + # config.use Module.new { + # def should_generate_new_friendly_id? + # slug.blank? || _changed? + # end + # } + # + # FriendlyId uses Rails's `parameterize` method to generate slugs, but for + # languages that don't use the Roman alphabet, that's not usually sufficient. + # Here we use the Babosa library to transliterate Russian Cyrillic slugs to + # ASCII. If you use this, don't forget to add "babosa" to your Gemfile. + # + # config.use Module.new { + # def normalize_friendly_id(text) + # text.to_slug.normalize! :transliterations => [:russian, :latin] + # end + # } +end diff --git a/db/migrate/20250213082348_create_friendly_id_slugs.rb b/db/migrate/20250213082348_create_friendly_id_slugs.rb new file mode 100644 index 0000000..a09491d --- /dev/null +++ b/db/migrate/20250213082348_create_friendly_id_slugs.rb @@ -0,0 +1,21 @@ +MIGRATION_CLASS = + if ActiveRecord::VERSION::MAJOR >= 5 + ActiveRecord::Migration["#{ActiveRecord::VERSION::MAJOR}.#{ActiveRecord::VERSION::MINOR}"] + else + ActiveRecord::Migration + end + +class CreateFriendlyIdSlugs < MIGRATION_CLASS + def change + create_table :friendly_id_slugs do |t| + t.string :slug, null: false + t.integer :sluggable_id, null: false + t.string :sluggable_type, limit: 50 + t.string :scope + t.datetime :created_at + end + add_index :friendly_id_slugs, [:sluggable_type, :sluggable_id] + add_index :friendly_id_slugs, [:slug, :sluggable_type], length: {slug: 140, sluggable_type: 50} + add_index :friendly_id_slugs, [:slug, :sluggable_type, :scope], length: {slug: 70, sluggable_type: 50, scope: 70}, unique: true + end +end diff --git a/db/schema.rb b/db/schema.rb index 4d004c3..49a8938 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,8 +10,18 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 0) do +ActiveRecord::Schema[8.0].define(version: 2025_02_13_082348) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + create_table "friendly_id_slugs", force: :cascade do |t| + t.string "slug", null: false + t.integer "sluggable_id", null: false + t.string "sluggable_type", limit: 50 + t.string "scope" + t.datetime "created_at" + t.index ["slug", "sluggable_type", "scope"], name: "index_friendly_id_slugs_on_slug_and_sluggable_type_and_scope", unique: true + t.index ["slug", "sluggable_type"], name: "index_friendly_id_slugs_on_slug_and_sluggable_type" + t.index ["sluggable_type", "sluggable_id"], name: "index_friendly_id_slugs_on_sluggable_type_and_sluggable_id" + end end From 880e0a9225279288c81d529a0ccac235e59241c3 Mon Sep 17 00:00:00 2001 From: Goulven Champenois Date: Thu, 13 Feb 2025 17:40:00 +0100 Subject: [PATCH 2/5] Add URL validator --- app/validators/url_validator.rb | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 app/validators/url_validator.rb diff --git a/app/validators/url_validator.rb b/app/validators/url_validator.rb new file mode 100644 index 0000000..f385384 --- /dev/null +++ b/app/validators/url_validator.rb @@ -0,0 +1,12 @@ +class UrlValidator < ActiveModel::EachValidator + def validate_each(record, attribute, value) + return if value.blank? + + begin + uri = URI.parse(value) + return if uri.host.present? && uri.scheme.match?(/^https?$/) + rescue URI::InvalidURIError + end + record.errors.add(attribute, :invalid) + end +end From 2f6d9a0dd8ac4ba0ee38f44e837bf0d4f3799778 Mon Sep 17 00:00:00 2001 From: Goulven Champenois Date: Thu, 13 Feb 2025 17:36:18 +0100 Subject: [PATCH 3/5] Add has_some_of_many gem --- Gemfile | 3 +++ Gemfile.lock | 4 ++++ 2 files changed, 7 insertions(+) diff --git a/Gemfile b/Gemfile index 856ee91..24ba71f 100644 --- a/Gemfile +++ b/Gemfile @@ -38,6 +38,9 @@ gem "bootsnap", require: false # Use Active Storage variants [https://guides.rubyonrails.org/active_storage_overview.html#transforming-images] # gem "image_processing", "~> 1.2" +# Add optimized Active Record association methods +gem "activerecord-has_some_of_many" + # Pagination gem "pagy" diff --git a/Gemfile.lock b/Gemfile.lock index 8d48dc3..75600d9 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -53,6 +53,9 @@ GEM activemodel (= 8.0.1) activesupport (= 8.0.1) timeout (>= 0.4.0) + activerecord-has_some_of_many (1.2.0) + activerecord (>= 7.0.0.alpha) + railties (>= 7.0.0.alpha) activestorage (8.0.1) actionpack (= 8.0.1) activejob (= 8.0.1) @@ -544,6 +547,7 @@ PLATFORMS x86_64-linux-musl DEPENDENCIES + activerecord-has_some_of_many axe-core-capybara (~> 4.9) axe-core-rspec (~> 4.8) better_errors From 6705c4a7ec4ddded36bbfe67e2aae6380f7c8dad Mon Sep 17 00:00:00 2001 From: Goulven Champenois Date: Thu, 13 Feb 2025 16:11:41 +0100 Subject: [PATCH 4/5] Add basic Site model --- app/controllers/sites_controller.rb | 57 +++++++++++++++++++++++ app/models/site.rb | 7 +++ app/views/sites/_form.html.erb | 7 +++ app/views/sites/edit.html.erb | 1 + app/views/sites/index.html.erb | 26 +++++++++++ app/views/sites/new.html.erb | 1 + app/views/sites/show.html.erb | 16 +++++++ config/locales/fr.yml | 33 +++++++++++++ config/routes.rb | 2 + db/migrate/20250213082907_create_sites.rb | 12 +++++ db/schema.rb | 10 +++- spec/factories/sites.rb | 5 ++ 12 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 app/controllers/sites_controller.rb create mode 100644 app/models/site.rb create mode 100644 app/views/sites/_form.html.erb create mode 100644 app/views/sites/edit.html.erb create mode 100644 app/views/sites/index.html.erb create mode 100644 app/views/sites/new.html.erb create mode 100644 app/views/sites/show.html.erb create mode 100644 db/migrate/20250213082907_create_sites.rb create mode 100644 spec/factories/sites.rb diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb new file mode 100644 index 0000000..a49fe3b --- /dev/null +++ b/app/controllers/sites_controller.rb @@ -0,0 +1,57 @@ +class SitesController < ApplicationController + before_action :set_site, except: :index + before_action :redirect_old_slugs, except: [:index, :new, :create], if: :get_request? + + # GET /sites + def index + @sites = Site.all + end + + # GET /sites/1 + def show; end + + # GET /sites/new + def new; end + + # GET /sites/1/edit + def edit; end + + # POST /sites + def create + @site = Site.new(site_params) + if @site.save + redirect_to @site, notice: t(".notice") + else + render :new, status: :unprocessable_entity + end + end + + # PATCH/PUT /sites/1 + def update + if @site.update(site_params) + redirect_to @site, notice: t(".notice"), status: :see_other + else + render :edit, status: :unprocessable_entity + end + end + + # DELETE /sites/1 + def destroy + @site.destroy! + redirect_to sites_path, notice: t(".notice"), status: :see_other + end + + private + + def set_site + @site = params[:id].present? ? Site.friendly.find(params.expect(:id)) : Site.new + end + + def redirect_old_slugs + redirect_to(@site, status: :moved_permanently) unless @site.slug == params[:id] + end + + def site_params + params.expect(site: [:url]) + end +end diff --git a/app/models/site.rb b/app/models/site.rb new file mode 100644 index 0000000..943d340 --- /dev/null +++ b/app/models/site.rb @@ -0,0 +1,7 @@ +class Site < ApplicationRecord + extend FriendlyId + + friendly_id :url, use: [:slugged, :history] + + attribute :url, :string +end diff --git a/app/views/sites/_form.html.erb b/app/views/sites/_form.html.erb new file mode 100644 index 0000000..e53ddb3 --- /dev/null +++ b/app/views/sites/_form.html.erb @@ -0,0 +1,7 @@ +<%= form_with(model: site) do |f| %> + <%= f.dsfr_input_field :url, :text_field, type: :url, required: true, autofocus: true, hint: "Saisissez une url valide, commençant par https:// ou http://" %> + + <%= f.submit "Enregistrer", name: nil, class: "fr-btn", data: { disable_with: "Enregistrement…" } %> + + <%= link_to "Annuler", { action: site.persisted? ? :show : :index }, class: "fr-btn fr-btn--secondary" %> +<% end %> diff --git a/app/views/sites/edit.html.erb b/app/views/sites/edit.html.erb new file mode 100644 index 0000000..7d32352 --- /dev/null +++ b/app/views/sites/edit.html.erb @@ -0,0 +1 @@ +<%= render "form", site: @site %> diff --git a/app/views/sites/index.html.erb b/app/views/sites/index.html.erb new file mode 100644 index 0000000..653546f --- /dev/null +++ b/app/views/sites/index.html.erb @@ -0,0 +1,26 @@ +<%= dsfr_table(caption: "Liste des sites") do |t| %> + <% t.with_head do %> + + <%= Site.human(:url) %> + <%= Site.human(:updated_at) %> + <%= t("shared.actions") %> + + <% end %> + <% t.with_body do %> + <% @sites.each do |site| %> + + <%= link_to site.name, site %> + + <%= l site.updated_at, format: :compact %> +
+ (<%= time_ago site.updated_at %>) + + <%= link_to "Voir le site", site.url, target: :_blank %> + + <% end %> + <% end %> +<% end %> + +<%= paginate %> + +<%= link_to "Ajouter un site", { action: :new }, class: "fr-btn" %> diff --git a/app/views/sites/new.html.erb b/app/views/sites/new.html.erb new file mode 100644 index 0000000..7d32352 --- /dev/null +++ b/app/views/sites/new.html.erb @@ -0,0 +1 @@ +<%= render "form", site: @site %> diff --git a/app/views/sites/show.html.erb b/app/views/sites/show.html.erb new file mode 100644 index 0000000..47e88e9 --- /dev/null +++ b/app/views/sites/show.html.erb @@ -0,0 +1,16 @@ +

+ <%= Site.human :url %> : + <%= link_to @site.url, @site.url, target: :_blank %> +

+

+ <%= Site.human :updated_at %> : + <%= l @site.updated_at, format: :long %> + (<%= time_ago @site.updated_at %>). +

+ +
+ +
+ <%= button_to "Supprimer ce site", @site, method: :delete, class: "fr-btn fr-btn--tertiary", form: { data: { turbo_confirm: t("shared.confirm") } } %> + <%= link_to "Revenir à la liste", { action: :index }, class: "fr-btn fr-btn--tertiary-no-outline" %> +
diff --git a/config/locales/fr.yml b/config/locales/fr.yml index 8f5e98d..4618518 100644 --- a/config/locales/fr.yml +++ b/config/locales/fr.yml @@ -10,6 +10,26 @@ fr: confirm: "Êtes-vous sûr ? Cette action ne peut pas être annulée." time_ago: "il y a %{time}" + time: + formats: + compact: "%Y/%m/%d" + + errors: + attributes: + url: + invalid: invalide + + activerecord: + attributes: + site: + created_at: Date de création + updated_at: Date de mise à jour + url: Adresse du site + count: + zero: Aucun site + one: Un site + other: "%{count} sites" + errors: not_found: title: "Page non trouvée (erreur 404)" @@ -30,3 +50,16 @@ fr: title: Mentions légales plan: title: Plan du site + sites: + index: + title: Tous les sites + new: + title: Ajouter un site + create: + notice: Site ajouté + edit: + title: Modifier le site + update: + notice: Site modifié + destroy: + notice: Site supprimé diff --git a/config/routes.rb b/config/routes.rb index 4e50ee8..f679931 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1,5 +1,7 @@ # Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html Rails.application.routes.draw do + resources :sites + # Static pages scope controller: :pages do root action: :accueil diff --git a/db/migrate/20250213082907_create_sites.rb b/db/migrate/20250213082907_create_sites.rb new file mode 100644 index 0000000..a632af7 --- /dev/null +++ b/db/migrate/20250213082907_create_sites.rb @@ -0,0 +1,12 @@ +class CreateSites < ActiveRecord::Migration[8.0] + def change + create_table :sites do |t| + t.string :name + t.string :slug, null: false + + t.timestamps + + t.index :slug, unique: true + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 49a8938..91194fe 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[8.0].define(version: 2025_02_13_082348) do +ActiveRecord::Schema[8.0].define(version: 2025_02_13_082907) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" @@ -24,4 +24,12 @@ t.index ["slug", "sluggable_type"], name: "index_friendly_id_slugs_on_slug_and_sluggable_type" t.index ["sluggable_type", "sluggable_id"], name: "index_friendly_id_slugs_on_sluggable_type_and_sluggable_id" end + + create_table "sites", force: :cascade do |t| + t.string "name" + t.string "slug", null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["slug"], name: "index_sites_on_slug", unique: true + end end diff --git a/spec/factories/sites.rb b/spec/factories/sites.rb new file mode 100644 index 0000000..a45a80a --- /dev/null +++ b/spec/factories/sites.rb @@ -0,0 +1,5 @@ +FactoryBot.define do + factory :site do + sequence(:slug) { |n| "www.example-#{n}.com" } + end +end From 684c3d232afec870ef6e5adda6c5fb24cc755d5e Mon Sep 17 00:00:00 2001 From: Goulven Champenois Date: Thu, 13 Feb 2025 23:49:07 +0100 Subject: [PATCH 5/5] Add audit model --- app/controllers/sites_controller.rb | 6 +- app/models/audit.rb | 32 ++++ app/models/site.rb | 46 ++++- app/views/sites/index.html.erb | 2 +- db/migrate/20250213152147_create_audits.rb | 18 ++ db/schema.rb | 19 +- spec/factories/audits.rb | 22 +++ spec/factories/sites.rb | 2 +- spec/models/audit_spec.rb | 196 +++++++++++++++++++++ spec/models/site_spec.rb | 116 ++++++++++++ 10 files changed, 451 insertions(+), 8 deletions(-) create mode 100644 app/models/audit.rb create mode 100644 db/migrate/20250213152147_create_audits.rb create mode 100644 spec/factories/audits.rb create mode 100644 spec/models/audit_spec.rb create mode 100644 spec/models/site_spec.rb diff --git a/app/controllers/sites_controller.rb b/app/controllers/sites_controller.rb index a49fe3b..7c39b74 100644 --- a/app/controllers/sites_controller.rb +++ b/app/controllers/sites_controller.rb @@ -4,7 +4,7 @@ class SitesController < ApplicationController # GET /sites def index - @sites = Site.all + @pagy, @sites = pagy Site.includes(:audit) end # GET /sites/1 @@ -18,7 +18,7 @@ def edit; end # POST /sites def create - @site = Site.new(site_params) + @site = Site.find_or_create_by_url(site_params) if @site.save redirect_to @site, notice: t(".notice") else @@ -44,7 +44,7 @@ def destroy private def set_site - @site = params[:id].present? ? Site.friendly.find(params.expect(:id)) : Site.new + @site = params[:id].present? ? Site.friendly.find(params.expect(:id)) : Site.new_with_audit end def redirect_old_slugs diff --git a/app/models/audit.rb b/app/models/audit.rb new file mode 100644 index 0000000..4ce2a28 --- /dev/null +++ b/app/models/audit.rb @@ -0,0 +1,32 @@ +class Audit < ApplicationRecord + MAX_ATTEMPTS = 3 + MAX_RUNTIME = 1.hour.freeze + + belongs_to :site, touch: true + + validates :url, presence: true, url: true + normalizes :url, with: ->(url) { URI.parse(url).normalize.to_s } + + enum :status, ["pending", "running", "passed", "retryable", "failed"].index_by(&:itself), validate: true, default: :pending + + scope :sort_by_newest, -> { order(created_at: :desc) } + scope :sort_by_url, -> { order(Arel.sql("REGEXP_REPLACE(url, '^https?://(www\.)?', '') ASC")) } + scope :due, -> { pending.where("run_at <= now()") } + scope :past, -> { where(status: [:passed, :failed]) } + scope :scheduled, -> { where("run_at > now()") } + scope :retryable, -> { failed.where(attempts: ...MAX_ATTEMPTS) } + scope :to_run, -> { due.or(retryable) } + scope :clean, -> { passed.where(attempts: 0) } + scope :late, -> { pending.where("run_at <= ?", 1.hour.ago) } + scope :retried, -> { passed.where(attempts: 1..) } + scope :stalled, -> { running.where("run_at < ?", MAX_RUNTIME.ago) } + scope :crashed, -> { failed.where(attempts: MAX_ATTEMPTS) } + + delegate :hostname, :path, to: :parsed_url + + def run_at = super || Time.zone.now + def due? = pending? && run_at <= Time.zone.now + def runnable? = due? || retryable? + def parsed_url = @parsed_url ||= URI.parse(url).normalize + def url_without_scheme = @url_without_scheme ||= [hostname, path == "/" ? nil : path].compact.join(nil) +end diff --git a/app/models/site.rb b/app/models/site.rb index 943d340..cefbf84 100644 --- a/app/models/site.rb +++ b/app/models/site.rb @@ -1,7 +1,49 @@ class Site < ApplicationRecord extend FriendlyId - friendly_id :url, use: [:slugged, :history] + has_many :audits, dependent: :destroy + has_one_of_many :audit, -> { order("audits.created_at DESC") }, class_name: "Audit" - attribute :url, :string + friendly_id :url_without_scheme, use: [:slugged, :history] + + delegate :url, :url_without_scheme, to: :audit + + class << self + def find_or_create_by_url(attributes) + url = attributes.to_h.delete(:url) + attributes.to_h.delete(:name) if attributes[:name].blank? + # Ignore http/https duplicates when searching + normalized_url = [url, url.sub(/^https?/, url.start_with?("https") ? "http" : "https")] + joins(:audits).find_by(audits: { url: normalized_url })&.tap { it.update(attributes) } \ + || create_with_audit(url:, **attributes) + end + + def create_with_audit(url: nil, **attributes) + new_with_audit(url:, **attributes).tap(&:save) + end + + def new_with_audit(url: nil, **attributes) + new(attributes).tap { |site| site.audits.build(url:) } + end + end + + def new(attributes = nil) + return super if attributes.nil? + + attributes = attributes.to_h.symbolize_keys + + if url = attributes.delete(:url) + self.class.find_or_create_by(url:, **attributes) + else + super + end + end + + def url=(new_url) + audit = audits.build(url: new_url) + end + + def to_title = url_without_scheme + def audit = super || audits.last || audits.build + def should_generate_new_friendly_id? = new_record? || (audit && slug != url_without_scheme.parameterize) end diff --git a/app/views/sites/index.html.erb b/app/views/sites/index.html.erb index 653546f..25feee6 100644 --- a/app/views/sites/index.html.erb +++ b/app/views/sites/index.html.erb @@ -9,7 +9,7 @@ <% t.with_body do %> <% @sites.each do |site| %> - <%= link_to site.name, site %> + <%= link_to site.url_without_scheme, site %> <%= l site.updated_at, format: :compact %>
diff --git a/db/migrate/20250213152147_create_audits.rb b/db/migrate/20250213152147_create_audits.rb new file mode 100644 index 0000000..44226b0 --- /dev/null +++ b/db/migrate/20250213152147_create_audits.rb @@ -0,0 +1,18 @@ +class CreateAudits < ActiveRecord::Migration[8.0] + def change + create_table :audits do |t| + t.belongs_to :site, null: false, foreign_key: true + t.string :url, null: false + t.string :status, null: false + t.integer :attempts, null: false, default: 0 + t.datetime :run_at, null: false, default: -> { "CURRENT_TIMESTAMP" } + + t.timestamps + + t.index :url + t.index [:status, :run_at], name: "index_audits_on_status_and_run_at" + t.index "REGEXP_REPLACE(url, '^https?://(www\.)?', '')", name: "index_audits_on_normalized_url" + t.index :attempts, where: "status = 'failed' AND attempts > 0", name: "index_audits_on_retryable" + end + end +end diff --git a/db/schema.rb b/db/schema.rb index 91194fe..ebd9aa5 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,10 +10,25 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema[8.0].define(version: 2025_02_13_082907) do +ActiveRecord::Schema[8.0].define(version: 2025_02_13_152147) do # These are extensions that must be enabled in order to support this database enable_extension "pg_catalog.plpgsql" + create_table "audits", force: :cascade do |t| + t.bigint "site_id", null: false + t.string "url", null: false + t.string "status", null: false + t.integer "attempts", default: 0, null: false + t.datetime "run_at", default: -> { "CURRENT_TIMESTAMP" }, null: false + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index "regexp_replace((url)::text, '^https?://(www.)?'::text, ''::text)", name: "index_audits_on_normalized_url" + t.index ["attempts"], name: "index_audits_on_retryable", where: "(((status)::text = 'failed'::text) AND (attempts > 0))" + t.index ["site_id"], name: "index_audits_on_site_id" + t.index ["status", "run_at"], name: "index_audits_on_status_and_run_at" + t.index ["url"], name: "index_audits_on_url" + end + create_table "friendly_id_slugs", force: :cascade do |t| t.string "slug", null: false t.integer "sluggable_id", null: false @@ -32,4 +47,6 @@ t.datetime "updated_at", null: false t.index ["slug"], name: "index_sites_on_slug", unique: true end + + add_foreign_key "audits", "sites" end diff --git a/spec/factories/audits.rb b/spec/factories/audits.rb new file mode 100644 index 0000000..b27baff --- /dev/null +++ b/spec/factories/audits.rb @@ -0,0 +1,22 @@ +FactoryBot.define do + factory :audit do + site { association :site, url:, strategy: :build } + url { "https://example.com" } + + trait :passed do + status { "passed" } + end + + trait :failed do + status { "failed" } + end + + trait :pending do + status { "pending" } + end + + trait :running do + status { "running" } + end + end +end diff --git a/spec/factories/sites.rb b/spec/factories/sites.rb index a45a80a..f3cdd5a 100644 --- a/spec/factories/sites.rb +++ b/spec/factories/sites.rb @@ -1,5 +1,5 @@ FactoryBot.define do factory :site do - sequence(:slug) { |n| "www.example-#{n}.com" } + sequence(:url) { |n| "https://www.example-#{n}.com/" } end end diff --git a/spec/models/audit_spec.rb b/spec/models/audit_spec.rb new file mode 100644 index 0000000..162e3d5 --- /dev/null +++ b/spec/models/audit_spec.rb @@ -0,0 +1,196 @@ +require "rails_helper" + +RSpec.describe Audit do + let(:site) { create(:site) } + subject(:audit) { build(:audit, site: nil) } + + it "has a valid factory" do + audit = build(:audit) + expect(audit).to be_valid + end + + describe "associations" do + it { is_expected.to belong_to(:site).touch(true) } + end + + describe "validations" do + it { is_expected.to allow_value("https://example.com").for(:url) } + it { is_expected.not_to allow_value("not-a-url").for(:url) } + end + + describe "normalization" do + it "normalizes URLs" do + audit.url = "HTTPS://EXAMPLE.COM/path/" + expect(audit.url).to eq("https://example.com/path/") + end + end + + describe "enums" do + it do + should define_enum_for(:status) + .validating + .with_values(["pending", "running", "passed", "retryable", "failed"].index_by(&:itself)) + .backed_by_column_of_type(:string) + .with_default(:pending) + end + end + + describe "scopes" do + before { site.audit.destroy } + it ".sort_by_newest returns audits in descending order by creation date" do + oldest = create(:audit, site:, created_at: 3.days.ago) + older = create(:audit, site:, created_at: 2.days.ago) + newer = create(:audit, site:, created_at: 1.day.ago) + + expect(described_class.sort_by_newest).to eq([newer, older, oldest]) + end + + it ".sort_by_url orders by URL ignoring protocol and www" do + alpha = create(:audit, site:, url: "https://alpha.com/path") + beta = create(:audit, site:, url: "http://www.beta.com") + gamma = create(:audit, site:, url: "https://www.gamma.com") + + expect(described_class.sort_by_url).to eq([alpha, beta, gamma]) + end + + it ".due returns pending audits with run_at in the past" do + past_pending = create(:audit, :pending, site:, run_at: 1.hour.ago) + create(:audit, :pending, site:, run_at: 1.hour.from_now) # future + create(:audit, :passed, site:, run_at: 2.hours.ago) # not pending + + expect(described_class.due).to eq([past_pending]) + end + + it ".to_run returns due and retryable audits" do + past_pending = create(:audit, :pending, site:, run_at: 1.hour.ago) + retryable = create(:audit, :failed, site:, attempts: Audit::MAX_ATTEMPTS - 1) + + # Create records that shouldn't be included + create(:audit, :pending, site:, run_at: 1.hour.from_now) # not due + create(:audit, :failed, site:, attempts: Audit::MAX_ATTEMPTS) # not retryable + create(:audit, :passed, site:) # wrong status + + expect(described_class.to_run).to match_array([past_pending, retryable]) + end + + context "with audits" do + let!(:passed_audit) { create(:audit, :passed, site:, created_at: 7.days.ago, url: "https://beta.com") } + let!(:failed_audit) { create(:audit, :failed, site:, created_at: 6.days.ago, url: "https://alpha.com") } + let!(:pending_audit) { create(:audit, :pending, site:, run_at: 1.hour.ago, created_at: 5.days.ago) } + let!(:future_audit) { create(:audit, :pending, site:, run_at: 1.hour.from_now, created_at: 4.days.ago) } + let!(:retried_audit) { create(:audit, :passed, site:, attempts: 2, created_at: 3.days.ago) } + let!(:running_audit) { create(:audit, :running, site:, run_at: 2.hours.ago, created_at: 2.days.ago) } + let!(:crashed_audit) { create(:audit, :failed, site:, attempts: Audit::MAX_ATTEMPTS, created_at: 1.day.ago) } + + it ".past returns passed and failed audits" do + expect(described_class.past).to contain_exactly(passed_audit, failed_audit, retried_audit, crashed_audit) + end + + it ".scheduled returns audits with future run_at" do + expect(described_class.scheduled).to eq([future_audit]) + end + + it ".retryable returns failed audits with attempts less than MAX_ATTEMPTS" do + expect(described_class.retryable).to eq([failed_audit]) + end + + it ".clean returns passed audits with no attempts" do + expect(described_class.clean).to eq([passed_audit]) + end + + it ".late returns pending audits overdue by an hour" do + expect(described_class.late).to eq([pending_audit]) + end + + it ".retried returns passed audits with attempts" do + expect(described_class.retried).to eq([retried_audit]) + end + + it ".stalled returns running audits older than MAX_RUNTIME" do + expect(described_class.stalled).to eq([running_audit]) + end + + it ".crashed returns failed audits with MAX_ATTEMPTS" do + expect(described_class.crashed).to eq([crashed_audit]) + end + end + end + + describe "#run_at" do + it "returns the set time when present" do + time = 1.hour.from_now + audit.run_at = time + expect(audit.run_at).to be_within(1.second).of(time) + end + + it "returns current time when not set" do + expect(audit.run_at).to be_within(1.second).of(Time.zone.now) + end + end + + describe "#due?" do + it "returns true for pending audits with past run_at" do + audit.status = "pending" + audit.run_at = 1.minute.ago + expect(audit).to be_due + end + + it "returns false for pending audits with future run_at" do + audit.status = "pending" + audit.run_at = 1.minute.from_now + expect(audit).not_to be_due + end + + it "returns false for non-pending audits" do + audit.status = "running" + audit.run_at = 1.minute.ago + expect(audit).not_to be_due + end + end + + describe "#runnable?" do + it "returns true when due" do + allow(audit).to receive(:due?).and_return(true) + expect(audit).to be_runnable + end + + it "returns true when retryable" do + allow(audit).to receive(:retryable?).and_return(true) + expect(audit).to be_runnable + end + + it "returns false when neither due nor retryable" do + allow(audit).to receive(:due?).and_return(false) + allow(audit).to receive(:retryable?).and_return(false) + expect(audit).not_to be_runnable + end + end + + describe "#parsed_url" do + it "returns a parsed and normalized URI" do + audit.url = "https://example.com/path/" + expect(audit.parsed_url).to be_a(URI::HTTPS) + expect(audit.parsed_url.to_s).to eq(audit.url) + end + # Skip memoization test because framework operation use URI.parse too + end + + describe "#url_without_scheme" do + it "returns hostname for root path" do + audit.url = "https://example.com" + expect(audit.url_without_scheme).to eq("example.com") + end + + it "returns hostname and path for non-root path" do + audit.url = "https://example.com/path" + expect(audit.url_without_scheme).to eq("example.com/path") + end + + it "memoizes the result" do + audit.url = "https://example.com" + first_result = audit.url_without_scheme + allow(audit).to receive(:hostname).and_return("different.com") + expect(audit.url_without_scheme).to eq(first_result) + end + end +end diff --git a/spec/models/site_spec.rb b/spec/models/site_spec.rb new file mode 100644 index 0000000..f1b5926 --- /dev/null +++ b/spec/models/site_spec.rb @@ -0,0 +1,116 @@ +require "rails_helper" + +RSpec.describe Site, type: :model do + let(:url) { "https://example.com/" } + + describe "associations" do + it { should have_many(:audits).dependent(:destroy) } + end + + describe "delegations" do + let(:site) { create(:site) } + let!(:audit) { create(:audit, site:, url: "https://example.com") } + + it { should delegate_method(:url).to(:audit) } + it { should delegate_method(:url_without_scheme).to(:audit) } + + it "delegates to the most recent audit" do + new_audit = create(:audit, site:, url: "https://new-example.com") + expect(site.reload.url).to eq(new_audit.url) + end + end + + describe ".find_or_create_by_url" do + let(:http_url) { "http://example.com" } + + context "when site with URL exists" do + let!(:existing_site) { described_class.create_with_audit(url:) } + + it "returns existing site for exact URL match" do + expect(described_class.find_or_create_by_url(url:)).to eq(existing_site) + end + + it "returns existing site when only scheme differs" do + expect(described_class.find_or_create_by_url(url: http_url)).to eq(existing_site) + end + + it "finds site with historical URLs" do + new_url = "https://new-example.com" + existing_site.audits.create!(url: new_url) + + expect(described_class.find_or_create_by_url(url:)).to eq(existing_site) + expect(described_class.find_or_create_by_url(url: new_url)).to eq(existing_site) + end + end + + context "when site does not exist" do + it "creates a new site with audit" do + expect { + site = described_class.find_or_create_by_url(url:) + expect(site).to be_persisted + expect(site.audit.url).to eq(url) + }.to change(described_class, :count).by(1) + .and change(Audit, :count).by(1) + end + end + end + + describe ".new_with_audit" do + it "builds a new site with associated audit" do + site = described_class.new_with_audit(url:) + + expect(site).to be_new_record + expect(site.audits.size).to eq(1) + expect(site.audit).to eq(site.audits.first) + expect(site.audit.url).to eq(url) + end + end + + describe ".create_with_audit" do + it "creates a new site with associated audit" do + expect { + site = described_class.create_with_audit(url:) + expect(site).to be_persisted + expect(site.audits.size).to eq(1) + expect(site.audit).to be_persisted + expect(site.audit.url).to eq(url) + }.to change(described_class, :count).by(1) + .and change(Audit, :count).by(1) + end + end + + describe "#to_title" do + let(:site) { create(:site, url:) } + let!(:audit) { create(:audit, site:) } + + it "returns the URL without scheme from the latest audit" do + expect(site.to_title).to eq(audit.url_without_scheme) + end + + it "updates when new audit is created" do + new_audit = create(:audit, site:, url: "https://new-example.com") + expect(site.reload.to_title).to eq(new_audit.url_without_scheme) + end + end + + describe "friendly_id" do + let(:url) { "https://example.com/path?query=1" } + let(:site) { described_class.create_with_audit(url:) } + + it "generates slug from url_without_scheme" do + expect(site.slug).to be_present + expect(site.slug).to eq(site.audit.url_without_scheme.parameterize) + end + + it "maintains history of slugs" do + old_slug = site.slug + new_url = "https://new-example.com" + + site.audits.create!(url: new_url) + site.save! + + expect(site.reload.slug).not_to eq(old_slug) + expect(described_class.friendly.find(old_slug)).to eq(site) + end + end +end