пятница, 12 октября 2012 г.

Ruby on Rails Tutorial 11

Глава 11 Микросообщения пользователей

В Главе 10 были закончены REST действия для ресурса Users, так что пришло время наконец-то добавить второй ресурс: пользовательские микросообщения.1 Эти короткие сообщения, связанные с конкретным пользователем, впервые были показаны (в зачаточной форме) в Главе 2. В этой главе мы сделаем полноценную версию наброска из Раздела 2.3, сконструировав модель данных Micropost, связав ее с моделью User при помощи has_many и belongs_to методов, а затем сделав формы и партиалы, необходимые для манипулирования результатами и их отображения. В Главе 12, мы завершим наш крохотный клон Twitter, добавив понятие слежения за пользователями, чтобы получить поток (feed) их микросообщений.
Если вы используете Git для управления версиями, я предлагаю сделать новую тему ветки, как обычно:
$ git checkout -b user-microposts

11.1 Модель Micropost

Мы начнем Microposts ресурс с создания модели Micropost, которая фиксирует основные характеристики микросообщений. Что мы сделаем основываясь на работе, проделанной в Разделе 2.3; как и модель из того раздела, наша новая модель Micropost будет включать валидации и ассоциации с моделью User. В отличие от той модели, данная Micropost модель будет полностью протестирована, а также будет иметь дефолтное упорядочивание и автоматическую деструкцию в случае уничтожения родительского пользователя.

11.1.1 Базовая модель

Модели Micropost необходимы лишь два атрибута: content атрибут, содержащий текст микросообщений,2 и user_id связывающий микросообщения с конкретным пользователем. Как и в случае с моделью User (Листинг 6.1), мы генерируем ее используя generate model:
$ rails generate model Micropost content:string user_id:integer
Это производит миграцию для создания таблицы microposts в базе данных (Листинг 11.1); сравните ее с аналогичной миграцией для таблицы users из Листинга 6.2.
Листинг 11.1. Миграция Micropost. (Обратите внимание на индекс user_id.)
db/migrate/<timestamp>_create_microposts.rb
class CreateMicroposts < ActiveRecord::Migration
  def self.up
    create_table :microposts do |t|
      t.string :content
      t.integer :user_id

      t.timestamps
    end
    add_index :microposts, :user_id
    add_index :microposts, :created_at
  end

  def self.down
    drop_table :microposts
  end
end
Отметим, что, поскольку мы ожидаем, извлечение всех микросообщений, связанных с данным id пользователя, в порядке, обратном их созданию Листинг 11.1 добавляет индексы (Блок 6.2) на столбцы user_id и created_at:
add_index :microposts, :user_id
add_index :microposts, :created_at
Отметим также строку t.timestamps, которая (как указано в Разделе 6.1.1) добавляет волшебные столбцы created_at и updated_at. Мы будем работать со столбцом created_at в Разделе 11.1.3 и Разделе 11.2.1.
Мы можем запустить миграцию микросообщений как обычно (с учетом необходимости подготовки тестовой базы данных, поскольку модель данных изменилась):
$ bundle exec rake db:migrate
$ bundle exec rake db:test:prepare
В результате получилась модель Micropost со структурой, показанной в Рис. 11.1.
micropost_model
Рисунок 11.1: Модель данных Micropost.

Доступные атрибуты

Прежде чем конкретизировать модель Micropost, важно в первую очередь применить attr_accessible для указания атрибутов, редактируемых посредством веб. Как обсуждалось в Разделе 6.1.2.2 и Разделе 10.4.1.1, неопределение доступных атрибутов означает что кто-нибудь может легко изменить любой аспект объекта микросоообщений используя клиент командной строки для выдачи вредоносного запроса. Например, злоумышленник может изменить user_id атрибуты на микросообщениях, тем самым связав микросообщения с неправильным пользователем.
В случае модели Micropost, есть только один атрибут, который необходимо редактировать через веб, а именно, атрибут content (Листинг 11.2).
Листинг 11.2. Открытие доступа к атрибуту contentтолько к content атрибуту).
app/models/micropost.rb
class Micropost < ActiveRecord::Base
  attr_accessible :content
end
Так как user_id не внесен как attr_accessible параметр, он не может быть редактирован через веб, поэтому user_id параметр при массовом назначении, таком как
Micropost.new(:content => "foo bar", :user_id => 17)
будет просто игнорироваться.
Декларация attr_accessible в Листинге 11.2 необходима для безопасности сайта, но она представляет проблему для дефолтного Micropost model spec (Листинг 11.3).
Листинг 11.3. Начальный Micropost spec.
spec/models/micropost_spec.rb
require 'spec_helper'

describe Micropost do

  before(:each) do
    @attr = {
      :content => "value for content",
      :user_id => 1
    }
  end

  it "should create a new instance given valid attributes" do
    Micropost.create!(@attr)
  end
end
Этот тест в настоящее время проходит, но есть в нем что-то неладное. (Попробуйте понять, что именно, прежде чем продолжать)
Проблема в том, что before(:each) блок в Листинге 11.3 назначает id пользователя через массовое назначение, собственно, что attr_accessible и предотвращает; в частности, как отмечалось выше, :user_id => 1 часть хэша инициализации просто игнорируется. Решением заключается в избегании использования непосредственно Micropost.new; вместо этого, мы будем создавать микросообщения через их ассоциацию с моделью User, которая устанавливает id пользователя автоматически. Достижение этого является задачей следующего раздела.

11.1.2 Ассоциации Пользователь/Микросообщения

Цель этого раздела заключается в установлении ассоциации (связи) между моделями Micropost и User — отношений, кратко рассмотренных в Разделе 2.3.3 и показанных схематически в Рис. 11.2 и Рис. 11.3. По пути, мы будем писать тесты для модели Micropost которые, в отличие от тестов в Листинге 11.3, будут совместимы с attr_accessible из Листинга 11.2.
micropost_belongs_to_user
Рисунок 11.2: belongs_to взаимоотношение между микросообщением и создавшим его пользователем. (полный размер)
user_has_many_microposts
Рисунок 11.3: has_many отношение между пользователем и его микросообщениями. (полный размер)
Мы начнем с ассоциации модели Micropost. Во-первых, мы хотим повторить Micropost.create! тест показанный в Листинге 11.3 без несостоятельного массового назначения. Во-вторых, мы видим из Рис. 11.2 что объект micropost должен иметь метод user. Наконец, micropost.user должен быть пользователем, соответствующим user_id микросообщений. Мы можем выразить эти требования в RSpec кодом Листинга 11.4.
Листинг 11.4. Тесты для ассоциации микросообщений пользователя.
spec/models/micropost_spec.rb
require 'spec_helper'

describe Micropost do

  before(:each) do
    @user = Factory(:user)
    @attr = { :content => "value for content" }
  end

  it "should create a new instance given valid attributes" do
    @user.microposts.create!(@attr)
  end

  describe "user associations" do

    before(:each) do
      @micropost = @user.microposts.create(@attr)
    end

    it "should have a user attribute" do
      @micropost.should respond_to(:user)
    end

    it "should have the right associated user" do
      @micropost.user_id.should == @user.id
      @micropost.user.should == @user
    end
  end
end
Заметим, что вместо использования Micropost.create или Micropost.create! для создания микросообщения, Листинг 11.4 использует
@user.microposts.create(@attr)
и
@user.microposts.create!(@attr)
Эта схема является каноническим способом создания микросообщения через его ассоциацию с пользователем. (Мы используем "фабричного" пользователя так как это тесты для модели Micropost, а не для модели User.) При создании этим способом, объект микросообщение автоматически имеет user_id с правильно установленным значением, что устраняет проблему, отмеченную в Разделе 11.1.1.1. В частности, код
  before(:each) do
    @attr = {
      :content => "value for content",
      :user_id => 1
    }
  end

  it "should create a new instance given valid attributes" do
    Micropost.create!(@attr)
  end
