Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion app/controllers/application_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@ class ApplicationController < ActionController::Base
# so a PAT cannot reach write endpoints via content negotiation.
API_ENDPOINTS = {
"links" => %w[index show],
"shares" => %w[index create]
"shares" => %w[index create],
"social_media_snippets" => %w[index create]
}.freeze

skip_forgery_protection if: :api_endpoint?
Expand Down
49 changes: 49 additions & 0 deletions app/controllers/social_media_snippets_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
class SocialMediaSnippetsController < ApplicationController
before_action :find_link

# GET /links/1/social_media_snippets.json
# Optional ?social_media_type=LinkedIn narrows the list to one network.
def index
snippets = @link.social_media_snippets.order(:created_at, :id)
snippets = snippets.where(social_media_type: params[:social_media_type]) if params[:social_media_type].present?

respond_to do |format|
format.json { render json: snippets.map { |s| snippet_json(s) } }
end
end

# POST /links/1/social_media_snippets.json
def create
@snippet = @link.social_media_snippets.build(snippet_params)

respond_to do |format|
format.json do
if @snippet.save
render json: snippet_json(@snippet), status: :created
else
render json: { errors: @snippet.errors.full_messages }, status: :unprocessable_entity
end
end
end
end

private

def find_link
@link = Link.find(params[:link_id])
end

def snippet_params
params.require(:social_media_snippet).permit(:social_media_type, :content)
end

def snippet_json(snippet)
{
id: snippet.id,
link_id: snippet.link_id,
social_media_type: snippet.social_media_type,
content: snippet.content,
created_at: snippet.created_at
}
end
end
14 changes: 14 additions & 0 deletions app/models/social_media_snippet.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
class SocialMediaSnippet < ApplicationRecord
SOCIAL_MEDIA_TYPES = %w[
Bluesky
GoogleBusiness
LinkedIn
Mastodon
Twitter
].sort!

belongs_to :link

validates :content, presence: true
# on: :create so snippets saved before this list existed stay editable.
validates :social_media_type, presence: true, on: :create
validates :social_media_type, inclusion: { in: SOCIAL_MEDIA_TYPES, allow_blank: true }, on: :create
validates :content, uniqueness: { scope: %i[link_id social_media_type],
message: "has already been saved for this link and social media type",
allow_blank: true }, on: :create
end
2 changes: 1 addition & 1 deletion app/views/links/show.html.erb
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@

<% if @link.social_media_snippets.any? %>
<h2 class="text-4xl">Social Media Snippets</h2>
<p class="my-3">These prompts are created by Open AI, and are suggestions to get you started when posting one of our blog posts, they should be paired with a link from above.</p>
<p class="my-3">Suggested copy for posting this article. Pair each snippet with one of the shortened links above.</p>

<% @grouped_social_media_snippets.each do |media_type, snippets| %>
<h3 class="text-2xl"><%= media_type %></h3>
Expand Down
1 change: 1 addition & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
root to: "links#index"
resources :links, only: [:index, :show] do
resources :shares
resources :social_media_snippets, only: [:index, :create]
get '/shares/:id/clone', to: 'shares#clone', as: "clone"
end
post 'execute_read_task', to: 'application#execute_read_task'
Expand Down
7 changes: 7 additions & 0 deletions spec/factories/social_media_snippets.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
FactoryBot.define do
factory :social_media_snippet do
social_media_type { 'LinkedIn' }
sequence(:content) { |n| "Snippet copy ##{n}" }
link
end
end
55 changes: 53 additions & 2 deletions spec/models/social_media_snippet_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,59 @@
link = SocialMediaSnippet.new

expect(link.valid?).to be_falsey
expect(link.errors.full_messages).to eq ["Link must exist", "Content can't be blank"]
expect(link.errors.full_messages).to eq [
"Link must exist",
"Content can't be blank",
"Social media type can't be blank"
]
end
end

context "social_media_type" do
let(:link) { FactoryBot.create(:link) }

it "accepts each supported type" do
SocialMediaSnippet::SOCIAL_MEDIA_TYPES.each do |type|
snippet = SocialMediaSnippet.new(link: link, content: "Copy for #{type}", social_media_type: type)
expect(snippet).to be_valid, "expected #{type} to be valid"
end
end

it "is case sensitive" do
snippet = SocialMediaSnippet.new(link: link, content: "Copy", social_media_type: "linkedin")

expect(snippet).not_to be_valid
expect(snippet.errors.full_messages).to eq ["Social media type is not included in the list"]
end

it "does not block editing a snippet saved before the list existed" do
legacy = SocialMediaSnippet.new(link: link, content: "Old copy", social_media_type: "Facebook")
legacy.save!(validate: false)

expect(legacy.update(content: "Edited old copy")).to be true
end
end

