Models and Tests - Lab 3 - Solution

The problem

From labs/3-activities.rb in your copy of models-and-tests:

An activity is one thing a salesperson did with one contact: "met at Cupertino". It holds two ids, the salesperson's and the contact's.

This is the lab about the ids. An activity doesn't contain a salesperson or a contact; it holds their ids, and every question you ask of it ("whose activity is this?", "what did Ben do with Tim?") is answered by looking rows up by those ids with where or find_by. There is no has_many or belongs_to in this repo, on purpose: the rules in AGENTS.md ask your agent for the lookup, so you can see it.

1. The rules

Under Activity in DONE.md:

## Activity
An activity can't be saved without a salesperson.
An activity can't be saved without a contact.
I can find one salesperson's activities with one contact.

The third is a different kind of rule from the first two. It doesn't forbid anything; it says what you must be able to find. Both kinds belong in DONE.md.

2. Tests first, red

Your prompt, in your own words, along the lines of lab 2's: a test for each sentence, run them, show them failing, don't change the model yet. The agent should produce something like:

require "test_helper"

class ActivityTest < ActiveSupport::TestCase
  # One test for each sentence under Activity in DONE.md, named with that sentence.

  test "an activity can't be saved without a salesperson" do
    activity = Activity.new
    activity["contact_id"] = 1
    activity["note"] = "met at Cupertino"
    activity.save

    assert_equal 0, Activity.all.count
  end

  test "an activity can't be saved without a contact" do
    activity = Activity.new
    activity["salesperson_id"] = 1
    activity["note"] = "met at Cupertino"
    activity.save

    assert_equal 0, Activity.all.count
  end

  test "I can find one salesperson's activities with one contact" do
    for note in ["quick checkin over facetime", "met at Cupertino"]
      activity = Activity.new
      activity["salesperson_id"] = 1
      activity["contact_id"] = 1
      activity["note"] = note
      activity.save
    end

    activity = Activity.new
    activity["salesperson_id"] = 1
    activity["contact_id"] = 2
    activity["note"] = "met at Amazon HQ"
    activity.save

    found = Activity.where({ "salesperson_id" => 1, "contact_id" => 1 })
    assert_equal 2, found.count
  end

end
3 runs, 3 assertions, 2 failures, 0 errors, 0 skips

Two fail, one passes, and that is the right first run. The two "can't be saved" rules fail because nothing enforces them yet. The "I can find" rule passes on the empty model, because
finding rows by a column comes free with the table: where needs no code of yours. A rule that passes before you've written anything is worth a second look (is it testing something?), and here the answer is yes: it checks that two of the three activities come back, not three.

Notice the ids in the tests are made up: 1, 2. The test database starts empty for every test, so there's no salesperson 1 to look up; the test only needs an activity to hold an id. That is both convenient and a trap, and lab 4 springs it.

3. Check, then green

DONE.md beside test/models/activity_test.rb: three sentences, three tests, each named with its sentence, each checking what it says. Then "Make them pass":

class Activity < ApplicationRecord
  validates :salesperson_id, presence: true
  validates :contact_id, presence: true
end
bin/rails test

Every test in the repo green, Company and Contact included.

4. The script

scripts/activities.rb needs rows to point at, so run scripts/list-contacts.rb (from class) and scripts/salespeople.rb (lab 2) first.

# Start empty, so this can run more than once
Activity.destroy_all

ben = Salesperson.find_by({ "first_name" => "Ben", "last_name" => "Block" })
tim = Contact.find_by({ "first_name" => "Tim", "last_name" => "Cook" })
andy = Contact.find_by({ "first_name" => "Andy", "last_name" => "Jassy" })

activity = Activity.new
activity["salesperson_id"] = ben["id"]
activity["contact_id"] = tim["id"]
activity["note"] = "quick checkin over facetime"
activity.save

activity = Activity.new
activity["salesperson_id"] = ben["id"]
activity["contact_id"] = tim["id"]
activity["note"] = "met at Cupertino"
activity.save

activity = Activity.new
activity["salesperson_id"] = ben["id"]
activity["contact_id"] = andy["id"]
activity["note"] = "met at Amazon HQ"
activity.save

puts "Activities between Ben and Tim Cook:"
for activity in Activity.where({ "salesperson_id" => ben["id"], "contact_id" => tim["id"] })
  puts "- #{activity["note"]}"
end
Activities between Ben and Tim Cook:
- quick checkin over facetime
- met at Cupertino

Read how it found them. The script never stores "Ben" in an activity. It looks Ben up once, takes his id, and puts that number in each row. The question "what did Ben do with Tim?" is then one where with two ids. That is the same WHERE salesperson_id = 1 AND contact_id = 1 you'd write in SQL, and it's in log/development.log if you want to see it.

If your agent wrote activity.salesperson or ben.activities, it reached for an association. Point it at AGENTS.md.

Challenge 4 and 4a: all of Ben's activities, with the company

puts "Ben's Activities:"
for activity in Activity.where({ "salesperson_id" => ben["id"] })
  contact = Contact.find_by({ "id" => activity["contact_id"] })
  company = Company.find_by({ "id" => contact["company_id"] })
  puts "#{contact["first_name"]} #{contact["last_name"]} (#{company["name"]}) - #{activity["note"]}"
end
Ben's Activities:
Tim Cook (Apple) - quick checkin over facetime
Tim Cook (Apple) - met at Cupertino
Andy Jassy (Amazon) - met at Amazon HQ

Two lookups per line, following the ids: the activity holds a contact_id, so find the contact; the contact holds a company_id, so find the company. Each find_by on an id is one row, so the result is a single contact and a single company, not a list.

Challenge 5: a count per salesperson

for salesperson in Salesperson.all
  count = Activity.where({ "salesperson_id" => salesperson["id"] }).count
  puts "#{salesperson["first_name"]} #{salesperson["last_name"]}: #{count} activities"
end
Ben Block: 3 activities
Brian Eng: 0 activities

Brian's zero is the line to check. where with an id nobody used returns an empty list, and an empty list counts as 0, so a salesperson with no activities still gets a line. If your
version skipped Brian, it probably looped over activities instead of salespeople.