из Листинга 11.3 является дефектным, так как :user_id => 1 ничего не делает, когда user_id не является одним из доступных атрибутов модели Micropost. Пройдя через ассоциацию пользователя, с другой стороны, код
it "should create a new instance given valid attributes" do
  @user.microposts.create!(@attr)
end
из Листинга 11.4 имеет правильный user_id по своей конструкции.
Эти специальные create методы пока не будут работать; они требуют надлежащей has_many ассоциации в модели User. Мы отложим более детализированные тесты для этой ассоциации до Раздела 11.1.3; на данный момент мы просто протестируем на наличие атрибута microposts (Листинг 11.5).
Листинг 11.5. Тест для атрибута пользовательских microposts.
spec/models/user_spec.rb
require 'spec_helper'

describe User do
  .
  .
  .
  describe "micropost associations" do

    before(:each) do
      @user = User.create(@attr)
    end

    it "should have a microposts attribute" do
      @user.should respond_to(:microposts)
    end
  end
end
Мы можем получить прохождение тестов из Листинга 11.4 и Листинга 11.5 используя belongs_to/has_many ассоциацию, проиллюстрированную в Рис. 11.2 и Рис. 11.3, как показано в Листинге 11.6 и Листинге 11.7.
Листинг 11.6. Микросообщения belongs_to (принадлежат) пользователю.
app/models/micropost.rb
class Micropost < ActiveRecord::Base
  attr_accessible :content

  belongs_to :user
end
Листинг 11.7. Пользователь has_many (имеет много) микросообщений.
app/models/user.rb
class User < ActiveRecord::Base
  attr_accessor :password
  attr_accessible :name, :email, :password, :password_confirmation

  has_many :microposts
  .
  .
  .
end
С помощью этой belongs_to/has_many ассоциации, Rails строит методы, показанные в Таблице 11.1. Вы можете сравнить записи в Таблице 11.1 с кодом в Листинге 11.4 и Листинге 11.5 чтобы убедиться, что вы понимаете основные свойства ассоциаций. (В Таблице 11.1 есть один метод, который мы прежде не использовали — метод build; ему найдется хорошее применение в Разделе 11.1.4 и особенно в Разделе 11.3.2.)
МетодНазначение
micropost.userВозвращает объект User связанный с микросообщением.
user.micropostsВозвращает массив микросообщений пользователя.
user.microposts.create(arg)Создает микросообщение (user_id = user.id).
user.microposts.create!(arg)Создает микросообщение (с исключением в случае неудачи).
user.microposts.build(arg)Возвращает объект new Micropost (user_id = user.id).
Таблица 11.1: Резюме методов ассоциации user/micropost.

11.1.3 Улучшение микросообщений

Тесты has_many ассоциации в Листинге 11.5 мало чего тестируют — они просто проверяют существование атрибута microposts. В этом разделе мы добавим упорядочивание и зависимость к микросообщениям, а также протестируем что user.microposts метод действительно возвращает массив микросообщений.
Нам нужно будет построить несколько микросообщений в User model spec, что означает, что мы должны сделать фабрику микросообщений в этой точке. Для этого нам нужен способ для создания ассоциации в Factory Girl. К счастью, это легко — мы просто используем Factory Girl метод micropost.association, как показано в Листинге 11.8.3
Листинг 11.8. Полный файл фабрики, включающий новую фабрику для микросообщений.
spec/factories.rb
# By using the symbol ':user', we get Factory Girl to simulate the User model.
Factory.define :user do |user|
  user.name                  "Michael Hartl"
  user.email                 "mhartl@example.com"
  user.password              "foobar"
  user.password_confirmation "foobar"
end

Factory.sequence :email do |n|
  "person-#{n}@example.com"
end

Factory.define :micropost do |micropost|
  micropost.content "Foo bar"
  micropost.association :user
end

Дефолтное упорядочивание

Мы можем поместить фабрику в тест упорядочивания микросообщений. По умолчанию, использование user.microposts для вытягивания пользовательских микросообщений из базы данных не дает никаких гарантий сохранения порядка микросообщений, но мы хотим (следуя конвенции блогов и Twitter), чтобы микросообщения выдавались в обратном хронологическом порядке, т.е. последнее созданное сообщение должно быть первым в списке. Для проверки этого порядка мы сначала создаем пару микросообщений следующим образом:
  @mp1 = Factory(:micropost, :user => @user, :created_at => 1.day.ago)
  @mp2 = Factory(:micropost, :user => @user, :created_at => 1.hour.ago)
Здесь мы указываем, что второй пост был создан совсем недавно, 1.hour.ago (один.час.назад), а второй пост был создан 1.day.ago (один.день.назад). Обратите внимание, насколько удобна Factory Girl в использовании: мы можем не только назначать пользователя используя массовое назначение (так как фабрики пренебрегают attr_accessible), мы также можем установить created_at вручную, чего не позволяет делать Active Record.4
Большинство адаптеров баз данных (в том числе адаптер SQLite) возвращает микросообщения в порядке их id, поэтому мы можем организовать начальные тесты, которые почти наверняка провалятся, используя код в Листинге 11.9.
Листинг 11.9. Тестирование порядка пользовательских микросообщений.
spec/models/user_spec.rb
require 'spec_helper'

describe User do
  .
  .
  .
  describe "micropost associations" do

    before(:each) do
      @user = User.create(@attr)
      @mp1 = Factory(:micropost, :user => @user, :created_at => 1.day.ago)
      @mp2 = Factory(:micropost, :user => @user, :created_at => 1.hour.ago)
    end

    it "should have a microposts attribute" do
      @user.should respond_to(:microposts)
    end

    it "should have the right microposts in the right order" do
      @user.microposts.should == [@mp2, @mp1]
    end
  end
end
Ключевой строкой здесь является
@user.microposts.should == [@mp2, @mp1]
указывающая, что сообщения должны быть упорядочены таким образом, чтобы новейшее сообщение было первым. Этот тест должен быть провальным, так как по умолчанию сообщения будут упорядочены по id, т.е., [@mp1, @mp2]. Эти тесты также тестируют базовую корректность самой has_many ассоциации, проверяя (как указано в Таблице 11.1) что user.microposts является массивом микросообщений.
Для того чтобы получить прохождение тестов упорядоченности, мы используем Rails средство default_scope с параметром :order, как показано в Листинге 11.10. (Это наш первый пример понятия пространства. Мы узнаем о пространстве в более общем контексте в Главе 12.)
Листинг 11.10. Упорядочивание микросообщений с default_scope.
app/models/micropost.rb
class Micropost < ActiveRecord::Base
  .
  .
  .
  default_scope :order => 'microposts.created_at DESC'
end
За порядок здесь отвечает ’microposts.created_at DESC’, где DESC это SQL для “по убыванию”, т.е., в порядке убывания от новых к старым.

Dependent: destroy

