From 869a9fc048565c2eda7eb1d36ef43e52b038eb88 Mon Sep 17 00:00:00 2001 From: Mark Moser Date: Sun, 11 Nov 2018 10:12:43 -0600 Subject: [PATCH] Blogs --- .gitignore | 1 + app/controllers/v1/blogs_controller.rb | 49 ++++++++++ app/models/blog.rb | 30 ++++++ app/models/user.rb | 2 + app/policies/blog_policy.rb | 45 +++++++++ app/views/v1/blogs/_blog.json.jbuilder | 14 +++ app/views/v1/blogs/index.json.jbuilder | 3 + app/views/v1/blogs/show.json.jbuilder | 3 + config/routes.rb | 1 + db/migrate/20181111150322_create_blogs.rb | 15 +++ db/schema.rb | 12 ++- test/controllers/v1/blogs_controller_test.rb | 97 ++++++++++++++++++++ test/fixtures/blogs.yml | 47 ++++++++++ test/models/blog_test.rb | 9 ++ test/policies/blog_policy_test.rb | 64 +++++++++++++ user_stories.md | 33 ++++--- 16 files changed, 412 insertions(+), 13 deletions(-) create mode 100644 app/controllers/v1/blogs_controller.rb create mode 100644 app/models/blog.rb create mode 100644 app/policies/blog_policy.rb create mode 100644 app/views/v1/blogs/_blog.json.jbuilder create mode 100644 app/views/v1/blogs/index.json.jbuilder create mode 100644 app/views/v1/blogs/show.json.jbuilder create mode 100644 db/migrate/20181111150322_create_blogs.rb create mode 100644 test/controllers/v1/blogs_controller_test.rb create mode 100644 test/fixtures/blogs.yml create mode 100644 test/models/blog_test.rb create mode 100644 test/policies/blog_policy_test.rb diff --git a/.gitignore b/.gitignore index 76c20db..73bf52f 100644 --- a/.gitignore +++ b/.gitignore @@ -26,3 +26,4 @@ # Ignore application configuration /config/application.yml coverage/ +erd.pdf diff --git a/app/controllers/v1/blogs_controller.rb b/app/controllers/v1/blogs_controller.rb new file mode 100644 index 0000000..16487c2 --- /dev/null +++ b/app/controllers/v1/blogs_controller.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +module V1 + class BlogsController < ApplicationController + before_action :set_blog, only: %i[show update destroy] + + def index + @blogs = policy_scope Blog.all + end + + def show; end + + def create + @blog = Blog.new(blog_params) + @blog.user_id = current_user.id + + authorize @blog + + if @blog.save + render :show, status: :created, location: v1_blogs_url(@blog) + else + render json: @blog.errors, status: :unprocessable_entity + end + end + + def update + if @blog.update(blog_params) + render :show, status: :ok, location: v1_blogs_url(@blog) + else + render json: @blog.errors, status: :unprocessable_entity + end + end + + def destroy + @blog.destroy + end + + private + + def set_blog + @blog = Blog.find(params[:id]) + authorize @blog + end + + def blog_params + params.require(:blog).permit(policy(Blog).permitted_attributes) + end + end +end diff --git a/app/models/blog.rb b/app/models/blog.rb new file mode 100644 index 0000000..1c0feb1 --- /dev/null +++ b/app/models/blog.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +# == Schema Information +# +# Table name: blogs +# +# id :integer not null, primary key +# article :text not null +# published_date :string default(""), not null +# title :string not null +# created_at :datetime not null +# updated_at :datetime not null +# user_id :integer +# +# Indexes +# +# index_blogs_on_user_id (user_id) +# + +class Blog < ApplicationRecord + belongs_to :author, class_name: "User", foreign_key: :user_id, inverse_of: :blogs + + scope :published, -> { where("published_date <= ?", Time.zone.now) } + + def published? + return false if published_date.empty? + + Time.zone.parse(published_date) <= Time.zone.now + end +end diff --git a/app/models/user.rb b/app/models/user.rb index 05c71a0..61b49f1 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -20,6 +20,8 @@ class User < ApplicationRecord has_secure_password + has_many :blogs, dependent: :destroy + validates :display_name, presence: true validates :email, presence: true, email_format: true, uniqueness: true validates :password_confirmation, presence: true, if: ->(m) { m.password.present? } diff --git a/app/policies/blog_policy.rb b/app/policies/blog_policy.rb new file mode 100644 index 0000000..fca58f2 --- /dev/null +++ b/app/policies/blog_policy.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +class BlogPolicy < ApplicationPolicy + def show? + return true if update? + + record.published? + end + + def update? + return true if user&.acts_as_admin? + + record.user_id == user&.id + end + + def destroy? + update? + end + + def create? + user&.acts_as_author? + end + + def permitted_attributes + return base_attributes + %i[user_id] if user&.acts_as_admin? + + base_attributes + end + + def base_attributes + %i[ + title + article + ] + end + + class Scope < Scope + def resolve + return scope if user&.acts_as_admin? + return scope.published.or(user.blogs) if user&.acts_as_author? + + scope.published + end + end +end diff --git a/app/views/v1/blogs/_blog.json.jbuilder b/app/views/v1/blogs/_blog.json.jbuilder new file mode 100644 index 0000000..a2fd7e7 --- /dev/null +++ b/app/views/v1/blogs/_blog.json.jbuilder @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +json.url v1_blog_url(blog, format: :json) + +json.extract! blog, + :title, + :article, + :published_date, + :id + +json.author do + json.name blog.author.display_name + json.url v1_user_url(blog.author, format: :json) +end diff --git a/app/views/v1/blogs/index.json.jbuilder b/app/views/v1/blogs/index.json.jbuilder new file mode 100644 index 0000000..9a68bf7 --- /dev/null +++ b/app/views/v1/blogs/index.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.array! @blogs, partial: 'v1/blogs/blog', as: :blog diff --git a/app/views/v1/blogs/show.json.jbuilder b/app/views/v1/blogs/show.json.jbuilder new file mode 100644 index 0000000..2f448e3 --- /dev/null +++ b/app/views/v1/blogs/show.json.jbuilder @@ -0,0 +1,3 @@ +# frozen_string_literal: true + +json.partial! "v1/blogs/blog", blog: @blog diff --git a/config/routes.rb b/config/routes.rb index 8e8f35c..88f6609 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -4,6 +4,7 @@ Rails.application.routes.draw do concern :api_base do post 'authenticate', to: 'authentication#authenticate', as: :authenticate resources :users + resources :blogs end namespace :v1, defaults: { format: :json } do diff --git a/db/migrate/20181111150322_create_blogs.rb b/db/migrate/20181111150322_create_blogs.rb new file mode 100644 index 0000000..a761eb4 --- /dev/null +++ b/db/migrate/20181111150322_create_blogs.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +class CreateBlogs < ActiveRecord::Migration[5.2] + def change + create_table :blogs do |t| + t.string :title, null: false + t.text :article, null: false + t.string :published_date, null: false, default: "" + t.integer :user_id + + t.timestamps + end + add_index :blogs, :user_id + end +end diff --git a/db/schema.rb b/db/schema.rb index 895b44a..4e1149e 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -10,7 +10,17 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2018_11_10_183741) do +ActiveRecord::Schema.define(version: 2018_11_11_150322) do + + create_table "blogs", force: :cascade do |t| + t.string "title", null: false + t.text "article", null: false + t.string "published_date", default: "", null: false + t.integer "user_id" + t.datetime "created_at", null: false + t.datetime "updated_at", null: false + t.index ["user_id"], name: "index_blogs_on_user_id" + end create_table "users", force: :cascade do |t| t.string "display_name", null: false diff --git a/test/controllers/v1/blogs_controller_test.rb b/test/controllers/v1/blogs_controller_test.rb new file mode 100644 index 0000000..97bf240 --- /dev/null +++ b/test/controllers/v1/blogs_controller_test.rb @@ -0,0 +1,97 @@ +# frozen_string_literal: true + +require 'test_helper' + +class BlogsControllerTest < ActionDispatch::IntegrationTest + test "anyone can index published blogs" do + blogs = Blog.published + get v1_blogs_url + body = JSON.parse response.body + + assert_response :ok + assert_equal blogs.count, body.count + end + + test "admins can index ALL blogs" do + get v1_blogs_url, headers: auth_headers(users(:admin)) + body = JSON.parse response.body + + assert_response :ok + assert_equal Blog.count, body.count + end + + test "author can index ALL his blogs plus published" do + author = users(:author) + blogs = Blog.published.or(author.blogs) + + get v1_blogs_url, headers: auth_headers(author) + body = JSON.parse response.body + + assert_response :ok + assert_equal blogs.count, body.count + end + + test "sally can not index authors unpublished blogs" do + bad_blog = blogs(:author2) + sally = users(:sally) + + get v1_blogs_url, headers: auth_headers(sally) + body = JSON.parse response.body + blog_ids = body.each_with_object([]) { |blog, memo| memo << blog["id"] } + + assert_response :ok + assert_not blog_ids.include?(bad_blog) + end + + test "guests can view a published blog" do + blog = blogs(:author1) + get v1_blog_url(blog) + + assert_response :success + assert_match blog.title, response.body + end + + test "guests CANNOT view an unpublished blog" do + get v1_blog_url(blogs(:author2)) + + assert_response :unauthorized + end + + test "authors can create and recieve a new blog" do + assert_difference('Blog.count') do + post v1_blogs_url, params: { blog: { + title: "This is my blog", + article: "I don't have much to say" + } }, headers: auth_headers(users(:michelle)) + end + + assert_response :created + assert_match(/this is my blog/i, response.body) + assert_match(/michelle/i, response.body) + end + + test "author can update blog" do + patch v1_blog_url(blogs(:author1)), params: { blog: { + title: "a new title" + } }, headers: auth_headers(users(:author)) + + assert_response :ok + assert_match(/a new title/i, response.body) + end + + test "admin can destroy a blog" do + assert_difference('Blog.count', -1) do + delete v1_blog_url(blogs(:author1)), headers: auth_headers(users(:admin)) + end + + assert_response :no_content + end + + test "sally can destroy her blogs" do + assert_difference('Blog.count', -1) do + delete v1_blog_url(blogs(:sally1)), headers: auth_headers(users(:sally)) + end + + assert_response :no_content + end +end diff --git a/test/fixtures/blogs.yml b/test/fixtures/blogs.yml new file mode 100644 index 0000000..5d253de --- /dev/null +++ b/test/fixtures/blogs.yml @@ -0,0 +1,47 @@ +# == Schema Information +# +# Table name: blogs +# +# id :integer not null, primary key +# article :text not null +# published_date :string default(""), not null +# title :string not null +# created_at :datetime not null +# updated_at :datetime not null +# user_id :integer +# +# Indexes +# +# index_blogs_on_user_id (user_id) +# + +# Read about fixtures at http://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html + +author1: + title: My Opus + article: "Donec sed odio dui. Nulla vitae elit libero, a pharetra augue. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nulla vitae elit libero, a pharetra augue." + published_date: <%= (Time.zone.now - 8.days).to_s %> + author: author + +author2: + title: A Work in Progress + article: "Donec sed odio dui. Nulla vitae elit libero, a pharetra augue. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nulla vitae elit libero, a pharetra augue." + author: author + +sally1: + title: Vehicula Fringilla Consectetur Elit + article: "Donec sed odio dui. Nulla vitae elit libero, a pharetra augue. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus mus. Nulla vitae elit libero, a pharetra augue." + published_date: <%= (Time.zone.now - 15.days).to_s %> + author: sally + +sally2: + title: Tristique Malesuada Dapibus Euismod + article: "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Curabitur blandit tempus porttitor. Donec sed odio dui. Nulla vitae elit libero, a pharetra augue." + published_date: <%= (Time.zone.now - 5.days).to_s %> + author: sally + +sally3: + title: Tellus Quam Euismod Aenean + article: "Nullam id dolor id nibh ultricies vehicula ut id elit. Cras justo odio, dapibus ac facilisis in, egestas eget quam. Donec ullamcorper nulla non metus auctor fringilla." + author: sally + diff --git a/test/models/blog_test.rb b/test/models/blog_test.rb new file mode 100644 index 0000000..04a7c7b --- /dev/null +++ b/test/models/blog_test.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +require 'test_helper' + +class BlogTest < ActiveSupport::TestCase + # test "the truth" do + # assert true + # end +end diff --git a/test/policies/blog_policy_test.rb b/test/policies/blog_policy_test.rb new file mode 100644 index 0000000..8eaea8d --- /dev/null +++ b/test/policies/blog_policy_test.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'test_helper' + +class BlogPolicyTest < PolicyAssertions::Test + test 'anyone can view a published blog' do + assert_permit nil, blogs(:author1), :show? + end + + test 'must authenticate for modification' do + assert_raise Pundit::NotAuthorizedError do + %w[create update destroy].each do |action| + UserPolicy.new(nil, User.new).send("#{action}?") + end + end + end + + # show + test 'author can show his unpublished blog' do + assert_permit users(:author), blogs(:author2), :show? + end + + test 'admin can show anothers unpublishd blog' do + assert_permit users(:admin), blogs(:author2), :show? + end + + test 'sally CANNOT show authors unpublishd blog' do + assert_not_permitted users(:sally), blogs(:author2), :show? + end + + # update + test 'author can update his unpublished blog' do + assert_permit users(:author), blogs(:author2), :update? + end + + test 'admin can update anothers unpublishd blog' do + assert_permit users(:admin), blogs(:author2), :update? + end + + test 'sally CANNOT update authors unpublishd blog' do + assert_not_permitted users(:sally), blogs(:author2), :update? + end + + # create + test 'users can create a new blog' do + assert_permit users(:admin), Blog.new, :create? + assert_permit users(:author), Blog.new, :create? + assert_permit users(:sally), Blog.new, :create? + assert_permit users(:michelle), Blog.new, :create? + end + + # destroy + test 'authors can destroy their own blogs' do + assert_permit users(:author), blogs(:author1), :destroy? + end + + test 'admins can destroy any blogs' do + assert_permit users(:admin), blogs(:author1), :destroy? + end + + test 'users CANOT destroy another authors blogs' do + assert_not_permitted users(:sally), blogs(:author1), :destroy? + end +end diff --git a/user_stories.md b/user_stories.md index 2b63952..5e7c655 100644 --- a/user_stories.md +++ b/user_stories.md @@ -2,15 +2,9 @@ An API for a micro blogging platform -Guests can only view published content and author list -Authors can edit their profile & blogs -Admins can view the list of authors and edit any blog - -blogs: - title - article - published_date - author_id +* Guests can view published content and author list +* Authors can edit their profile & blogs +* Admins can view the list of authors and edit any blog ## Tasks @@ -19,9 +13,14 @@ blogs: As an author, I want to be able to write a short bio that is displayed with each of my postings. +### BUG: Publishing + +It has been reported that authors are not able to publish +their blogs + ### Moderation -As an admin, I want to be able to put a blog it in a state +As an admin, I want to be able to put a blog in a state of moderation that also unpublishes it. Once a blog post has this state, the author must edit and @@ -29,8 +28,18 @@ submit for another review before an admin will republish. ### Categories -As an author, I would like to be able to assign as category -to my posting from a known list of options. +As an author, I would like to be able to assign a category +to my posting from an existing list of options. As an admin, I can manage the list of categories available to the authors. + +### Registration + +As a guest, I should be able to register so that I may start +producing content. + +### Author Titles + +As a user, when I look up an author, I should also get a list +of titles and links to her published articles.