From cf2332a862679ad814593b29656d42cc4634b8ea Mon Sep 17 00:00:00 2001 From: Ernesto Tagwerker Date: Sat, 16 May 2026 16:06:35 -0400 Subject: [PATCH 1/2] Validate utm_* inclusion and add Bluesky to UTM_SOURCES Until now the UTM_SOURCES, UTM_MEDIUMS, UTM_CAMPAIGN and UTM_CONTENT constants were only dropdown options in the share form. The API and any direct callers could store arbitrary strings. This change: - Adds Bluesky to UTM_SOURCES so it shows up in the dropdown and is accepted by the new JSON create endpoint - Adds inclusion validations for utm_source, utm_medium, utm_campaign, and utm_content scoped to :create. Existing rows are left untouched on update so legacy values do not block future edits - Updates the factory and api_links_spec setups to use values that are in the whitelist, so the validations do not break those tests - Adds Share model specs covering each new validation, Bluesky as a valid utm_source, and the :create-only contract Co-Authored-By: Claude Opus 4.7 (1M context) --- app/models/share.rb | 5 +++ spec/factories/shares.rb | 6 ++-- spec/models/share_spec.rb | 58 +++++++++++++++++++++++++++++++-- spec/requests/api_links_spec.rb | 12 +++---- 4 files changed, 70 insertions(+), 11 deletions(-) diff --git a/app/models/share.rb b/app/models/share.rb index b45f97e..8c1a233 100644 --- a/app/models/share.rb +++ b/app/models/share.rb @@ -3,6 +3,7 @@ class Share < ApplicationRecord UTM_SOURCES = %w[ Bing + Bluesky ConvertKit Facebook Google @@ -42,6 +43,10 @@ class Share < ApplicationRecord ].sort! validates :utm_source, :utm_campaign, :utm_medium, :utm_term, presence: true + validates :utm_source, inclusion: { in: UTM_SOURCES, allow_blank: true }, on: :create + validates :utm_medium, inclusion: { in: UTM_MEDIUMS, allow_blank: true }, on: :create + validates :utm_campaign, inclusion: { in: UTM_CAMPAIGN, allow_blank: true }, on: :create + validates :utm_content, inclusion: { in: UTM_CONTENT, allow_blank: true }, on: :create def calculated_url uri = URI(link.url) diff --git a/spec/factories/shares.rb b/spec/factories/shares.rb index eecc4c7..81fd484 100644 --- a/spec/factories/shares.rb +++ b/spec/factories/shares.rb @@ -1,10 +1,10 @@ FactoryBot.define do factory :share do utm_source { 'LinkedIn' } - utm_medium { 'community' } - utm_campaign { 'campaignOne' } + utm_medium { 'Organic' } + utm_campaign { 'Blogpromo' } utm_term { 'termOne' } - utm_content { 'campaignContent' } + utm_content { 'Photo' } link end end diff --git a/spec/models/share_spec.rb b/spec/models/share_spec.rb index 757ffbc..48eec82 100644 --- a/spec/models/share_spec.rb +++ b/spec/models/share_spec.rb @@ -5,10 +5,64 @@ context "when it does not have a link or the minimum requirements" do it "is not valid" do link = Share.new - + expect(link.valid?).to be_falsey expect(link.errors.full_messages).to eq ["Link must exist", "Utm source can't be blank", "Utm campaign can't be blank", "Utm medium can't be blank", "Utm term can't be blank"] end end + + context "on create with an unrecognized utm_source" do + it "is not valid" do + share = build(:share, utm_source: "NotARealSource") + + expect(share.valid?(:create)).to be_falsey + expect(share.errors[:utm_source]).to include("is not included in the list") + end + end + + context "on create with Bluesky as utm_source" do + it "is valid" do + share = build(:share, utm_source: "Bluesky") + + expect(share.valid?(:create)).to be_truthy + end + end + + context "on create with an unrecognized utm_medium" do + it "is not valid" do + share = build(:share, utm_medium: "community") + + expect(share.valid?(:create)).to be_falsey + expect(share.errors[:utm_medium]).to include("is not included in the list") + end + end + + context "on create with an unrecognized utm_campaign" do + it "is not valid" do + share = build(:share, utm_campaign: "campaignOne") + + expect(share.valid?(:create)).to be_falsey + expect(share.errors[:utm_campaign]).to include("is not included in the list") + end + end + + context "on create with an unrecognized utm_content" do + it "is not valid" do + share = build(:share, utm_content: "Custom") + + expect(share.valid?(:create)).to be_falsey + expect(share.errors[:utm_content]).to include("is not included in the list") + end + end + + context "when updating an existing record whose utm_source is no longer in the list" do + it "is allowed (validations are :create-only)" do + share = create(:share) + share.update_column(:utm_source, "LegacyValue") + + share.shortened_url = "https://example.com/abc" + expect(share.save).to be_truthy + end + end end -end \ No newline at end of file +end diff --git a/spec/requests/api_links_spec.rb b/spec/requests/api_links_spec.rb index 73c9af0..ae27ea0 100644 --- a/spec/requests/api_links_spec.rb +++ b/spec/requests/api_links_spec.rb @@ -73,8 +73,8 @@ fastruby_link.social_media_snippets.create!(content: 'Tweet 1', social_media_type: 'Twitter') fastruby_link.social_media_snippets.create!(content: 'LI 1', social_media_type: 'LinkedIn') fastruby_link.shares.create!( - utm_source: 'LinkedIn', utm_medium: 'community', - utm_campaign: 'campaignOne', utm_term: 'termOne' + utm_source: 'LinkedIn', utm_medium: 'Organic', + utm_campaign: 'Blogpromo', utm_term: 'termOne' ) end @@ -104,8 +104,8 @@ describe 'GET /links/:link_id/shares.json' do let!(:share) do fastruby_link.shares.create!( - utm_source: 'LinkedIn', utm_medium: 'community', - utm_campaign: 'campaignOne', utm_term: 'termOne' + utm_source: 'LinkedIn', utm_medium: 'Organic', + utm_campaign: 'Blogpromo', utm_term: 'termOne' ) end @@ -140,8 +140,8 @@ { share: { utm_source: 'LinkedIn', - utm_medium: 'community', - utm_campaign: 'campaignOne', + utm_medium: 'Organic', + utm_campaign: 'Blogpromo', utm_term: 'termOne', utm_content: 'Photo' } From 53e2e2f828b900ed2ecce7df831d105cbd164c7e Mon Sep 17 00:00:00 2001 From: Ernesto Tagwerker Date: Sat, 16 May 2026 17:43:09 -0400 Subject: [PATCH 2/2] Stub OpenAI Link callback in share specs The "update with legacy utm_source" spec calls `create(:share)`, which persists a Link. Link has an `after_create` callback that calls `fetch_social_media_snippets`, which talks to OpenAI. In CI there is no OpenAI access token so the test fails with `OpenAI::ConfigurationError`. Match the pattern already used in `shares_controller_spec.rb` and `api_links_spec.rb`: stub `fetch_social_media_snippets` to return an empty array in a top-level `before` block so the callback is a no-op across every example in the file. Co-Authored-By: Claude Opus 4.7 (1M context) --- spec/models/share_spec.rb | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/spec/models/share_spec.rb b/spec/models/share_spec.rb index 48eec82..40639c2 100644 --- a/spec/models/share_spec.rb +++ b/spec/models/share_spec.rb @@ -1,6 +1,10 @@ require "rails_helper" RSpec.describe Share do + before do + allow_any_instance_of(Link).to receive(:fetch_social_media_snippets).and_return([]) + end + describe "#valid?" do context "when it does not have a link or the minimum requirements" do it "is not valid" do