Помимо правильного упорядочивания, есть второе уточнение, которое мы хотели бы добавить в микросообщения. Напомним из Раздела 10.4 что администраторы сайта имеют право уничтожать пользователей. Само собой разумеется, что если пользователь уничтожен, то должны быть уничтожены и его микросообщения. Мы можем протестировать это вначале уничтожив пользователя, а затем проверив, что связанных с ним микросообщений больше нет в базе данных (Листинг 11.11).
Листинг 11.11. Тестирование того, что микросообщения уничтожаются вместе с пользователями.
spec/models/user_spec.rb
describe User do
  .
  .
  .
  describe "micropost associations" do

    before(:each) do
      @user = User.create(@attr)
      @mp1 = Factory(:micropost, :user => @user, :created_at => 1.day.ago)
      @mp2 = Factory(:micropost, :user => @user, :created_at => 1.hour.ago)
    end
    .
    .
    .
    it "should destroy associated microposts" do
      @user.destroy
      [@mp1, @mp2].each do |micropost|
        Micropost.find_by_id(micropost.id).should be_nil
      end
    end
  end
  .
  .
  .
end
Здесь мы использовали Micropost.find_by_id, который возвращает nil если запись не найдена, в то время как Micropost.find вызывает исключение в случае возникновения ошибки, что немного сложнее для проверки. (В случае, если вам интересно, то
lambda do
  Micropost.find(micropost.id)
end.should raise_error(ActiveRecord::RecordNotFound)
проделывает этот трюк.)
Код приложения, необходимый для прохождения тестов из Листинга 11.11 короче чем одна строка; в самом деле, это всего лишь опция метода ассоциации has_many, как показано в Листинге 11.12.
Листинг 11.12. Обеспечение уничтожения микросообщений пользователя вместе с пользователем.
app/models/user.rb
class User < ActiveRecord::Base
  .
  .
  .
  has_many :microposts, :dependent => :destroy
  .
  .
  .
end
В этом окончательном виде ассоциация пользователь/микросообщения готова к использованию.

11.1.4 Валидации микросообщений

Прежде чем покинуть модель Micropost, мы наведем в ней окончательный лоск, добавив валидации (следуя примеру из Раздела 2.3.2). И user_id и content атрибуты должны существовать, а content в дальнейшем вынужден быть не длиннее чем 140 знаков, что мы можем протестировать, используя код в Листинге 11.13.
Листинг 11.13. Тесты для валидаций модели Micropost.
spec/models/micropost_spec.rb
require 'spec_helper'

describe Micropost do

  before(:each) do
    @user = Factory(:user)
    @attr = { :content => "value for content" }
  end
  .
  .
  .
  describe "validations" do

    it "should require a user id" do
      Micropost.new(@attr).should_not be_valid
    end

    it "should require nonblank content" do
      @user.microposts.build(:content => "  ").should_not be_valid
    end

    it "should reject long content" do
      @user.microposts.build(:content => "a" * 141).should_not be_valid
    end
  end
end
Этот код в основном следует примерам из теста валидаций модели User из Раздела 6.2. (аналогичные тесты были разбиты на несколько строк в том разделе, но вы уже должны достаточно комфортно переваривать показанную выше более компактную формулировку кода RSpec.)
Как и в Разделе 6.2, код в Листинге 11.13 использует мультипликацию строки для тестирования валидации длины микросообщения:
$ rails console
>> "a" * 10
=> "aaaaaaaaaa"
>> "a" * 141
=> "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
В противоположность этому, вместо использования дефолтного new конструктора как в
User.new(...)
код в Листинге 11.13 использует метод build:
@user.microposts.build
Вспомните из Таблицы 11.1, что это по существу эквивалентно Micropost.new, кроме того, что он автоматически устанавливает user_id к @user.id.
Сами валидации являются прямыми аналогами валидаций модели User, как видно из Листинга 11.14.
Листинг 11.14. Валидации модели Micropost.
app/models/micropost.rb
class Micropost < ActiveRecord::Base
  attr_accessible :content

  belongs_to :user

  validates :content, :presence => true, :length => { :maximum => 140 }
  validates :user_id, :presence => true

  default_scope :order => 'microposts.created_at DESC'
end
Это завершает моделирование данных для пользователей и микросообщений. Пришло время для создания веб-интерфейса.

11.2 Просмотр микросообщений

Хотя у нас еще нет способа создания микросообщений через веб — он появится лишь в Разделе 11.3.2 — это не остановит нас от их отображения (и тестирования этого отображения). Следуя по стопам Twitter мы запланируем отображение микросообщений пользователя не на отдельной странице microposts index, а непосредственно на самой странице user show, как показано на макете в Рис. 11.4. Мы начнем с довольно простых ERb шаблонов для добавления отображения микросообщений в профиле пользователя, а затем мы добавим микросообщения в заполнителя образцов данных из Раздела 10.3.2 чтобы у нас было что отображать.
user_microposts_mockup
Рисунок 11.4: Макет страницы профиля с микросообщениями.  (полный размер)
Как и в случае обсуждения машинерии входа в Разделе 9.3.2, Раздел 11.2.1 будет часто отправлять несколько элементов в стек на время, а затем выталкивать их оттуда один за другим. Если вы начнете увязать, будте терпеливы; у Раздела 11.2.2 хорошая развязка.

11.2.1 Дополнение страницы показывающей пользователя

Начнем с теста для отображения микросообщений пользователя. Мы работаем в Users controller spec, так как контроллер Users содержит действие user show. Наша стратегия заключается в создании пары "фабричных" микросообщений, связанных с пользователем, а затем проверке, что страница show имеет тег span с CSS классом "content" содержащим текст каждого микросообщения. Результирующий RSpec пример представлен в Листинге 11.15.
Листинг 11.15. Тесты для отображения микросообщений на странице user show.
spec/controllers/users_controller_spec.rb
require 'spec_helper'

describe UsersController do
  render_views
  .
  .
  .
  describe "GET 'show'" do

    before(:each) do
      @user = Factory(:user)
    end
    .
    .
    .
    it "should show the user's microposts" do
      mp1 = Factory(:micropost, :user => @user, :content => "Foo bar")
      mp2 = Factory(:micropost, :user => @user, :content => "Baz quux")
      get :show, :id => @user
      response.should have_selector("span.content", :content => mp1.content)
      response.should have_selector("span.content", :content => mp2.content)
    end
  end
  .
  .
  .
end
Хотя эти тесты не пройдут до Листинга 11.17, мы начнем работу с кодом приложения, добавив таблицу микросообщений в страницу профиля пользователя, как показано в Листинге 11.16.5
Листинг 11.16. Добавление микросообщений в страницу user show.
app/views/users/show.html.erb
<table class="profile">
  <tr>
    <td class="main">
      .
      .
      .
      <% unless @user.microposts.empty? %>
        <table class="microposts" summary="User microposts">
          <%= render @microposts %>
        </table>
        <%= will_paginate @microposts %>
      <% end %>
    </td>
    <td class="sidebar round">
      <strong>Name</strong> <%= @user.name %><br />
      <strong>URL</strong> <%= link_to user_path(@user), @user %><br />
      <strong>Microposts</strong> <%= @user.microposts.count %>
    </td>
  </tr>
</table>
Мы займемся таблицей table через мгновение, но есть несколько других вещей, которые необходимо отметить в первую очередь. Одна из идей заключается в использовании empty? в строке
@user.microposts.empty?
Такое применение метода empty?, мы видели прежде в контексте строк (например, Раздел 4.2.3), к массиву:
$ rails console
>> [1, 2].empty?
=> false
>> [].empty?
=> true
Используя условный оператор unless,
<% unless @user.microposts.empty? %>
мы можем быть уверены, что пустая таблица не будет отображаться, когда у пользователя нет микросообщений.
Вы также могли отметить в Листинге 11.16 что мы превентивно добавили пагинацию для микросообщений.
<%= will_paginate @microposts %>
Если вы сравните это с аналогичной строкой на странице списка пользователей, Листинг 10.27, вы увидите, что прежде мы имели просто
<%= will_paginate %>
Это работало, потому что, в контексте контроллера Users, will_paginate предполагает существование переменной экземпляра @users (которая, как мы видели в Разделе 10.3.3, должна принадлежать классу WillPaginate::Collection). В данном случае, поскольку мы все еще в контроллере Users, но хотим пагинировать микросообщения, а не пользователей, мы явно передаем @microposts переменную в will_paginate. Конечно, это означает, что мы должны будем определить такую переменную в user show действии (Листинг 11.18 below).
Наконец, отметим, что мы воспользовались этой возможностью, чтобы добавить графу текущего количества микросообщений к сайдбару профиля:
<td class="sidebar round">
  <strong>Name</strong> <%= @user.name %><br />
  <strong>URL</strong> <%= link_to user_path(@user), @user %><br />
  <strong>Microposts</strong> <%= @user.microposts.count %>