context "duplicates" do
let(:link) { FactoryBot.create(:link) }
let!(:existing) { FactoryBot.create(:social_media_snippet, link: link, social_media_type: "LinkedIn", content: "Same copy") }

it "rejects the same content for the same link and type" do
duplicate = SocialMediaSnippet.new(link: link, social_media_type: "LinkedIn", content: "Same copy")

expect(duplicate).not_to be_valid
expect(duplicate.errors[:content]).to eq ["has already been saved for this link and social media type"]
end

it "allows the same content for another type" do
expect(SocialMediaSnippet.new(link: link, social_media_type: "Bluesky", content: "Same copy")).to be_valid
end

it "allows the same content for another link" do
other = FactoryBot.create(:link, url: "https://www.fastruby.io/blog/other.html")

expect(SocialMediaSnippet.new(link: other, social_media_type: "LinkedIn", content: "Same copy")).to be_valid
end
end
end
end
end
191 changes: 191 additions & 0 deletions spec/requests/api_social_media_snippets_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
require 'rails_helper'

RSpec.describe 'JSON API for social media snippets', type: :request do
let(:user) { FactoryBot.create(:user) }
let(:token) { FactoryBot.create(:personal_access_token, user: user) }
let(:auth_headers) { { 'Authorization' => "Bearer #{token.token}", 'Accept' => 'application/json' } }

let!(:link) { FactoryBot.create(:link, url: 'https://www.fastruby.io/blog/some-post.html', title: 'Some Post') }
let!(:other_link) { FactoryBot.create(:link, url: 'https://www.fastruby.io/blog/another-post.html', title: 'Another Post') }

describe 'GET /links/:link_id/social_media_snippets.json' do
let!(:linkedin_snippet) { FactoryBot.create(:social_media_snippet, link: link, social_media_type: 'LinkedIn', content: 'On LinkedIn') }
let!(:mastodon_snippet) { FactoryBot.create(:social_media_snippet, link: link, social_media_type: 'Mastodon', content: 'On Mastodon') }
let!(:other_snippet) { FactoryBot.create(:social_media_snippet, link: other_link, content: 'Another link') }

it "returns only the link's snippets" do
get "/links/#{link.id}/social_media_snippets.json", headers: auth_headers

expect(response).to have_http_status(:ok)
body = JSON.parse(response.body)
expect(body.map { |s| s['content'] }).to eq(['On LinkedIn', 'On Mastodon'])
expect(body.first.keys).to match_array(%w[id link_id social_media_type content created_at])
end

it 'filters by social_media_type' do
get "/links/#{link.id}/social_media_snippets.json",
params: { social_media_type: 'Mastodon' }, headers: auth_headers

expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body).map { |s| s['content'] }).to eq(['On Mastodon'])
end

it 'returns an empty list for a link with no snippets' do
empty_link = FactoryBot.create(:link, url: 'https://www.fastruby.io/blog/empty.html')
get "/links/#{empty_link.id}/social_media_snippets.json", headers: auth_headers

expect(response).to have_http_status(:ok)
expect(JSON.parse(response.body)).to eq([])
end

it 'raises RecordNotFound for an unknown link (Rails answers 404 outside the test environment)' do
expect {
get '/links/0/social_media_snippets.json', headers: auth_headers
}.to raise_error(ActiveRecord::RecordNotFound)
end

it 'returns 401 without a token' do
get "/links/#{link.id}/social_media_snippets.json", headers: { 'Accept' => 'application/json' }
expect(response).to have_http_status(:unauthorized)
end

it 'returns 401 with an invalid token' do
headers = { 'Authorization' => 'Bearer wrong', 'Accept' => 'application/json' }
get "/links/#{link.id}/social_media_snippets.json", headers: headers
expect(response).to have_http_status(:unauthorized)
end
end

describe 'POST /links/:link_id/social_media_snippets.json' do
let(:valid_params) do
{ social_media_snippet: { social_media_type: 'LinkedIn', content: 'A fresh take on the post.' } }
end

def post_snippet(params, headers: auth_headers, to: link)
post "/links/#{to.id}/social_media_snippets.json", params: params, headers: headers
end

it 'creates a snippet for a valid token' do
expect { post_snippet(valid_params) }.to change(SocialMediaSnippet, :count).by(1)

expect(response).to have_http_status(:created)
body = JSON.parse(response.body)
expect(body['link_id']).to eq(link.id)
expect(body['social_media_type']).to eq('LinkedIn')
expect(body['content']).to eq('A fresh take on the post.')
expect(body['id']).to be_present
expect(body['created_at']).to be_present
end

it 'accepts every supported social media type' do
SocialMediaSnippet::SOCIAL_MEDIA_TYPES.each do |type|
post_snippet({ social_media_snippet: { social_media_type: type, content: "Copy for #{type}" } })
expect(response).to have_http_status(:created), "expected #{type} to be accepted"
end
end

it 'shows up in GET /links/:id.json' do
post_snippet(valid_params)

