Back to Repositories

Testing PaperTrail Pet Model Versioning with STI in paper_trail

This test suite validates PaperTrail’s versioning functionality for Pet models, focusing on Single Table Inheritance (STI) and model reification capabilities. The tests ensure proper version tracking across model inheritance hierarchies and verify the functionality of PaperTrail’s STI migration generator.

Test Coverage Overview

The test suite provides comprehensive coverage of PaperTrail’s versioning system for Pet models:
  • Basic versioning setup validation
  • Model reification with associated records
  • STI (Single Table Inheritance) version tracking
  • Historical version restoration across inheritance hierarchies
  • Migration generator functionality for updating legacy STI records

Implementation Analysis

The testing approach employs RSpec’s context-driven testing patterns with detailed setup and verification steps.
  • Uses before/after hooks for inheritance column manipulation
  • Implements let blocks for test data preparation
  • Utilizes nested contexts for different testing scenarios
  • Employs explicit version tracking verification

Technical Details

Key technical components include:
  • RSpec as the testing framework
  • PaperTrail::Version model for version tracking
  • Rails generators for migration testing
  • Custom PaperTrailSpecMigrator for test migrations
  • Model hierarchy: Animal (base) -> Cat/Dog (derived)

Best Practices Demonstrated

The test suite exemplifies several testing best practices:
  • Isolated test contexts with proper setup/teardown
  • Comprehensive edge case coverage
  • Clear test case organization and naming
  • Proper cleanup of modified class attributes
  • Explicit version count and type verification

paper-trail-gem/paper_trail

spec/models/pet_spec.rb

            
# frozen_string_literal: true

require "spec_helper"
require "rails/generators"

RSpec.describe Pet, type: :model, versioning: true do
  it "baseline test setup" do
    expect(described_class.new).to be_versioned
  end

  it "can be reified" do
    person = Person.create(name: "Frank")
    dog = Dog.create(name: "Snoopy")
    cat = Cat.create(name: "Garfield")

    person.pets << described_class.create(animal: dog)
    person.pets << described_class.create(animal: cat)
    person.update(name: "Steve")

    dog.update(name: "Beethoven")
    cat.update(name: "Sylvester")
    person.update(name: "Peter")

    expect(person.reload.versions.length).to(eq(3))
  end

  context "when an older version entry's item_type refers to the base_class" do
    let(:cat) { Cat.create(name: "Garfield") }   # Index 0
    let(:animal) { Animal.create }               # Index 4

    before do
      # This line runs the `let` for :cat, creating two entries
      cat.update(name: "Sylvester")   # Index 1 - second
      cat.update(name: "Cheshire")    # Index 2 - third
      cat.destroy # Index 3 - fourth

      # Prior to PR#1143 a subclassed version's item_subtype would be nil.  In order to simulate
      # an entry having been made in the old way, set one of the item_subtype entries to be nil
      # instead of "Cat".
      versions = PaperTrail::Version.order(:id)
      versions.second.update(item_subtype: nil)

      # This line runs the `let` for :animal, creating two entries
      animal.update(name: "Muppets Drummer")    # Index 5
      animal.destroy                            # Index 6
    end

    it "can reify a subclassed item" do
      versions = PaperTrail::Version.order(:id)
      # Still the reification process correctly brings back Cat since `species` is
      # properly set to this sub-classed name.
      expect(versions.second.reify).to be_a(Cat) # Sylvester
      expect(versions.third.reify).to be_a(Cat) # Cheshire
      expect(versions.fourth.reify).to be_a(Cat) # Cheshire that was destroyed
      # Creating an object from the base class is correctly identified as "Animal"
      expect(versions[5].reify).to be_an(Animal) # Muppets Drummer
      expect(versions[6].reify).to be_an(Animal) # Animal that was destroyed
    end

    it "has a generator that builds migrations to upgrade older entries" do
      # Only newer versions have item_subtype that refers directly to the subclass name.
      expect(PaperTrail::Version.where(item_subtype: "Cat").count).to eq(3)

      # To have has_many :versions work properly, you can generate and run a migration
      # that examines all existing models to identify use of STI, then updates all older
      # version entries that may refer to the base_class so they refer to the subclass.
      # (This is the same as running:  rails g paper_trail:update_sti; rails db:migrate)
      migrator = ::PaperTrailSpecMigrator.new
      expect {
        migrator.generate_and_migrate("paper_trail:update_item_subtype", [])
      }.to output(/Associated 1 record to Cat/).to_stdout

      # And now it finds all four changes
      cat_versions = PaperTrail::Version.where(item_subtype: "Cat").order(:id).to_a
      expect(cat_versions.length).to eq(4)
      expect(cat_versions.map(&:event)).to eq(%w[create update update destroy])

      # And Animal is unaffected
      animal_versions = PaperTrail::Version.where(item_subtype: "Animal").order(:id).to_a
      expect(animal_versions.length).to eq(3)
      expect(animal_versions.map(&:event)).to eq(%w[create update destroy])
    end

    # After creating a bunch of records above, we change the inheritance_column
    # so that we can demonstrate passing hints to the migration generator.
    context "when there was a historical change to inheritance_column" do
      before do
        Animal.inheritance_column = "species_xyz"
      end

      after do
        # Clean up the temporary switch-up
        Animal.inheritance_column = "species"
      end

      it "no hints given to generator, does not generate the correct migration" do
        # Because of the change to inheritance_column, the generator `rails g
        # paper_trail:update_sti` would be unable to determine the previous
        # inheritance_column, so a generated migration *with no hints* would
        # accomplish nothing.
        migrator = ::PaperTrailSpecMigrator.new
        hints = []
        expect {
          migrator.generate_and_migrate("paper_trail:update_item_subtype", hints)
        }.not_to output(/Associated 1 record to Cat/).to_stdout

        expect(PaperTrail::Version.where(item_subtype: "Cat").count).to eq(3)
        # And older Cat changes remain as nil.
        expect(PaperTrail::Version.where(item_subtype: nil, item_id: cat.id).count).to eq(1)
      end

      it "giving hints to the generator, updates older entries in a custom way" do
        # Pick up all version IDs regarding our single cat Garfield / Sylvester / Cheshire
        cat_ids = PaperTrail::Version.where(item_type: "Animal", item_id: cat.id).
          order(:id).pluck(:id)
        # This time (as opposed to above example) we are going to provide hints
        # to the generator.
        #
        # You can specify custom inheritance_column settings over a range of
        # IDs so that the generated migration will properly update all your historic versions,
        # having them now to refer to the proper subclass.

        # This is the same as running:
        #   rails g paper_trail:update_sti Animal(species):1..4; rails db:migrate
        migrator = ::PaperTrailSpecMigrator.new
        hints = ["Animal(species):#{cat_ids.first}..#{cat_ids.last}"]
        expect {
          migrator.generate_and_migrate("paper_trail:update_item_subtype", hints)
        }.to output(/Associated 1 record to Cat/).to_stdout

        # And now the has_many :versions properly finds all four changes
        cat_versions = cat.versions.order(:id).to_a
        expect(cat_versions.length).to eq(4)
        expect(cat_versions.map(&:event)).to eq(%w[create update update destroy])

        # And Animal is still unaffected
        animal_versions = animal.versions.order(:id).to_a
        expect(animal_versions.length).to eq(3)
        expect(animal_versions.map(&:event)).to eq(%w[create update destroy])
      end
    end
  end
end