</td>
Здесь @user.microposts.count является аналогом метода User.count, за исключением того, что он рассчитывает количество микросообщений, принадлежащих данному пользователю через ассоциацию пользователь/микросообщение.6
Теперь о самой microposts table:
<table class="microposts" summary="User microposts">
  <%= render @microposts %>
</table>
Этот код отвечает за генерацию таблицы микросообщений, но вы можете видеть, что он просто перекладывает тяжелую работу на партиал micropost. Мы видели в Разделе 10.3.4 что код
<%= render @users %>
автоматически рендерит каждого пользователя из переменной @users, используя партиал _user.html.erb. Аналогичным образом, код
<%= render @microposts %>
делает то же самое для микросообщений. Это означает, что мы должны определить партиал _micropost.html.erb (наряду с директорией представлений micropost), как показано в Листинге 11.17.
Листинг 11.17. Партиал для отображения отдельно взятого микросообщения.
app/views/microposts/_micropost.html.erb
<tr>
  <td class="micropost">
    <span class="content"><%= micropost.content %></span>
    <span class="timestamp">
      Posted <%= time_ago_in_words(micropost.created_at) %> ago.
    </span>
  </td>
</tr>
При этом используется удивительный вспомогательный метод time_ago_in_words, чей эффект мы увидим в Разделе 11.2.2.
До сих пор, несмотря на определение всех соответствующих ERb шаблонов, тесты в Листинг 11.15 Листинге 11.15 должны быть провальными за неимением переменной @microposts. Мы можем заставить их пройти с кодом из Листинга 11.18.
Листинг 11.18. Добавление переменной экземпляра @microposts в user show действие.
app/controllers/users_controller.rb
class UsersController < ApplicationController
  .
  .
  .
  def show
    @user = User.find(params[:id])
    @microposts = @user.microposts.paginate(:page => params[:page])
    @title = @user.name
  end
end
Обратим здесь внимание, насколько умен paginate — он работает даже с ассоциацией микросообщений, конвертируя массив в объект WillPaginate::Collection на лету.
После добавления CSS из Листинга 11.19 к нашему custom.css файлу,7 мы можем взглянуть на нашу новую страницу профиля пользователя в Рис. 11.5. Это довольно… печально. Конечно, это связано с тем, что в настоящее время у нас нет микросообщений. Пришло время изменить это.
Листинг 11.19. CSS для микросообщений (включает в себя все CSS для этой главы).
public/stylesheets/custom.css
.
.
.
h1.micropost {
  margin-bottom: 0.3em;
}

table.microposts {
  margin-top: 1em;
}

table.microposts tr {
  height: 70px;
}

table.microposts tr td.gravatar {
  border-top: 1px solid #ccc;
  vertical-align: top;
  width: 50px;
}

table.microposts tr td.micropost {
  border-top: 1px solid #ccc;
  vertical-align: top;
  padding-top: 10px;
}

table.microposts tr td.micropost span.timestamp {
  display: block;
  font-size: 85%;
  color: #666;
}

div.user_info img {
  padding-right: 0.1em;
}

div.user_info a {
  text-decoration: none;
}

div.user_info span.user_name {
  position: absolute;
}

div.user_info span.microposts {
  font-size: 80%;
}

form.new_micropost {
  margin-bottom: 2em;
}

form.new_micropost textarea {
  height: 4em;
  margin-bottom: 0;
}
user_profile_no_microposts
Рисунок 11.5: Страница профиля пользователя с кодом для микросообщений, но без микросообщений. (полный размер)

11.2.2 Образцы микросообщений

При всей проделанной работе по созданию шаблонов для микросообщений пользователя в Разделе 11.2.1, конец был довольно разочаровывающим. Мы можем исправить эту печальную ситуацию, добавив микросообщения во вноситель образцов данных из Раздела 10.3.2. Добавление образцов микросообщений для всех пользователей займет довольно много времени, поэтому сначала мы выберем только первые шесть пользователей8 используя :limit опцию метода User.all:9
User.all(:limit => 6)
Затем мы сделаем 50 микросообщений для каждого пользователя (достаточно, для переполнения лимита пагинации, равного 30), сгенерируем образец содержимого для каждого микросообщения, используя удобный метод Lorem.sentence гема Faker. (Faker::Lorem.sentence возвращает текст lorem ipsum; как отмечалось в Главе 6, lorem ipsum имеет увлекательную предысторию.) Результирующий новый вноситель образцов данных показан в Листинге 11.20.
Листинг 11.20. Добавление микросообщений к образцам данных.
lib/tasks/sample_data.rake
namespace :db do

  desc "Fill database with sample data"
  task :populate => :environment do
    .
    .
    .
    User.all(:limit => 6).each do |user|
      50.times do
        user.microposts.create!(:content => Faker::Lorem.sentence(5))
      end
    end
  end
end
Конечно, для генерации новых образцов данных мы должны запустить Рейк задачу db:populate:
$ bundle exec rake db:populate
Теперь мы в состоянии воспользоваться плодами наших трудов в Разделе 11.2.1 отображая информацию для каждого микросообщеня.10 Рис. 11.6 показывает страницу профиля первого (вошедшего) пользователя, а Рис. 11.7 показывает профиль второго пользователя. Наконец, Рис. 11.8 показывает вторую страницу микросообщений первого пользователя, наряду с пагинационными ссылками в нижней части отображения. Заметим, что, во всех трех случаях, в каждом отображении микросообщения указывается время когда оно было создано (например, “Posted 1 minute ago.”); Это работа метода time_ago_in_words из Листинга 11.17. Если вы подождете пару минут и перезагрузите страницу, вы увидите, как текст автоматически обновится в соответствии с новым временем.
user_profile_with_microposts
Рисунок 11.6: Профиль пользователя (/users/1) с микросообщениями. (полный размер)
other_profile_with_microposts
Рисунок 11.7: Профиль другого пользователя, тоже с микросообщениями (/users/3). (полный размер)
user_profile_microposts_page_2_rails_3
Рисунок 11.8: Вторая страница профиля с микросообщениями, с пагинационными ссылками (/users/1?page=2). (полный размер)

11.3 Манипулирование микросообщениями