get "/links/#{link.id}.json", headers: auth_headers
contents = JSON.parse(response.body)['social_media_snippets'].map { |s| s['content'] }
expect(contents).to eq(['A fresh take on the post.'])
end

it 'returns 422 when content is missing' do
expect {
post_snippet({ social_media_snippet: { social_media_type: 'LinkedIn', content: '' } })
}.not_to change(SocialMediaSnippet, :count)

expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)['errors']).to include("Content can't be blank")
end

it 'returns 422 for an unknown social media type' do
expect {
post_snippet({ social_media_snippet: { social_media_type: 'Myspace', content: 'Hello' } })
}.not_to change(SocialMediaSnippet, :count)

expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)['errors']).to include('Social media type is not included in the list')
end

it 'returns 422 when the social media type is missing' do
post_snippet({ social_media_snippet: { content: 'Hello' } })

expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)['errors']).to include("Social media type can't be blank")
end

it 'returns 422 for an exact duplicate, so a retry is safe' do
post_snippet(valid_params)

expect { post_snippet(valid_params) }.not_to change(SocialMediaSnippet, :count)

expect(response).to have_http_status(:unprocessable_entity)
expect(JSON.parse(response.body)['errors'].first).to match(/has already been saved/)
end

it 'accepts the same content on another network or another link' do
post_snippet(valid_params)

post_snippet({ social_media_snippet: valid_params[:social_media_snippet].merge(social_media_type: 'Mastodon') })
expect(response).to have_http_status(:created)

post_snippet(valid_params, to: other_link)
expect(response).to have_http_status(:created)
end

it 'accepts a JSON body, with or without the social_media_snippet wrapper' do
headers = auth_headers.merge('Content-Type' => 'application/json')

post "/links/#{link.id}/social_media_snippets.json",
params: { social_media_type: 'Bluesky', content: 'Flat JSON body' }.to_json, headers: headers
expect(response).to have_http_status(:created)
expect(JSON.parse(response.body)['content']).to eq('Flat JSON body')

post "/links/#{link.id}/social_media_snippets.json",
params: { social_media_snippet: { social_media_type: 'Mastodon', content: 'Wrapped JSON body' } }.to_json,
headers: headers
expect(response).to have_http_status(:created)
expect(JSON.parse(response.body)['content']).to eq('Wrapped JSON body')
end

it 'raises ParameterMissing without any snippet params (Rails answers 400 outside the test environment)' do
expect { post_snippet({}) }.to raise_error(ActionController::ParameterMissing)
end

it 'raises RecordNotFound for an unknown link and creates nothing' do
expect {
expect {
post '/links/0/social_media_snippets.json', params: valid_params, headers: auth_headers
}.to raise_error(ActiveRecord::RecordNotFound)
}.not_to change(SocialMediaSnippet, :count)
end

it 'returns 401 without a token' do
expect {
post_snippet(valid_params, headers: { 'Accept' => 'application/json' })
}.not_to change(SocialMediaSnippet, :count)

expect(response).to have_http_status(:unauthorized)
end

it 'returns 401 with an invalid token' do
headers = { 'Authorization' => 'Bearer wrong', 'Accept' => 'application/json' }

expect { post_snippet(valid_params, headers: headers) }.not_to change(SocialMediaSnippet, :count)
expect(response).to have_http_status(:unauthorized)
end

it 'returns 401 for cookie-authenticated browsers without a Bearer token' do
sign_in user

expect {
post_snippet(valid_params, headers: { 'Accept' => 'application/json' })
}.not_to change(SocialMediaSnippet, :count)

expect(response).to have_http_status(:unauthorized)
end
end
end
37 changes: 37 additions & 0 deletions spec/views/links/show.html.erb_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
require "rails_helper"

RSpec.describe "links/show", type: :view do
let(:link) { FactoryBot.create(:link) }

def render_page
assign(:link, link)
assign(:grouped_social_media_snippets, link.social_media_snippets.group_by(&:social_media_type))
render
end

context "with snippets" do
before do
FactoryBot.create(:social_media_snippet, link: link, social_media_type: "LinkedIn", content: "Copy for LinkedIn")
FactoryBot.create(:social_media_snippet, link: link, social_media_type: "Mastodon", content: "Copy for Mastodon")
end

it "lists each snippet under its social media type" do
render_page

expect(rendered).to include("Social Media Snippets", "LinkedIn", "Copy for LinkedIn", "Mastodon", "Copy for Mastodon")
end

it "doesn't say where the copy came from, since it isn't only generated anymore" do
render_page

expect(rendered).not_to match(/open ?ai/i)
expect(rendered).to include("Suggested copy for posting this article. Pair each snippet with one of the shortened links above.")
end
end

it "leaves out the section when the link has no snippets" do
render_page

expect(rendered).not_to include("Social Media Snippets")
end
end
Loading