Закончив моделирование данных и шаблоны для отображения микросообщений, сейчас мы обратим наше внимание на интерфейс для их создания через веб. Результатом будет наш третий пример использования формы HTML, для создания ресурса — в данном случае, ресурса Microposts.11 В этом разделе мы также увидим первый намек на status feed — понятие, полной реализацией которого мы займемся в Главе 12. Наконец, как и с пользователями, мы сделаем возможным уничтожение микросообщений через веб.
Существует один разрыв с предыдущими соглашениями, который стоит отметить: интерфейс ресурса Microposts будет работать главным образом за счет контроллеров Users и Pages, а не полагаться на собственный контроллер. Это означает, что маршруты для ресурса Microposts необычайно просты, как показано в Листинге 11.21. Код в Листинге 11.21 в свою очередь приводит к RESTful маршрутам показанным в Таблице 11.2, которые являются сокращенным вариантом полного набора маршрутов виденного в Таблице 2.3. Конечно, эта простота является признаком того, что они более продвинутые — мы прошли долгий путь со времени нашей зависимости от scaffolding в Главе 2, и нам более не нужна бОльшая часть их сложности.
Листинг 11.21. Маршруты для ресурса Microposts.
config/routes.rb
SampleApp::Application.routes.draw do
  resources :users
  resources :sessions,   :only => [:new, :create, :destroy]
  resources :microposts, :only => [:create, :destroy]
  .
  .
  .
end
HTTP запросURLДействиеЦель
POST/micropostscreateсоздание нового микросообщения
DELETE/microposts/1destroyудаление микросообщения с id 1
Таблица 11.2: RESTful маршруты обеспеченные ресурсом Microposts в Листинге 11.21.

11.3.1 Контроль доступа

Мы начнем нашу разработку ресурса Microposts с контроля доступа в контроллере Microposts. Идея проста: как create так и destroy действия должны требовать чтобы пользователи вошли в систему. Код RSpec для тестирования этого представлен в Листинге 11.22, что потребует создания файла Microposts controller spec. (Мы протестируем и добавим третью защиту — обеспечение того, что только пользователь, создавший микросообщение может удалить его — в Разделе 11.3.4.)
Листинг 11.22. Тесты контроля доступа для контроллера Microposts.
spec/controllers/microposts_controller_spec.rb
require 'spec_helper'

describe MicropostsController do
  render_views

  describe "access control" do

    it "should deny access to 'create'" do
      post :create
      response.should redirect_to(signin_path)
    end

    it "should deny access to 'destroy'" do
      delete :destroy, :id => 1
      response.should redirect_to(signin_path)
    end
  end
end
Прежде чем начать писать код приложения, необходимый для прохождения тестов из Листинга 11.22 требуется произвести небольшой рефакторинг. Вспомним из Раздела 10.2.1 что мы внедрили требование входа используя предфильтр который назывался authenticate метод (Листинг 10.11). В то время authenticate был необходим только в Users контроллере, но теперь мы обнаружили, что он нам также необходим и в контроллере Microposts, так что мы переместим authenticate в Sessions хелпер, как показано в Листинге 11.23.12
Листинг 11.23. Перемещение authenticate метода в Sessions хелпер.
app/helpers/sessions_helper.rb
module SessionsHelper
  .
  .
  .
  def authenticate
    deny_access unless signed_in?
  end

  def deny_access
    store_location
    redirect_to signin_path, :notice => "Please sign in to access this page."
  end
  .
  .
  .
end
(Чтобы избежать повторения кода, вы сейчас должны также удалить authenticate из контроллера Users.)
С кодом в Листинге 11.23, authenticate метод теперь доступен в контроллере Microposts, что означает, что мы можем ограничить доступ к create и destroy действиям с предфильтром показанным в Листинге 11.24. (Так как мы не генерировали его через командную строку, вам следует создать контроллер Microposts вручную.)
Листинг 11.24. Добавление аутентификации в действия контроллера Microposts.
app/controllers/microposts_controller.rb
class MicropostsController < ApplicationController
  before_filter :authenticate

  def create
  end

  def destroy
  end
end
Обратите внимание, что мы не ограничили действия к которым применяется предфильтр, поскольку он в настоящее время должен применяться ко всем действиям контроллера. Если бы мы добавили, скажем, index действие, доступное даже для не вошедших пользователей, мы должны были бы указать защищаемые действия в явном виде:
class MicropostsController < ApplicationController
  before_filter :authenticate, :only => [:create, :destroy]

  def create
  end

  def destroy
  end
end

11.3.2 Создание микросообщений

В Главе 8, мы реализовали регистрацию пользователей, сделав HTML форму которая выдавала HTTP запрос POST в create действие контроллера Users. Реализация создания микросообщения аналогична; основное отличие заключается в том, что вместо использования отдельной страницы с адресом /microposts/new, мы (следуя Twitter конвенции) поместим форму на самой Home странице (т.е., root path /), как показано на макете в Рис. 11.9.
home_page_with_micropost_form_mockup
Рисунок 11.9: Макет Home страницы с формой для создания микросообщений. (полный размер)
Когда мы последний раз видели Home страницу, она выглядела как на Рис. 5.7 — то есть, у нее была большая жирная “Sign up now!” кнопка посередине. Так как форма для создания микросообщения имеет смысл только в контексте конкретного, вошедшего в систему пользователя, одной из целей данного раздела будет предоставление различных версий Home страницы в зависимости от статуса посетителя. Мы осуществим это в Листинге 11.27 ниже, а пока только подразумевается что тесты для create действия контроллера Microposts должны обеспечить вход ("фабричного") пользователя в систему прежде чем пытаться создать сообщение.
Держа в уме эту оговорку, тесты для создания микросообщений параллельны аналогичным тестам для создания пользователя из Листинга 8.6 и Листинга 8.14; результаты представлены в Листинге 11.25.
Листинг 11.25. Тесты для create действия контроллера Microposts.
spec/controllers/microposts_controller_spec.rb
require 'spec_helper'

describe MicropostsController do
  .
  .
  .
  describe "POST 'create'" do

    before(:each) do
      @user = test_sign_in(Factory(:user))
    end

    describe "failure" do

      before(:each) do
        @attr = { :content => "" }
      end

      it "should not create a micropost" do
        lambda do
          post :create, :micropost => @attr
        end.should_not change(Micropost, :count)
      end

      it "should render the home page" do
        post :create, :micropost => @attr
        response.should render_template('pages/home')
      end
    end

    describe "success" do

      before(:each) do
        @attr = { :content => "Lorem ipsum" }
      end

      it "should create a micropost" do
        lambda do
          post :create, :micropost => @attr
        end.should change(Micropost, :count).by(1)
      end

      it "should redirect to the home page" do
        post :create, :micropost => @attr
        response.should redirect_to(root_path)
      end

      it "should have a flash message" do
        post :create, :micropost => @attr
        flash[:success].should =~ /micropost created/i
      end
    end
  end
end
Действие create микросообщений похоже на его user аналог (Листинг 8.15); принципиальное отличие заключается в использовании пользователь/микросообщение ассоциации для build нового микрособщения, как видно в Листинге 11.26.
Листинг 11.26. Действие create контроллера Microposts.
app/controllers/microposts_controller.rb
class MicropostsController < ApplicationController
  .
  .
  .
  def create
    @micropost  = current_user.microposts.build(params[:micropost])
    if @micropost.save
      flash[:success] = "Micropost created!"
      redirect_to root_path
    else
      render 'pages/home'
    end
  end
  .
  .
  .
end
В этой точке все тесты из Листинга 11.25 должны проходить, но, конечно, у нас все еще нет формы для создания микросообщений. Мы можем исправить это с кодом из Листинга 11.27, который предоставляет различный HTML в зависимости от того, вошел ли посетитель в систему.
Листинг 11.27. Добавление создания микросообщений к Home странице (/).
app/views/pages/home.html.erb
<% if signed_in? %>
  <table class="front" summary="For signed-in users">
    <tr>
      <td class="main">
        <h1 class="micropost">What's up?</h1>
        <%= render 'shared/micropost_form' %>
      </td>
      <td class="sidebar round">
        <%= render 'shared/user_info' %>
      </td>
    </tr>
  </table>
<% else %>
  <h1>Sample App</h1>

  <p>
    This is the home page for the
    <a href="http://railstutorial.org/">Ruby on Rails Tutorial</a>
    sample application.
  </p>

  <%= link_to "Sign up now!", signup_path, :class => "signup_button round" %>
<% end %>
Наличие большого количества кода в каждой ветке условного оператора if-else это немного грязно, и его очистка с использованием партиалов остается в качестве упражнения (Раздел 11.5). Однако заполнение необходимых партиалов из Листинг 11.27 не является упражнением; мы заполним партиал формы микросообщений в Листинге 11.28 и сайдбар новой Home страницы в Листинге 11.29.
Листинг 11.28. Партиал формы для создания микросообщений.
app/views/shared/_micropost_form.html.erb
<%= form_for @micropost do |f| %>
  <%= render 'shared/error_messages', :object => f.object %>
  <div class="field">
    <%= f.text_area :content %>
  </div>
  <div class="actions">
    <%= f.submit "Submit" %>
  </div>
<% end %>
Листинг 11.29. Партиал для сайдбара с информацией о пользователе.
app/views/shared/_user_info.html.erb
<div class="user_info">
  <a href="<%= user_path(current_user) %>">
    <%= gravatar_for current_user, :size => 30 %>
    <span class="user_name">
      <%= current_user.name %>
    </span>
    <span class="microposts">
      <%= pluralize(current_user.microposts.count, "micropost") %>
    </span>
  </a>
</div>
Отметим, что, как и в сайдбаре профиля (Листинг 11.16), информация о пользователе в Листинге 11.29 отображает общее число микросообщений пользователя. Хотя есть небольшое отличие; в сайдбаре профиля, Microposts это метка, и отображаемое Microposts 1 имет смысл. Однако в данном случае выражение “1 microposts” является безграмотным, поэтому мы организуем отображение “1 micropost” (но “2 microposts”) используя удобный вспомогателный метод pluralize.
Форма, определенная в Листинге 11.28 является точным аналогом регистрационной формы из Листинга 8.2, что означает, что ей необходима переменная экземпляра @micropost. Она поставляется в Листинге 11.30 — но только когда пользователь вошел в систему.
Листинг 11.30. Добавление переменной экземпляра micropost в home действие.
app/controllers/pages_controller.rb
class PagesController < ApplicationController

  def home
    @title = "Home"
    @micropost = Micropost.new if signed_in?
  end
  .
  .
  .
end
Теперь HTML должен рендериться правильно, показывая форму как на Рис. 11.10, и форму с ошибкой отправки как на Рис. 11.11. Приглашаю вас создать свое микросообщение и убедиться, что все работает — но вам, вероятно, все же следует повременить с этим делом до Раздела 11.3.3.
home_with_form
Рисунок 11.10: Home страница (/) с формой для создания нового микросообщения. (полный размер)
home_form_errors
Рисунок 11.11: Home страница с ошибками формы. (полный размер)

11.3.3 Предварительная реализация потока сообщений

Комментарий в конце Раздела 11.3.2 намекает на проблему: текущая Home страница не отображает микросообщений. Если вы хотите, вы можете убедиться что форма показанная на Рис. 11.10 работает, введя допустимое микросообщение и затем перейдя на страницу профиля чтобы увидеть сообщение, но это довольно громоздко. Было бы гораздо лучше иметь feed (поток, канал) микросообщений, который включал бы в себя микросообщения пользователя, как показано на макете в Рис. 11.12. (В Главе 12, мы обобщим этот канал (поток) включив микросообщения пользователей за которыми следит текущий пользователь.)
proto_feed_mockup
Рисунок 11.12: Макет Home страницы с предварительной реализацией потока сообщений. (полный размер)
Так как каждый пользователь должен иметь поток сообщений, мы естественным образом пришли к feed методу в модели User. В конце концов, мы протестируем, что поток сообщений возвращает микросообщения пользователей, за которыми следит текущий пользователь, но пока мы просто протестируем, что feed метод включает в себя микросообщения текущего пользователя, но исключает сообщения других пользователей. Мы можем выразить эти требования в коде из Листинга 11.31.
Листинг 11.31. Тесты для предварительной реализацией потока сообщений.
spec/models/user_spec.rb
require 'spec_helper'

describe User do
  .
  .
  .
  describe "micropost associations" do

    before(:each) do
      @user = User.create(@attr)
      @mp1 = Factory(:micropost, :user => @user, :created_at => 1.day.ago)
      @mp2 = Factory(:micropost, :user => @user, :created_at => 1.hour.ago)
    end
    .
    .
    .
    describe "status feed" do

      it "should have a feed" do
        @user.should respond_to(:feed)
      end

      it "should include the user's microposts" do
        @user.feed.include?(@mp1).should be_true
        @user.feed.include?(@mp2).should be_true
      end

      it "should not include a different user's microposts" do
        mp3 = Factory(:micropost,
                      :user => Factory(:user, :email => Factory.next(:email)))
        @user.feed.include?(mp3).should be_false
      end
    end
  end
end
Эти тесты вводят метод массива include?, который просто проверяет что массив включает данный элемент:13
$ rails console
>> a = [1, "foo", :bar]
>> a.include?("foo")
=> true
>> a.include?(:bar)
=> true
>> a.include?("baz")
=> false
Мы можем организовать feed соответствующих микросообщений выбрав все микросообщения с user_id равным id текущего пользователя, чего мы можем достигнуть используя where метод на модели Micropost, как показано в Листинге 11.32.14
Листинг 11.32. Предварительная реализация ленты микросообщений.
app/models/user.rb
class User < ActiveRecord::Base
  .
  .
  .
  def feed
    # Это предварительное решение. См. полную реализацию в Главе 12.
    Micropost.where("user_id = ?", id)
  end
  .
  .
  .
end
Знак вопроса в
Micropost.where("user_id = ?", id)
гарантирует, что id корректно маскирован прежде чем быть включенным в лежащий в его основе SQL запрос, что позволит избежать серьезной дыры в безопасности называемой SQL инъекция. (id атрибут в данном случае просто целое число, так что в этом случае опасности нет, но постоянное маскирование переменных, вводимых в SQL выражение является хорошей привычкой.)
Внимательные читатели могли отметить в этой точке, что код в Листинге 11.32 по сути эквивалентен записи
def feed
  microposts
end
Мы использовали код Листинга 11.32 вместо нее так как он генерализует гораздо более естественным образом полную ленту микросообщений необходимую в Главе 12.
Чтобы использовать этот поток сообщений в примере приложения, мы добавим переменную экземпляра @feed_items для (пагинированного) потока сообщений текущего пользователя как в Листинге 11.33, а затем добавим партиал feed (Листинг 11.34) к Home странице (Листинг 11.36).
Листинг 11.33. Добавление переменной экземпляра feed к home действию.
app/controllers/pages_controller.rb
class PagesController < ApplicationController

  def home
    @title = "Home"
    if signed_in?
      @micropost = Micropost.new
      @feed_items = current_user.feed.paginate(:page => params[:page])
    end
  end
  .
  .
  .
end
Листинг 11.34. Партиал _feed.
app/views/shared/_feed.html.erb
<% unless @feed_items.empty? %>
  <table class="microposts" summary="User microposts">
    <%= render :partial => 'shared/feed_item', :collection => @feed_items %>
  </table>
  <%= will_paginate @feed_items %>
<% end %>
Партиал статуса потока сообщений перекладывает рендеринг элемента потока сообщений на партиал элемента потока сообщений с помощью кода
<%= render :partial => 'shared/feed_item', :collection => @feed_items %>
Здесь мы передаем параметр :collection с элементами потока сообщений, что заставляет render использовать данный партиал для (’feed_item’ в данном случае) рендеринга каждого элемента в коллекции. (Мы опустили :partial параметр в предыдущем рендеринге, написание, например, render ’shared/micropost’, но с :collection параметром этот синтаксис не работает.) Сам партиал элемента потока сообщений представлен в Листинге 11.35; обратите внимание на добавление удаляющей ссылки в партиал feed item, следуя примеру из Листинга 10.38.
Листинг 11.35. Партиал для отдельно взятого feed item.
app/views/shared/_feed_item.html.erb
<tr>
  <td class="gravatar">
    <%= link_to gravatar_for(feed_item.user), feed_item.user %>
  </td>
  <td class="micropost">
    <span class="user">
      <%= link_to feed_item.user.name, feed_item.user %>
    </span>
    <span class="content"><%= feed_item.content %></span>
    <span class="timestamp">
      Posted <%= time_ago_in_words(feed_item.created_at) %> ago.
    </span>
  </td>
  <% if current_user?(feed_item.user) %>
  <td>
    <%= link_to "delete", feed_item, :method => :delete,
                                     :confirm => "You sure?",
                                     :title => feed_item.content %>
  </td>
  <% end %>
</tr>
Затем мы можем добавить поток сообщений на Home страницу посредством рендеринга партиала feed как обычно (Листинг 11.36). В результате поток сообщений отображается на Home странице, как и требовалось (Рис. 11.13).
Листинг 11.36. Добавление ленты микросообщений к Home странице.
app/views/pages/home.html.erb
<% if signed_in? %>
  <table class="front" summary="For signed-in users">
    <tr>
      <td class="main">
        <h1 class="micropost">What's up?</h1>
        <%= render 'shared/micropost_form' %>
        <%= render 'shared/feed' %>
      </td>
      .
      .
      .
    </tr>
  </table>

<% else %>
  .
  .
  .
<% end %>
home_with_proto_feed
Рисунок 11.13: Home страница (/) с предварительной реализацией потока сообщений. (полный размер)
На данный момент, создание новых микросообщений работает как надо, что показано на Рис. 11.14. (Мы напишем интеграционный тест для этого эффекта в Разделе 11.3.5.) Однако есть одна тонкость: при неудачной отправке микросообщения, Home страница ожидает переменную экземпляра @feed_items, таким образом провальная отправка в настоящее время не работает (что вы можете проверить запустив ваш набор тестов). Самым простым решением будет полное подавление потока сообщений присвоением ему пустого массива, как показано в Листинге 11.37.15
micropost_created
Рисунок 11.14: Home страница после создания нового микросообщения. (полный размер)
Листинг 11.37. Добавление (пустой) @feed_items переменной экземпляра к create действию.
app/controllers/microposts_controller.rb
class MicropostsController < ApplicationController
  .
  .
  .
  def create
    @micropost = current_user.microposts.build(params[:micropost])
    if @micropost.save
      flash[:success] = "Micropost created!"
      redirect_to root_path
    else
      @feed_items = []
      render 'pages/home'
    end
  end
  .
  .
  .
end

11.3.4 Уничтожение микросообщений

Последний кусок функционала добавляемый к ресурсу Microposts это воможность уничтожения микросообщений. Как и с удалением пользователя (Раздел 10.4.2), мы будем делать это с помощью “delete” ссылок, как показано на макете в Рис. 11.15. В отличие от уничтожения пользователя, где право на удаление имели только администраторы, удаляющие ссылки будут работать только для пользователя, создавшего микросообщения.
micropost_delete_links_mockup
Рисунок 11.15: Макет предварительной реализации потока сообщений со ссылками на удаление микросообщений. (полный размер)
Нашим первым шагом является добавление удаляющей ссылки в партиал micropost как в Листинге 11.35. Результат представлен в Листинге 11.38.
Листинг 11.38. Партиал для отображения отдельно взятого микросообщения.
app/views/microposts/_micropost.html.erb
<tr>
  <td class="micropost">
    <span class="content"><%= micropost.content %></span>
    <span class="timestamp">
      Posted <%= time_ago_in_words(micropost.created_at) %> ago.
    </span>
  </td>
  <% if current_user?(micropost.user) %>
  <td>
    <%= link_to "delete", micropost, :method => :delete,
                                     :confirm => "You sure?",
                                     :title => micropost.content %>
  </td>
  <% end %>
</tr>
Примечание: В последней версии Rails 3.0, я и несколько других читателей иногда сталкиваемся со странной ошибкой, при которой micropost.user ассоциация некорректно создается. В результате вызов micropost.user вызывает исключение NoMethodError. Пока этот Rails баг не исправили, в качестве обходного пути вы можете заменить строки
<% if current_user?(micropost.user) %>
на
<% user = micropost.user rescue User.find(micropost.user_id) %>
<% if current_user?(user) %>
Когда обращение к micropost.user вызывает исключение, этот код ищет пользователя опираясь на user_id микросообщений.
Тест для destroy действия это просто обобщение аналогичных тестов для уничтожения пользователей (Листинг 10.40), как видно в Листинге 11.39.
Листинг 11.39. Тесты для destroy действия контроллера Microposts.
spec/controllers/microposts_controller_spec.rb
describe MicropostsController do
  .
  .
  .
  describe "DELETE 'destroy'" do

    describe "for an unauthorized user" do

      before(:each) do
        @user = Factory(:user)
        wrong_user = Factory(:user, :email => Factory.next(:email))
        test_sign_in(wrong_user)
        @micropost = Factory(:micropost, :user => @user)
      end

      it "should deny access" do
        delete :destroy, :id => @micropost
        response.should redirect_to(root_path)
      end
    end

    describe "for an authorized user" do

      before(:each) do
        @user = test_sign_in(Factory(:user))
        @micropost = Factory(:micropost, :user => @user)
      end

      it "should destroy the micropost" do
        lambda do
          delete :destroy, :id => @micropost
        end.should change(Micropost, :count).by(-1)
      end
    end
  end
end
Код приложения также аналогичен коду для удаления пользователей из Листинга 10.41; главное отличие в том, что вместо использования предфильтра admin_user, в случае микросообщений мы используем предфильтр authorized_user для проверки того, что текущий пользователь действительно имеет микросообщение с данным id. Код представлен в Листинг 11.40, Листинге 11.40, а результаты уничтожения предпоследнего сообщения представлены на Рис. 11.16.
Листинг 11.40. Действие destroy контроллера Microposts.
app/controllers/microposts_controller.rb
class MicropostsController < ApplicationController
  before_filter :authenticate, :only => [:create, :destroy]
  before_filter :authorized_user, :only => :destroy
  .
  .
  .
  def destroy
    @micropost.destroy
    redirect_back_or root_path
  end

  private

    def authorized_user
      @micropost = current_user.microposts.find_by_id(params[:id])
      redirect_to root_path if @micropost.nil?
    end
end
Отметьте, что мы использовали метод where (представлен в Разделе 11.3.3) для поиска микросообщений через ассоциацию:
current_user.microposts.find_by_id(params[:id])
Это автоматически обеспечивает поиск лишь микросообщений принадлежащих текущему пользователю. В данном случае мы используем find_by_id вместо find так как последнее вызывает исключение в случае если микросообщение не существует вместо того, чтобы вернуть nil. Кстати, если вы хорошо знакомы с исключениями в Ruby, вы также можете написать фильтр authorized_user вроде этого:
def authorized_user
  @micropost = current_user.microposts.find(params[:id])
rescue
  redirect_to root_path
end
Отметьте, что в фильтре authorized_user мы могли бы использовать модель Micropost непосредственно, как здесь:
@micropost = Micropost.find_by_id(params[:id])
redirect_to root_path unless current_user?(@micropost.user)
Это было бы эквивалентно коду в Листинге 11.40. Но, как объясняет Wolfram Arnold в своем блоге Access Control 101 in Rails and the Citibank Hack, в целях безопасности, хорошей практикой является выполнение поиска только через ассоциацию.
home_post_delete
Рисунок 11.16: Home страница пользователя после удаления предпоследнего микросообщения. (полный размер)

11.3.5 Тестирование новой home страницы

Прежде чем оставить создание и уничтожение микросообщений, мы напишем интеграционный тест чтобы проверить что наши формы правильно работают. Как и в случае с пользователями (Раздел 8.4), мы начнем с генерации интеграционного теста микросообщений:
$ rails generate integration_test microposts
Тесты для провального и успешного создания микросообщения представлены в Листинге 11.41.
Листинг 11.41. Интеграционный тест для микросообщений на home странице.
spec/requests/microposts_spec.rb
require 'spec_helper'

describe "Microposts" do

  before(:each) do
    user = Factory(:user)
    visit signin_path
    fill_in :email,    :with => user.email
    fill_in :password, :with => user.password
    click_button
  end

  describe "creation" do

    describe "failure" do

      it "should not make a new micropost" do
        lambda do
          visit root_path
          fill_in :micropost_content, :with => ""
          click_button
          response.should render_template('pages/home')
          response.should have_selector("div#error_explanation")
        end.should_not change(Micropost, :count)
      end
    end

    describe "success" do

      it "should make a new micropost" do
        content = "Lorem ipsum dolor sit amet"
        lambda do
          visit root_path
          fill_in :micropost_content, :with => content
          click_button
          response.should have_selector("span.content", :content => content)
        end.should change(Micropost, :count).by(1)
      end
    end
  end
end
Завершив тестирование функциональности микросообщений мы готовы перейти к завершающей функции нашего примера приложения: следованию за пользователями.

11.4 Заключение

Добавив ресурс Microposts, мы почти закончили наш пример приложения. Все, что осталось, это добавить социальный слой, позволив пользователям следовать друг за другом. Мы узнаем как моделировать такие отношения пользователей и увидим полноценную реализацию потока сообщений в Главе 12.
Если вы используете Git для управления версиями, прежде чем продолжить, убедитесь, что вы зафиксировали и объединили ваши изменения:
$ git add .
$ git commit -m "Added user microposts"
$ git checkout master
$ git merge user-microposts
Вы также можете отправить приложение на Heroku в этой точке. Поскольку модель данных изменилась за счет добавления таблицы микросообщений, вам также необходимо мигрировать production базу данных:
$ git push heroku
$ heroku rake db:migrate

11.5 Упражнения

Мы рассмотрели достаточно материала и теперь случился комбинаторный взрыв возможных расширений к нашему приложению. Вот лишь некоторые из многих возможностей:
  1. (сложное) Добавить JavaScript отображение к Home странице для обратного отсчета 140 знаков.
  2. Добавить тесты для отображения количества микросообщений в сайдбаре (включая надлежащие плюрализации).
  3. (в основном для дизайнеров) Модифицировать список микросообщений чтобы использовать упорядоченный список вместо таблицы. (Примечание: это как Twitter отображает обновление их статуса.) Затем добавить соответствующие CSS, чтобы в результате поток сообщений не выглядел как дерьмо.
  4. Добавить тесты для пагинации микросообщений.
  5. Сделать рефакторинг Home страницы чтобы использовать отдельные партиалы для двух ветвей выражения if-else.
  6. Написать тест чтобы убедиться, что ссылки на удаление не появляются у микросообщений созданных не текущим пользователем.
  7. Добавить вложенные маршруты так, чтобы по адресу /users/1/microposts показывались все микросообщения пользователя 1. (Вам также необходимо будет добавить в контроллер Microposts действие index и соответствующее представление.)
  8. Сейчас очень длинные слова крушат наш шаблон, как это показано на Рис. 11.17. Исправьте эту проблему с помощью хелпера wrap определенного в Листинге 11.42. (Обратите внимание на использование метода raw для предотвращения маскирования Рельсами результирующего HTML, совместно с sanitize методом необходимым для предотвращения межсайтового скриптинга.)
long_word_micropost
Рисунок 11.17: (Порушенный) особенно длинным словом шаблон сайта. (полный размер)
Листинг 11.42. Хелпер для упаковки длинных слов.
app/helpers/microposts_helper.rb
module MicropostsHelper

  def wrap(content)
    sanitize(raw(content.split.map{ |s| wrap_long_string(s) }.join(' ')))
  end

  private

    def wrap_long_string(text, max_width = 30)
      zero_width_space = "&#8203;"
      regex = /.{1,#{max_width}}/
      (text.length < max_width) ? text :
                                  text.scan(regex).join(zero_width_space)
    end
end
  1. Технически, в Главе 9 мы обращались c сессиями как с ресурсом, но они не сохранялись в базе данных как пользователи и микросообщения. 
  2. Атрибут content будет string, но, как кратко отмечалось в Разделе 2.1.2, для более длинных текстовых полей вам следует использовать тип данных text
  3. Более подробную информацию о Factory Girl ассоциациях, включая множество доступных опций, см. Factory Girl documentation
  4. Вспомните, что created_at и updated_at это “волшебные” столбцы, так что все явные значения инициализации магическим образом перезаписываются. 
  5. В смысле семантической разметки, вероятно, было бы лучше использовать нумерованный список, но в этом случае вертикальное выравнивания текста и изображений было бы гораздо более трудным, чем с таблицами. См. упражнение в Раздел 11.5 если вы настаиваете на трудностях с семантической версией. 
  6. В случае, если вам интересно, метод ассоциаций count умен, и выполняет подсчет непосредственно в базе данных. В частности, он не вытягивает все микросообщения из базы данных и не вызывает затем length на получившийся массив, так как это стало бы страшно неэффективно при увеличении числа микросообщений. Вместо этого, он просит базу данных подсчитать количество микросообщений с данным user_id. Кстати, в маловероятном случае при котором count все же будет узким местом в вашем приложении, вы можете сделать его еще быстрее с counter cache
  7. Для удобства, Листинг 11.19 на самом деле включает все CSS необходимые для этой главы. 
  8. (т.е., пять с пользовательскими Gravatar-ами, и один с дефолтным Gravatar) 
  9. Следите за своим log/development.log файлом, если вам интересно, какой SQL генерирует этот метод. 
  10. Текст lorem ipsum гема Faker случаен, так что контент ваших образцов микросообщений будет отличаться. 
  11. Остальные два ресурса это Users в Разделе 8.1 и Sessions в Разделе 9.1
  12. Мы отмечали в Разделе 9.3.2, что вспомогательные методы по умолчанию доступны только в представлениях, но мы сделали вспомогательный метод Sessions доступным также и в контроллерах, добавив include SessionsHelper в контроллер Application (Листинг 9.11). 
  13. Изучение методов, таких как include? является одной из причин, по которым, как отмечалось в Разделе 1.1.1, я рекомендую читать книги о чистом Ruby после окончания этого учебника 
  14. См. в Rails Guide Интерфейс запросов Active Record for more on where and the like (# предположительно: непередаваемый канадский юмор). 
  15. К сожалению, возвращение пагинированного потока сообщений не работает в данном случае. Реализуйте это и кликните по пагинационной ссылке чтобы увидеть почему. ( Rails Tutorial screencasts раскрывают этот вопрос более подробно.) 

Комментариев нет:

Отправить комментарий