diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8cfe0cd..676fea5 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -96,57 +96,12 @@ jobs:
with:
bundler-cache: true
- - name: Run tests
+ - name: Run specs
env:
RAILS_ENV: test
DATABASE_URL: postgres://postgres:postgres@localhost:5432
# RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
# REDIS_URL: redis://localhost:6379/0
- run: bin/rails db:test:prepare test
-
- system-test:
- runs-on: ubuntu-latest
-
- services:
- postgres:
- image: postgres
- env:
- POSTGRES_USER: postgres
- POSTGRES_PASSWORD: postgres
- ports:
- - 5432:5432
- options: --health-cmd="pg_isready" --health-interval=10s --health-timeout=5s --health-retries=3
-
- # redis:
- # image: valkey/valkey:8
- # ports:
- # - 6379:6379
- # options: --health-cmd "redis-cli ping" --health-interval 10s --health-timeout 5s --health-retries 5
-
- steps:
- - name: Install packages
- run: sudo apt-get update && sudo apt-get install --no-install-recommends -y libpq-dev libvips
-
- - name: Checkout code
- uses: actions/checkout@v6
-
- - name: Set up Ruby
- uses: ruby/setup-ruby@v1
- with:
- bundler-cache: true
-
- - name: Run System Tests
- env:
- RAILS_ENV: test
- DATABASE_URL: postgres://postgres:postgres@localhost:5432
- # RAILS_MASTER_KEY: ${{ secrets.RAILS_MASTER_KEY }}
- # REDIS_URL: redis://localhost:6379/0
- run: bin/rails db:test:prepare test:system
-
- - name: Keep screenshots from failed system tests
- uses: actions/upload-artifact@v4
- if: failure()
- with:
- name: screenshots
- path: ${{ github.workspace }}/tmp/screenshots
- if-no-files-found: ignore
+ run: |
+ bin/rails db:test:prepare
+ bundle exec rspec
diff --git a/.rspec b/.rspec
new file mode 100644
index 0000000..c99d2e7
--- /dev/null
+++ b/.rspec
@@ -0,0 +1 @@
+--require spec_helper
diff --git a/Gemfile b/Gemfile
index f1a90c4..8a6a570 100644
--- a/Gemfile
+++ b/Gemfile
@@ -54,6 +54,9 @@ group :development, :test do
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
gem "rubocop-rails-omakase", require: false
+ gem "rspec-rails"
+ gem "factory_bot_rails"
+ gem "faker"
end
group :development do
@@ -66,3 +69,5 @@ group :test do
gem "capybara"
gem "selenium-webdriver"
end
+
+gem "csv"
diff --git a/Gemfile.lock b/Gemfile.lock
index ac99ec7..ff6ed30 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -102,10 +102,12 @@ GEM
concurrent-ruby (1.3.8)
connection_pool (3.0.2)
crass (1.0.7)
+ csv (3.3.6)
date (3.5.1)
debug (1.11.1)
irb (~> 1.10)
reline (>= 0.3.8)
+ diff-lcs (1.6.2)
dotenv (3.2.0)
drb (2.2.3)
ed25519 (1.4.0)
@@ -113,6 +115,13 @@ GEM
erubi (1.13.1)
et-orbi (1.4.2)
tzinfo
+ factory_bot (6.6.0)
+ activesupport (>= 6.1.0)
+ factory_bot_rails (6.5.1)
+ factory_bot (~> 6.5)
+ railties (>= 6.1.0)
+ faker (3.8.0)
+ i18n (>= 1.8.11, < 2)
ffi (1.17.4-aarch64-linux-gnu)
ffi (1.17.4-aarch64-linux-musl)
ffi (1.17.4-arm-linux-gnu)
@@ -277,6 +286,23 @@ GEM
regexp_parser (2.13.1)
reline (0.7.0)
io-console (~> 0.5)
+ rspec-core (3.13.6)
+ rspec-support (~> 3.13.0)
+ rspec-expectations (3.13.5)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.13.0)
+ rspec-mocks (3.13.8)
+ diff-lcs (>= 1.2.0, < 2.0)
+ rspec-support (~> 3.13.0)
+ rspec-rails (8.0.4)
+ actionpack (>= 7.2)
+ activesupport (>= 7.2)
+ railties (>= 7.2)
+ rspec-core (>= 3.13.0, < 5.0.0)
+ rspec-expectations (>= 3.13.0, < 5.0.0)
+ rspec-mocks (>= 3.13.0, < 5.0.0)
+ rspec-support (>= 3.13.0, < 5.0.0)
+ rspec-support (3.13.7)
rubocop (1.91.0)
json (>= 2.3)
language_server-protocol (~> 3.17.0.2)
@@ -393,7 +419,10 @@ DEPENDENCIES
brakeman
bundler-audit
capybara
+ csv
debug
+ factory_bot_rails
+ faker
image_processing (~> 1.2)
importmap-rails
jbuilder
@@ -402,6 +431,7 @@ DEPENDENCIES
propshaft
puma (>= 5.0)
rails (~> 8.1.4)
+ rspec-rails
rubocop-rails-omakase
selenium-webdriver
solid_cable
@@ -441,14 +471,19 @@ CHECKSUMS
concurrent-ruby (1.3.8) sha256=b2f1be836e968ccc78ccfce277ea79c72a88633f22306782c16ff23fb415d1e1
connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a
crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295
+ csv (3.3.6) sha256=aba61e7e507a66f03d45cb1f3c4b6359861c3504038b422962875dce099e4456
date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0
debug (1.11.1) sha256=2e0b0ac6119f2207a6f8ac7d4a73ca8eb4e440f64da0a3136c30343146e952b6
+ diff-lcs (1.6.2) sha256=9ae0d2cba7d4df3075fe8cd8602a8604993efc0dfa934cff568969efb1909962
dotenv (3.2.0) sha256=e375b83121ea7ca4ce20f214740076129ab8514cd81378161f11c03853fe619d
drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373
ed25519 (1.4.0) sha256=16e97f5198689a154247169f3453ef4cfd3f7a47481fde0ae33206cdfdcac506
erb (6.0.7) sha256=c5ca6dc25b0ef974a44dc8f59fe847577122483b1968a38dec305c60bf91ee92
erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9
et-orbi (1.4.2) sha256=bb555dae668419cb24caa2a293a170e58be6d4df1e017c51f5030bdc133cd20c
+ factory_bot (6.6.0) sha256=1fc1b3b5620ec980a6a27aec1b6ec8c250ca82962e970e8a40f93e8d388d4b89
+ factory_bot_rails (6.5.1) sha256=d3cc4851eae4dea8a665ec4a4516895045e710554d2b5ac9e68b94d351bc6d68
+ faker (3.8.0) sha256=c147b308df73a90f27a4fc84f18d4c22ef0ad9c2a64b2b61c86fd0ca71753efc
ffi (1.17.4-aarch64-linux-gnu) sha256=b208f06f91ffd8f5e1193da3cae3d2ccfc27fc36fba577baf698d26d91c080df
ffi (1.17.4-aarch64-linux-musl) sha256=9286b7a615f2676245283aef0a0a3b475ae3aae2bb5448baace630bb77b91f39
ffi (1.17.4-arm-linux-gnu) sha256=d6dbddf7cb77bf955411af5f187a65b8cd378cb003c15c05697f5feee1cb1564
@@ -520,6 +555,11 @@ CHECKSUMS
rdoc (8.1.0) sha256=befc37880a5cfd36dcb176c748b5fd5ee39dd3536a75e81824e4972ce230f60a
regexp_parser (2.13.1) sha256=5aedb6b7c35688f51e86eef17a15374dfd287c83f4ca644b2c5f3ec50a33b44b
reline (0.7.0) sha256=5b012d8e55dbf9d450f12bde2cf7d15ff546ae80b3f8f3b30e570d431815583d
+ rspec-core (3.13.6) sha256=a8823c6411667b60a8bca135364351dda34cd55e44ff94c4be4633b37d828b2d
+ rspec-expectations (3.13.5) sha256=33a4d3a1d95060aea4c94e9f237030a8f9eae5615e9bd85718fe3a09e4b58836
+ rspec-mocks (3.13.8) sha256=086ad3d3d17533f4237643de0b5c42f04b66348c28bf6b9c2d3f4a3b01af1d47
+ rspec-rails (8.0.4) sha256=06235692fc0892683d3d34977e081db867434b3a24ae0dd0c6f3516bad4e22df
+ rspec-support (3.13.7) sha256=0640e5570872aafefd79867901deeeeb40b0c9875a36b983d85f54fb7381c47c
rubocop (1.91.0) sha256=9c82b7bf391c5d7e3798c5b9996e22a1fe3bd7468e351dfdeb96140c058296d0
rubocop-ast (1.50.0) sha256=b9ca88300da0803ee222ad20cdb30494c0a784eed06fdc35d254b06d662788db
rubocop-performance (1.27.0) sha256=eeeb1374d062a368ee1c787b70eb0b0cc4b184cb1f8565f424760946146d61ce
diff --git a/README.md b/README.md
index 10291c3..e4ffc6d 100644
--- a/README.md
+++ b/README.md
@@ -104,7 +104,15 @@ bin/rails db:migrate
bin/dev
```
-The dataset import task and the scenario seed script are part of Lesson 1 and do not exist yet.
+Put the Olist files in `data/olist/` (see the Data section), then load them:
+
+```bash
+LIMIT=200 bin/rails data:import_olist # small sample: 200 customers and their orders, about half a minute
+bin/rails data:import_olist # the full dataset, about 3 minutes
+bin/rails data:plant_scenarios # shipments for every order, plus the planted scenarios
+```
+
+Each import step prints how many rows it loaded and how many it skipped. `data:plant_scenarios` first generates the shipments, then edits five fixed orders (the first five that have a shipment) so each has a known answer for the evals. It prints the order id of each scenario, and running it again gives the same result.
## Scope
@@ -112,4 +120,7 @@ This is a learning project about building a reliable AI agent, not a complete su
## More
-The full plan, with architecture, gems by lesson, data mapping and the evaluation plan, is in [docs/PROJECT_PLAN.md](docs/PROJECT_PLAN.md).
+- The full plan, with architecture, gems by lesson, data mapping and the evaluation plan: [docs/PROJECT_PLAN.md](docs/PROJECT_PLAN.md)
+- The database tables and how they relate, as a diagram: [docs/DATA_MODEL.md](docs/DATA_MODEL.md)
+- Lesson progress, decisions and what broke: [docs/lessons/01-data-model-and-import.md](docs/lessons/01-data-model-and-import.md)
+- Index of all documentation: [docs/README.md](docs/README.md)
diff --git a/app/models/customer.rb b/app/models/customer.rb
new file mode 100644
index 0000000..65c08e2
--- /dev/null
+++ b/app/models/customer.rb
@@ -0,0 +1,3 @@
+class Customer < ApplicationRecord
+ has_many :orders
+end
diff --git a/app/models/order.rb b/app/models/order.rb
new file mode 100644
index 0000000..cf946f5
--- /dev/null
+++ b/app/models/order.rb
@@ -0,0 +1,7 @@
+class Order < ApplicationRecord
+ belongs_to :customer
+ has_many :order_items, dependent: :destroy
+ has_many :products, through: :order_items
+ has_many :payments, dependent: :destroy
+ has_one :shipment, dependent: :destroy
+end
diff --git a/app/models/order_item.rb b/app/models/order_item.rb
new file mode 100644
index 0000000..bc16957
--- /dev/null
+++ b/app/models/order_item.rb
@@ -0,0 +1,4 @@
+class OrderItem < ApplicationRecord
+ belongs_to :order
+ belongs_to :product
+end
diff --git a/app/models/payment.rb b/app/models/payment.rb
new file mode 100644
index 0000000..3237adf
--- /dev/null
+++ b/app/models/payment.rb
@@ -0,0 +1,3 @@
+class Payment < ApplicationRecord
+ belongs_to :order
+end
diff --git a/app/models/product.rb b/app/models/product.rb
new file mode 100644
index 0000000..1bdd579
--- /dev/null
+++ b/app/models/product.rb
@@ -0,0 +1,4 @@
+class Product < ApplicationRecord
+ has_many :order_items
+ has_many :orders, through: :order_items
+end
diff --git a/app/models/shipment.rb b/app/models/shipment.rb
new file mode 100644
index 0000000..1fd492b
--- /dev/null
+++ b/app/models/shipment.rb
@@ -0,0 +1,3 @@
+class Shipment < ApplicationRecord
+ belongs_to :order
+end
diff --git a/db/migrate/20261006134215_create_customers.rb b/db/migrate/20261006134215_create_customers.rb
new file mode 100644
index 0000000..fe99825
--- /dev/null
+++ b/db/migrate/20261006134215_create_customers.rb
@@ -0,0 +1,14 @@
+class CreateCustomers < ActiveRecord::Migration[8.1]
+ def change
+ create_table :customers do |t|
+ t.string :olist_customer_unique_id, null: false
+ t.string :name
+ t.string :email
+ t.string :city
+ t.string :state
+
+ t.timestamps
+ end
+ add_index :customers, :olist_customer_unique_id, unique: true
+ end
+end
diff --git a/db/migrate/20261006135202_create_products.rb b/db/migrate/20261006135202_create_products.rb
new file mode 100644
index 0000000..b29eb38
--- /dev/null
+++ b/db/migrate/20261006135202_create_products.rb
@@ -0,0 +1,19 @@
+class CreateProducts < ActiveRecord::Migration[8.1]
+ def change
+ create_table :products do |t|
+ t.string :olist_product_id, null: false
+ t.string :name
+ t.string :category
+ t.integer :name_length
+ t.integer :description_length
+ t.integer :photos_count
+ t.integer :weight_g
+ t.integer :length_cm
+ t.integer :height_cm
+ t.integer :width_cm
+
+ t.timestamps
+ end
+ add_index :products, :olist_product_id, unique: true
+ end
+end
diff --git a/db/migrate/20261006141029_create_orders.rb b/db/migrate/20261006141029_create_orders.rb
new file mode 100644
index 0000000..eb25327
--- /dev/null
+++ b/db/migrate/20261006141029_create_orders.rb
@@ -0,0 +1,17 @@
+class CreateOrders < ActiveRecord::Migration[8.1]
+ def change
+ create_table :orders do |t|
+ t.string :olist_order_id, null: false
+ t.references :customer, null: false, foreign_key: true
+ t.string :status, null: false
+ t.datetime :purchased_at
+ t.datetime :approved_at
+ t.datetime :shipped_at
+ t.datetime :delivered_at
+ t.datetime :estimated_delivery_at
+
+ t.timestamps
+ end
+ add_index :orders, :olist_order_id, unique: true
+ end
+end
diff --git a/db/migrate/20261006141535_create_order_items.rb b/db/migrate/20261006141535_create_order_items.rb
new file mode 100644
index 0000000..744e3ea
--- /dev/null
+++ b/db/migrate/20261006141535_create_order_items.rb
@@ -0,0 +1,12 @@
+class CreateOrderItems < ActiveRecord::Migration[8.1]
+ def change
+ create_table :order_items do |t|
+ t.references :order, null: false, foreign_key: true
+ t.references :product, null: false, foreign_key: true
+ t.decimal :price, precision: 10, scale: 2
+ t.decimal :freight_value, precision: 10, scale: 2
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20261006142602_create_payments.rb b/db/migrate/20261006142602_create_payments.rb
new file mode 100644
index 0000000..de9268d
--- /dev/null
+++ b/db/migrate/20261006142602_create_payments.rb
@@ -0,0 +1,13 @@
+class CreatePayments < ActiveRecord::Migration[8.1]
+ def change
+ create_table :payments do |t|
+ t.references :order, null: false, foreign_key: true
+ t.integer :sequence
+ t.string :payment_method
+ t.integer :installments
+ t.decimal :amount, null: false, precision: 10, scale: 2
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20261006143358_create_shipments.rb b/db/migrate/20261006143358_create_shipments.rb
new file mode 100644
index 0000000..68eda8f
--- /dev/null
+++ b/db/migrate/20261006143358_create_shipments.rb
@@ -0,0 +1,14 @@
+class CreateShipments < ActiveRecord::Migration[8.1]
+ def change
+ create_table :shipments do |t|
+ t.references :order, null: false, foreign_key: true, index: { unique: true }
+ t.string :carrier
+ t.string :tracking_number
+ t.string :status, null: false
+ t.string :last_known_location
+ t.datetime :last_scan_at
+
+ t.timestamps
+ end
+ end
+end
diff --git a/db/migrate/20261006144548_add_unique_index_to_customers_email.rb b/db/migrate/20261006144548_add_unique_index_to_customers_email.rb
new file mode 100644
index 0000000..f8dd0c6
--- /dev/null
+++ b/db/migrate/20261006144548_add_unique_index_to_customers_email.rb
@@ -0,0 +1,5 @@
+class AddUniqueIndexToCustomersEmail < ActiveRecord::Migration[8.1]
+ def change
+ add_index :customers, :email, unique: true
+ end
+end
diff --git a/db/migrate/20261007133706_add_final_sale_to_products.rb b/db/migrate/20261007133706_add_final_sale_to_products.rb
new file mode 100644
index 0000000..5149255
--- /dev/null
+++ b/db/migrate/20261007133706_add_final_sale_to_products.rb
@@ -0,0 +1,5 @@
+class AddFinalSaleToProducts < ActiveRecord::Migration[8.1]
+ def change
+ add_column :products, :final_sale, :boolean, null: false, default: false
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
new file mode 100644
index 0000000..286d21e
--- /dev/null
+++ b/db/schema.rb
@@ -0,0 +1,100 @@
+# This file is auto-generated from the current state of the database. Instead
+# of editing this file, please use the migrations feature of Active Record to
+# incrementally modify your database, and then regenerate this schema definition.
+#
+# This file is the source Rails uses to define your schema when running `bin/rails
+# db:schema:load`. When creating a new database, `bin/rails db:schema:load` tends to
+# be faster and is potentially less error prone than running all of your
+# migrations from scratch. Old migrations may fail to apply correctly if those
+# migrations use external dependencies or application code.
+#
+# It's strongly recommended that you check this file into your version control system.
+
+ActiveRecord::Schema[8.1].define(version: 2026_10_07_133706) do
+ # These are extensions that must be enabled in order to support this database
+ enable_extension "pg_catalog.plpgsql"
+
+ create_table "customers", force: :cascade do |t|
+ t.string "olist_customer_unique_id", null: false
+ t.string "name"
+ t.string "email"
+ t.string "city"
+ t.string "state"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["email"], name: "index_customers_on_email", unique: true
+ t.index ["olist_customer_unique_id"], name: "index_customers_on_olist_customer_unique_id", unique: true
+ end
+
+ create_table "order_items", force: :cascade do |t|
+ t.bigint "order_id", null: false
+ t.bigint "product_id", null: false
+ t.decimal "price", precision: 10, scale: 2
+ t.decimal "freight_value", precision: 10, scale: 2
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["order_id"], name: "index_order_items_on_order_id"
+ t.index ["product_id"], name: "index_order_items_on_product_id"
+ end
+
+ create_table "orders", force: :cascade do |t|
+ t.string "olist_order_id", null: false
+ t.bigint "customer_id", null: false
+ t.string "status", null: false
+ t.datetime "purchased_at"
+ t.datetime "approved_at"
+ t.datetime "shipped_at"
+ t.datetime "delivered_at"
+ t.datetime "estimated_delivery_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["customer_id"], name: "index_orders_on_customer_id"
+ t.index ["olist_order_id"], name: "index_orders_on_olist_order_id", unique: true
+ end
+
+ create_table "payments", force: :cascade do |t|
+ t.bigint "order_id", null: false
+ t.integer "sequence"
+ t.string "payment_method"
+ t.integer "installments"
+ t.decimal "amount", precision: 10, scale: 2, null: false
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["order_id"], name: "index_payments_on_order_id"
+ end
+
+ create_table "products", force: :cascade do |t|
+ t.string "olist_product_id", null: false
+ t.string "name"
+ t.string "category"
+ t.integer "name_length"
+ t.integer "description_length"
+ t.integer "photos_count"
+ t.integer "weight_g"
+ t.integer "length_cm"
+ t.integer "height_cm"
+ t.integer "width_cm"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.boolean "final_sale", default: false, null: false
+ t.index ["olist_product_id"], name: "index_products_on_olist_product_id", unique: true
+ end
+
+ create_table "shipments", force: :cascade do |t|
+ t.bigint "order_id", null: false
+ t.string "carrier"
+ t.string "tracking_number"
+ t.string "status", null: false
+ t.string "last_known_location"
+ t.datetime "last_scan_at"
+ t.datetime "created_at", null: false
+ t.datetime "updated_at", null: false
+ t.index ["order_id"], name: "index_shipments_on_order_id", unique: true
+ end
+
+ add_foreign_key "order_items", "orders"
+ add_foreign_key "order_items", "products"
+ add_foreign_key "orders", "customers"
+ add_foreign_key "payments", "orders"
+ add_foreign_key "shipments", "orders"
+end
diff --git a/db/seeds.rb b/db/seeds.rb
deleted file mode 100644
index 4fbd6ed..0000000
--- a/db/seeds.rb
+++ /dev/null
@@ -1,9 +0,0 @@
-# This file should ensure the existence of records required to run the application in every environment (production,
-# development, test). The code here should be idempotent so that it can be executed at any point in every environment.
-# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
-#
-# Example:
-#
-# ["Action", "Comedy", "Drama", "Horror"].each do |genre_name|
-# MovieGenre.find_or_create_by!(name: genre_name)
-# end
diff --git a/docs/DATA_MODEL.md b/docs/DATA_MODEL.md
new file mode 100644
index 0000000..bca5793
--- /dev/null
+++ b/docs/DATA_MODEL.md
@@ -0,0 +1,155 @@
+# Data model
+
+The support agent's database: who the customers are, what they ordered, how they paid and where
+the package is. This is the data the read tools (`get_order`, `get_shipment`, `get_customer`,
+`search_orders`) will query. It is built in Lesson 1 and is the same set of tables every later lesson uses.
+
+## Diagram
+
+```mermaid
+erDiagram
+ customers ||--o{ orders : "places"
+ orders ||--|{ order_items : "contains"
+ products ||--o{ order_items : "appears in"
+ orders ||--o{ payments : "paid by"
+ orders ||--o| shipments : "shipped as"
+
+ customers {
+ bigint id PK
+ string olist_customer_unique_id UK "NOT NULL"
+ string name "generated by Faker"
+ string email "generated by Faker"
+ string city
+ string state
+ }
+
+ orders {
+ bigint id PK
+ string olist_order_id UK "NOT NULL"
+ bigint customer_id FK "NOT NULL"
+ string status "NOT NULL"
+ datetime purchased_at
+ datetime approved_at
+ datetime shipped_at
+ datetime delivered_at
+ datetime estimated_delivery_at
+ }
+
+ order_items {
+ bigint id PK
+ bigint order_id FK "NOT NULL"
+ bigint product_id FK "NOT NULL"
+ decimal price "10,2"
+ decimal freight_value "10,2"
+ }
+
+ products {
+ bigint id PK
+ string olist_product_id UK "NOT NULL"
+ string name "generated by Faker"
+ string category "English name"
+ integer name_length
+ integer description_length
+ integer photos_count
+ integer weight_g
+ integer length_cm
+ integer height_cm
+ integer width_cm
+ }
+
+ payments {
+ bigint id PK
+ bigint order_id FK "NOT NULL"
+ integer sequence "1st, 2nd payment on the order"
+ string payment_method
+ integer installments
+ decimal amount "10,2 NOT NULL"
+ }
+
+ shipments {
+ bigint id PK
+ bigint order_id FK "UNIQUE, NOT NULL"
+ string carrier
+ string tracking_number
+ string status "NOT NULL"
+ string last_known_location
+ datetime last_scan_at
+ }
+```
+
+`created_at` and `updated_at` exist on every table and are left out of the diagram to keep it readable.
+
+
+Text version
+
+- A **customer** places many **orders** (one customer, zero or more orders).
+- An **order** contains one or more **order items**. Each item points at one **product**, so
+ `order_items` is the join table between orders and products (many to many).
+- An order has zero or more **payments**. More than one is normal (a voucher plus a card).
+- An order has zero or one **shipment**, enforced by a unique index on `shipments.order_id`.
+
+
+
+## How to read it
+
+| Symbol | Meaning |
+| --- | --- |
+| `\|\|--o{` | exactly one on the left, zero or many on the right |
+| `\|\|--\|{` | exactly one on the left, one or many on the right |
+| `\|\|--o\|` | exactly one on the left, zero or one on the right |
+| PK, FK, UK | primary key, foreign key, unique index |
+
+## Why it looks like this
+
+- **Integer primary keys, plus an `olist_*_id` column.** Olist ids are 32-character hex strings.
+ Integer ids are smaller, faster to join and read naturally (`/orders/1042`). The Olist id is kept only so
+ the import can match rows across files, so it has a unique index.
+- **One `Customer` per real person.** Olist creates a new `customer_id` for every order and keeps a stable
+ `customer_unique_id` for the person. We store the stable one, so `customer.orders` shows a person's whole
+ history, which a support agent needs ("this is their third late delivery"). The import translates each
+ per-order `customer_id` to the right person while loading orders.
+- **`order_items` is the middle table.** An order has many products and a product is in many orders, so the
+ link lives in its own table, with the price paid at purchase time.
+- **Money is `decimal(10, 2)`, never float.** Floats store 58.90 as 58.8999..., which is a real bug when the
+ agent computes a refund.
+- **Payments are separate rows.** About 3% of Olist orders have more than one payment. `sequence` is how the
+ duplicate charge scenario tells a legitimate split payment from being charged twice.
+- **`shipments` is its own table, with no copy of the dates.** Olist has no shipment data, so it is generated.
+ `orders` already holds `shipped_at` and `delivered_at`. The shipment adds what only the carrier knows:
+ carrier, tracking number, status and the last scan. A package whose last scan was 12 days ago looks lost
+ even if its status still says `in_transit`.
+- **The database enforces the rules, not only Rails.** `null: false`, unique indexes and foreign keys mean a
+ console typo or a future script cannot create a half-valid row.
+- **Deletes.** Items, payments and the shipment die with their order (`dependent: :destroy`). Orders are
+ never deleted with their customer or product, so order history is protected by the foreign keys.
+
+## Where each table comes from
+
+| Table | Source | Notes |
+| --- | --- | --- |
+| `customers` | Olist customers file | Collapsed to one row per `customer_unique_id`. `name` and `email` generated. |
+| `products` | Olist products file plus category translation file | English category. Olist misspells two column names as `lenght`, fixed here. `name` generated. `final_sale` is not from Olist: it is false by default and set by a planted scenario. |
+| `orders` | Olist orders file | Timestamps renamed (`order_delivered_customer_date` becomes `delivered_at`). |
+| `order_items` | Olist order items file | Seller, shipping limit and item number are skipped, the agent does not need them. |
+| `payments` | Olist order payments file | `payment_type` becomes `payment_method`, to avoid clashing with Ruby's built-in `method`. |
+| `shipments` | Generated by `data:generate_shipments` | One per order that was handed to a carrier, built from its shipped and delivered dates. `data:plant_scenarios` runs this first, then edits five orders. |
+
+Olist `sellers`, `geolocation` and `reviews` are not used, because a support agent does not need them yet.
+
+## Associations in Rails
+
+```
+Customer has_many :orders
+Order belongs_to :customer
+ has_many :order_items, dependent: :destroy
+ has_many :products, through: :order_items
+ has_many :payments, dependent: :destroy
+ has_one :shipment, dependent: :destroy
+OrderItem belongs_to :order, :product
+Product has_many :order_items
+ has_many :orders, through: :order_items
+Payment belongs_to :order
+Shipment belongs_to :order
+```
+
+The source of truth is always `db/schema.rb`. If the two ever disagree, fix this page.
diff --git a/docs/PROJECT_PLAN.md b/docs/PROJECT_PLAN.md
index 5244800..25e67f3 100644
--- a/docs/PROJECT_PLAN.md
+++ b/docs/PROJECT_PLAN.md
@@ -261,11 +261,13 @@ The project choice is locked. New project plans from other tools are not conside
## 12. Current step: Lesson 1 checklist
-- [ ] Create the app: `rails new --database=postgresql --css=tailwind`
-- [ ] Add `data/` to `.gitignore`
-- [ ] Download the Olist dataset into `data/olist/` and check its license
-- [ ] Note the column names of `orders`, `order_items`, `customers` and `products`
-- [ ] Write the migrations for `Customer`, `Product`, `Order`, `OrderItem`, `Payment`, `Shipment`
-- [ ] Write the import task (`bin/rails data:import_olist`)
-- [ ] Write the scenario seed script with a fixed random seed
+- [x] Create the app: `rails new --database=postgresql --css=tailwind`
+- [x] Add `data/` to `.gitignore`
+- [x] Download the Olist dataset into `data/olist/` and check its license
+- [x] Note the column names of `orders`, `order_items`, `customers` and `products`
+- [x] Write the migrations for `Customer`, `Product`, `Order`, `OrderItem`, `Payment`, `Shipment`
+- [ ] Write the import task (`bin/rails data:import_olist`): written and tested on a sample, batching and a full run still to do
+- [x] Write the scenario seed script with a fixed random seed (`bin/rails data:plant_scenarios`)
- [ ] Tag `lesson-01`
+
+Detailed progress, decisions and measured results: [lessons/01-data-model-and-import.md](lessons/01-data-model-and-import.md).
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..ba02767
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,18 @@
+# Documentation index
+
+Everything that explains how this project is built and why. Start with the plan, then read the
+lesson pages in order.
+
+| Page | What it is for |
+| --- | --- |
+| [PROJECT_PLAN.md](PROJECT_PLAN.md) | The whole project: scenario, architecture, stack, tools and permissions, 14 lessons, evaluation plan. |
+| [DATA_MODEL.md](DATA_MODEL.md) | The database as a diagram, why each table looks the way it does, and where its data comes from. |
+| [lessons/01-data-model-and-import.md](lessons/01-data-model-and-import.md) | Lesson 1: progress checklist, decisions, what broke and measured results. |
+
+## Conventions
+
+- **One page per lesson** in `lessons/`, named `NN-short-topic.md`. Each has the same parts:
+ status, what was built, decisions and why, what broke, and proof.
+- **Facts only.** Numbers in these pages come from a real run. If something has not been run yet,
+ the page says "expected".
+- **The code is the source of truth.** If a page and the code disagree, fix the page.
diff --git a/docs/lessons/01-data-model-and-import.md b/docs/lessons/01-data-model-and-import.md
new file mode 100644
index 0000000..8317b86
--- /dev/null
+++ b/docs/lessons/01-data-model-and-import.md
@@ -0,0 +1,109 @@
+# Lesson 1: a normal Rails app with real data
+
+No AI yet. This lesson builds the data the agent will later investigate: customers, orders,
+payments and shipments, loaded from a real public dataset. Every later lesson changes this same app.
+
+Last updated: 2026-10-07
+
+## Status
+
+- [x] App, gems (RSpec, FactoryBot, Faker, csv)
+- [x] Six tables, models and associations (see [DATA_MODEL.md](../DATA_MODEL.md))
+- [x] Data model diagram
+- [x] Import for customers, products, orders, order items and payments, tested with a 200 customer sample
+- [x] All five sections imported in batches (about 70 minutes down to about 3)
+- [x] Full import run, totals checked against the Olist files
+- [x] Shipments generated, plus five planted scenarios: delayed shipment, lost package, return window
+ closed, final sale item and duplicate charge
+- [x] Specs and factories for every model, and for the scenarios task
+- [x] README: how to run the import and the scenarios
+- [x] Clean `db:reset` run from scratch: import, scenarios and specs all pass
+- [ ] Tag `lesson-01` after the merge
+
+## What was built
+
+`bin/rails data:import_olist` (in `lib/tasks/data.rake`) reads the Olist CSV files from `data/olist/` and
+fills the tables in dependency order: customers, products, orders, then order items and payments.
+Names, emails and product names are generated, because Olist has none.
+
+```
+LIMIT=200 bin/rails data:import_olist # a small, consistent sample in about half a minute
+bin/rails data:import_olist # everything, about 3 minutes
+bin/rails data:plant_scenarios # shipments for every order, then the five planted scenarios
+```
+
+`LIMIT` applies to customers only. Everything below customers follows on its own: an order whose
+customer was not loaded is skipped, and so are the items and payments of skipped orders. Each step prints
+how many rows it kept and how many it skipped, so the totals can be checked against the files.
+
+## Planted scenarios
+
+Real data has no cases where we know the right answer, and an eval needs one. `bin/rails data:plant_scenarios`
+(in `lib/tasks/scenarios.rake`) first generates a shipment for every order that was handed to a carrier, then
+edits five orders so each tells one story. These are the golden cases the later lessons test the agent against.
+
+| Scenario | What is planted | Correct answer for the agent |
+| --- | --- | --- |
+| Delayed shipment | Shipped 13 days ago, shipment `in_transit`, last scan 11 days ago | Apologize, share tracking, wait |
+| Lost package | Shipped 21 days ago, shipment `exception`, last scan 16 days ago | Offer a replacement (needs approval) |
+| Return window closed | Delivered 40 days ago, the policy allows 30 | Refuse the return |
+| Final sale item | Delivered 5 days ago, but the product has `final_sale: true` | Refuse the return |
+| Duplicate charge | Two identical payments on one order | Refund one (needs approval) |
+
+The five orders are the first five (by id) that have a shipment. That rule does not depend on the integer
+ids, which change on every import, so reruns always plant on the same orders. The task prints the order id of
+each scenario, and running it twice gives the same result (the duplicate payment is not added again).
+A product marked final sale is final sale on every order that contains it, which is fine for the evals.
+
+## Decisions and why
+
+| Decision | Why |
+| --- | --- |
+| Integer ids plus an `olist_*_id` column | Olist ids are 32 character hex strings. Integers are smaller and read naturally. The Olist id is only for matching rows during import, so it has a unique index. |
+| One `Customer` per real person | Olist makes a new `customer_id` for every order. The stable `customer_unique_id` identifies the person, so `customer.orders` shows a real history. The import keeps a lookup from each per-order id to the person. |
+| Unknown values stay `nil`, never `0` | A missing weight stored as 0 would make the agent say an item weighs nothing. `nil` lets it say "not recorded". Rails casts the CSV strings, so no `.to_i` is needed. |
+| Emails are name plus a counter on reserved domains | `brittany.klocko.7@example.org`. The counter makes every email unique even when names repeat, and `example.com`, `.org` and `.net` can never reach a real mailbox, which matters once the agent can send email. |
+| Category translations come from the Olist file, not an AI | A fixed lookup file gives the same result every run, costs nothing and keeps the data reproducible. Categories missing from it keep their Portuguese name. |
+| Money is `decimal(10, 2)` | Floats cannot store 58.90 exactly, which is a real bug when computing refunds. |
+| The database enforces the rules | `null: false`, unique indexes and foreign keys stop bad rows even if a script or a console typo tries to create them. |
+| Batch inserts with `insert_all!` | One query per 1,000 rows instead of one per row. The bang version raises on a duplicate instead of silently skipping it, so a bad dataset cannot lose rows unnoticed. It skips validations and callbacks, which is fine while the models have none. |
+| Fixed random seed | `Faker::Config.random = Random.new(42)` gives everyone the same names, which evals will rely on. |
+
+## What broke
+
+- **The first CSV column had a hidden character.** The category translation file starts with a byte
+ order mark. Reading it with `encoding: "bom|utf-8"` fixes the column name.
+- **A column called `sequence` clashed with FactoryBot.** `sequence` is a keyword there, so the generated
+ factory failed with "wrong number of arguments". The fix is `add_attribute(:sequence) { 1 }`.
+- **`delete_all` does not reset ids.** After several test runs the first customer had id 46986. The
+ ids are harmless, but evals must find records by content or Olist id, never by a fixed integer id.
+- **Importing one row at a time was far too slow.** Each `create!` is its own database round trip.
+ Products took 5 minutes 22 seconds, and the full import of the other four took about an hour. Batching
+ with `insert_all!` brought products to 17 seconds and the whole import to about 3 minutes, with identical counts.
+- **The import failed once shipments existed.** It cleared `orders` while `shipments` still pointed at them,
+ and the foreign key refused. Child tables are now cleared first, shipments before orders.
+
+## Proof
+
+Measured on 2026-10-06 and 2026-10-07.
+
+| Step | Result |
+| --- | --- |
+| Full import | 96,096 customers (from 99,441 rows), 32,951 products, 99,441 orders, 112,650 order items, 103,886 payments, nothing skipped |
+| Sample run, `LIMIT=200` | 200 customers, 200 orders, 226 order items, 209 payments |
+| Sample run honesty check | orders 200 + skipped 99,241 = 99,441 rows; items 226 + 112,424 = 112,650; payments 209 + 103,677 = 103,886 |
+| Import time, one row at a time | about 70 minutes (customers 14 min, orders 16 min, items 25 min, payments 14 min) |
+| Import time, batches of 1,000 | about 3 minutes (customers 61 s, orders 70 s, items 21 s, payments 18 s) |
+| Shipments generated | 97,658 (orders that never reached a carrier have none) |
+| Specs | 20 examples, 0 failures; rubocop clean |
+| Clean run from scratch | `db:reset`, import (about 3 minutes), `plant_scenarios`, specs: same totals, scenarios on orders 1 to 5 |
+
+Known trade-offs: the import is not wrapped in a transaction (a crash leaves partial data, and rerunning
+clears and reloads everything), and it has no automated test of its own, only the totals above.
+
+## Where each piece lives
+
+- Tables and relations: [DATA_MODEL.md](../DATA_MODEL.md)
+- Import code: `lib/tasks/data.rake`
+- Shipments and planted scenarios: `lib/tasks/scenarios.rake`, tested in `spec/tasks/scenarios_spec.rb`
+- Dataset files: `data/` (git-ignored, see the README for the source and license)
diff --git a/lib/tasks/data.rake b/lib/tasks/data.rake
new file mode 100644
index 0000000..8e68c8a
--- /dev/null
+++ b/lib/tasks/data.rake
@@ -0,0 +1,161 @@
+namespace :data do
+ desc "Import the Olist dataset"
+ task import_olist: :environment do
+ Faker::Config.random = Random.new(42)
+ step = ->(message) { puts "#{Time.current.strftime("%H:%M:%S")} #{message}" }
+ # One insert per 1,000 rows is far faster than create! per row. The bang version raises on duplicates instead of silently skipping them
+ insert_in_batches = ->(model, rows) { rows.each_slice(1_000) { |batch| model.insert_all!(batch) } }
+
+ Shipment.delete_all
+ OrderItem.delete_all
+ Payment.delete_all
+ Order.delete_all
+ Product.delete_all
+ Customer.delete_all
+
+ step.call("importing customers")
+ path = Rails.root.join("data", "olist", "olist_customers_dataset.csv")
+ customer_attributes_by_unique_id = {}
+ unique_ids_by_olist_id = {}
+ email_domains = %w[example.com example.org example.net]
+
+ customer_rows = CSV.foreach(path, headers: true)
+ customer_rows = customer_rows.first(ENV["LIMIT"].to_i) if ENV["LIMIT"]
+
+ customer_rows.each do |row|
+ unique_id = row["customer_unique_id"]
+ unique_ids_by_olist_id[row["customer_id"]] = unique_id
+ next if customer_attributes_by_unique_id.key?(unique_id)
+
+ email_number = customer_attributes_by_unique_id.size + 1
+ name = Faker::Name.name
+ customer_attributes_by_unique_id[unique_id] = {
+ olist_customer_unique_id: unique_id,
+ name: name,
+ email: "#{name.parameterize(separator: ".")}.#{email_number}@#{email_domains[email_number % email_domains.size]}",
+ city: row["customer_city"],
+ state: row["customer_state"]
+ }
+ end
+
+ insert_in_batches.call(Customer, customer_attributes_by_unique_id.values)
+ customer_ids_by_unique_id = Customer.pluck(:olist_customer_unique_id, :id).to_h
+ customer_ids_by_olist_id = unique_ids_by_olist_id.transform_values { |unique_id| customer_ids_by_unique_id[unique_id] }
+
+ step.call("customers: #{Customer.count}, lookup entries: #{customer_ids_by_olist_id.size}")
+
+ step.call("importing products")
+ translations_path = Rails.root.join("data", "olist", "product_category_name_translation.csv")
+ category_translations = {}
+
+ CSV.foreach(translations_path, headers: true, encoding: "bom|utf-8") do |row|
+ category_translations[row["product_category_name"]] = row["product_category_name_english"]
+ end
+
+ products_path = Rails.root.join("data", "olist", "olist_products_dataset.csv")
+
+ product_rows = CSV.foreach(products_path, headers: true).map do |row|
+ portuguese_category = row["product_category_name"]
+
+ {
+ olist_product_id: row["product_id"],
+ name: Faker::Commerce.product_name,
+ category: category_translations.fetch(portuguese_category, portuguese_category),
+ name_length: row["product_name_lenght"],
+ description_length: row["product_description_lenght"],
+ photos_count: row["product_photos_qty"],
+ weight_g: row["product_weight_g"],
+ length_cm: row["product_length_cm"],
+ height_cm: row["product_height_cm"],
+ width_cm: row["product_width_cm"]
+ }
+ end
+
+ insert_in_batches.call(Product, product_rows)
+ product_ids_by_olist_id = Product.pluck(:olist_product_id, :id).to_h
+
+ step.call("products: #{Product.count}")
+
+ step.call("importing orders")
+ orders_path = Rails.root.join("data", "olist", "olist_orders_dataset.csv")
+ order_rows = []
+ skipped_orders = 0
+
+ CSV.foreach(orders_path, headers: true) do |row|
+ customer_id = customer_ids_by_olist_id[row["customer_id"]]
+
+ # With LIMIT only some customers exist, so their orders are the only ones we can keep
+ unless customer_id
+ skipped_orders += 1
+ next
+ end
+
+ order_rows << {
+ olist_order_id: row["order_id"],
+ customer_id: customer_id,
+ status: row["order_status"],
+ purchased_at: row["order_purchase_timestamp"],
+ approved_at: row["order_approved_at"],
+ shipped_at: row["order_delivered_carrier_date"],
+ delivered_at: row["order_delivered_customer_date"],
+ estimated_delivery_at: row["order_estimated_delivery_date"]
+ }
+ end
+
+ insert_in_batches.call(Order, order_rows)
+ order_ids_by_olist_id = Order.pluck(:olist_order_id, :id).to_h
+
+ step.call("orders: #{Order.count}, skipped: #{skipped_orders}")
+
+ step.call("importing order items")
+ order_items_path = Rails.root.join("data", "olist", "olist_order_items_dataset.csv")
+ order_item_rows = []
+ skipped_order_items = 0
+
+ CSV.foreach(order_items_path, headers: true) do |row|
+ order_id = order_ids_by_olist_id[row["order_id"]]
+
+ unless order_id
+ skipped_order_items += 1
+ next
+ end
+
+ order_item_rows << {
+ order_id: order_id,
+ product_id: product_ids_by_olist_id[row["product_id"]],
+ price: row["price"],
+ freight_value: row["freight_value"]
+ }
+ end
+
+ insert_in_batches.call(OrderItem, order_item_rows)
+
+ step.call("order items: #{OrderItem.count}, skipped: #{skipped_order_items}")
+
+ step.call("importing payments")
+ payments_path = Rails.root.join("data", "olist", "olist_order_payments_dataset.csv")
+ payment_rows = []
+ skipped_payments = 0
+
+ CSV.foreach(payments_path, headers: true) do |row|
+ order_id = order_ids_by_olist_id[row["order_id"]]
+
+ unless order_id
+ skipped_payments += 1
+ next
+ end
+
+ payment_rows << {
+ order_id: order_id,
+ sequence: row["payment_sequential"],
+ payment_method: row["payment_type"],
+ installments: row["payment_installments"],
+ amount: row["payment_value"]
+ }
+ end
+
+ insert_in_batches.call(Payment, payment_rows)
+
+ step.call("payments: #{Payment.count}, skipped: #{skipped_payments}")
+ end
+end
diff --git a/lib/tasks/scenarios.rake b/lib/tasks/scenarios.rake
new file mode 100644
index 0000000..7146ff3
--- /dev/null
+++ b/lib/tasks/scenarios.rake
@@ -0,0 +1,64 @@
+namespace :data do
+ desc "Generate shipments from the imported orders"
+ task generate_shipments: :environment do
+ Faker::Config.random = Random.new(42)
+ carriers = %w[Correios Jadlog Loggi Total\ Express]
+
+ Shipment.delete_all
+
+ # Orders that were never handed to a carrier (canceled, still processing) have no shipment
+ Order.where.not(shipped_at: nil).find_in_batches(batch_size: 1_000) do |orders|
+ rows = orders.map do |order|
+ {
+ order_id: order.id,
+ carrier: carriers.sample(random: Faker::Config.random),
+ tracking_number: Faker::Alphanumeric.alphanumeric(number: 12).upcase,
+ status: order.delivered_at ? "delivered" : "in_transit",
+ last_scan_at: order.delivered_at || order.shipped_at
+ }
+ end
+
+ Shipment.insert_all(rows)
+ end
+
+ puts "shipments: #{Shipment.count}"
+ end
+
+ desc "Plant the scenarios the evals know the answer to"
+ task plant_scenarios: :generate_shipments do
+ # Orders with a shipment are picked by id order, so reruns always plant on the same orders
+ candidates = Order.joins(:shipment).order(:id)
+
+ delayed = candidates.first
+ delayed.update!(status: "shipped", shipped_at: 13.days.ago, delivered_at: nil)
+ delayed.shipment.update!(status: "in_transit", last_scan_at: 11.days.ago, last_known_location: "Curitiba hub")
+ puts "delayed shipment: order #{delayed.id}"
+
+ lost = candidates.offset(1).first
+ lost.update!(status: "shipped", shipped_at: 21.days.ago, delivered_at: nil)
+ lost.shipment.update!(status: "exception", last_scan_at: 16.days.ago, last_known_location: "Sao Paulo sorting center")
+ puts "lost package: order #{lost.id}"
+
+ expired_return = candidates.offset(2).first
+ expired_return.update!(status: "delivered", shipped_at: 45.days.ago, delivered_at: 40.days.ago)
+ expired_return.shipment.update!(status: "delivered", last_scan_at: 40.days.ago)
+ puts "return window closed: order #{expired_return.id}"
+
+ final_sale = candidates.offset(3).first
+ final_sale.update!(status: "delivered", shipped_at: 8.days.ago, delivered_at: 5.days.ago)
+ final_sale.shipment.update!(status: "delivered", last_scan_at: 5.days.ago)
+ final_sale.products.first.update!(final_sale: true)
+ puts "final sale item: order #{final_sale.id}"
+
+ duplicate_charge = candidates.offset(4).first
+ payment = duplicate_charge.payments.order(:sequence).first
+ # find_or_create_by! keeps reruns from stacking extra duplicates
+ duplicate_charge.payments.find_or_create_by!(
+ sequence: payment.sequence + 1,
+ payment_method: payment.payment_method,
+ installments: payment.installments,
+ amount: payment.amount
+ )
+ puts "duplicate charge: order #{duplicate_charge.id}"
+ end
+end
diff --git a/spec/factories/customers.rb b/spec/factories/customers.rb
new file mode 100644
index 0000000..47027e8
--- /dev/null
+++ b/spec/factories/customers.rb
@@ -0,0 +1,9 @@
+FactoryBot.define do
+ factory :customer do
+ sequence(:olist_customer_unique_id) { |n| "unique-customer-#{n}" }
+ sequence(:email) { |n| "customer#{n}@example.com" }
+ name { "Ana Silva" }
+ city { "curitiba" }
+ state { "PR" }
+ end
+end
diff --git a/spec/factories/order_items.rb b/spec/factories/order_items.rb
new file mode 100644
index 0000000..44ef30a
--- /dev/null
+++ b/spec/factories/order_items.rb
@@ -0,0 +1,8 @@
+FactoryBot.define do
+ factory :order_item do
+ order
+ product
+ price { "59.90" }
+ freight_value { "12.50" }
+ end
+end
diff --git a/spec/factories/orders.rb b/spec/factories/orders.rb
new file mode 100644
index 0000000..174ad8b
--- /dev/null
+++ b/spec/factories/orders.rb
@@ -0,0 +1,12 @@
+FactoryBot.define do
+ factory :order do
+ customer
+ sequence(:olist_order_id) { |n| "order-#{n}" }
+ status { "delivered" }
+ purchased_at { 30.days.ago }
+ approved_at { 29.days.ago }
+ shipped_at { 25.days.ago }
+ delivered_at { 20.days.ago }
+ estimated_delivery_at { 18.days.ago }
+ end
+end
diff --git a/spec/factories/payments.rb b/spec/factories/payments.rb
new file mode 100644
index 0000000..7044634
--- /dev/null
+++ b/spec/factories/payments.rb
@@ -0,0 +1,9 @@
+FactoryBot.define do
+ factory :payment do
+ order
+ add_attribute(:sequence) { 1 }
+ payment_method { "credit_card" }
+ installments { 1 }
+ amount { "59.90" }
+ end
+end
diff --git a/spec/factories/products.rb b/spec/factories/products.rb
new file mode 100644
index 0000000..7a2c2b2
--- /dev/null
+++ b/spec/factories/products.rb
@@ -0,0 +1,7 @@
+FactoryBot.define do
+ factory :product do
+ sequence(:olist_product_id) { |n| "product-#{n}" }
+ name { "Ergonomic Wooden Chair" }
+ category { "furniture_decor" }
+ end
+end
diff --git a/spec/factories/shipments.rb b/spec/factories/shipments.rb
new file mode 100644
index 0000000..815b5e0
--- /dev/null
+++ b/spec/factories/shipments.rb
@@ -0,0 +1,9 @@
+FactoryBot.define do
+ factory :shipment do
+ order
+ carrier { "Correios" }
+ sequence(:tracking_number) { |n| "TRACK#{n.to_s.rjust(7, "0")}" }
+ status { "in_transit" }
+ last_scan_at { 2.days.ago }
+ end
+end
diff --git a/spec/models/customer_spec.rb b/spec/models/customer_spec.rb
new file mode 100644
index 0000000..6b55a6a
--- /dev/null
+++ b/spec/models/customer_spec.rb
@@ -0,0 +1,16 @@
+require "rails_helper"
+
+RSpec.describe Customer, type: :model do
+ it "has many orders" do
+ customer = create(:customer)
+ order = create(:order, customer: customer)
+
+ expect(customer.orders).to eq([ order ])
+ end
+
+ it "does not allow two customers with the same email" do
+ create(:customer, email: "ana@example.com")
+
+ expect { create(:customer, email: "ana@example.com") }.to raise_error(ActiveRecord::RecordNotUnique)
+ end
+end
diff --git a/spec/models/order_item_spec.rb b/spec/models/order_item_spec.rb
new file mode 100644
index 0000000..d72532c
--- /dev/null
+++ b/spec/models/order_item_spec.rb
@@ -0,0 +1,10 @@
+require "rails_helper"
+
+RSpec.describe OrderItem, type: :model do
+ it "belongs to an order and a product" do
+ item = create(:order_item)
+
+ expect(item.order).to be_a(Order)
+ expect(item.product).to be_a(Product)
+ end
+end
diff --git a/spec/models/order_spec.rb b/spec/models/order_spec.rb
new file mode 100644
index 0000000..c95eb11
--- /dev/null
+++ b/spec/models/order_spec.rb
@@ -0,0 +1,36 @@
+require "rails_helper"
+
+RSpec.describe Order, type: :model do
+ let(:order) { create(:order) }
+
+ it "belongs to a customer" do
+ expect(order.customer).to be_a(Customer)
+ end
+
+ it "reaches its products through order items" do
+ item = create(:order_item, order: order)
+
+ expect(order.products).to eq([ item.product ])
+ end
+
+ it "has many payments" do
+ payment = create(:payment, order: order)
+
+ expect(order.payments).to eq([ payment ])
+ end
+
+ it "has one shipment" do
+ shipment = create(:shipment, order: order)
+
+ expect(order.shipment).to eq(shipment)
+ end
+
+ it "removes its items, payments and shipment when destroyed" do
+ create(:order_item, order: order)
+ create(:payment, order: order)
+ create(:shipment, order: order)
+
+ expect { order.destroy }
+ .to change { [ OrderItem.count, Payment.count, Shipment.count ] }.from([ 1, 1, 1 ]).to([ 0, 0, 0 ])
+ end
+end
diff --git a/spec/models/payment_spec.rb b/spec/models/payment_spec.rb
new file mode 100644
index 0000000..ca4657f
--- /dev/null
+++ b/spec/models/payment_spec.rb
@@ -0,0 +1,7 @@
+require "rails_helper"
+
+RSpec.describe Payment, type: :model do
+ it "belongs to an order" do
+ expect(create(:payment).order).to be_a(Order)
+ end
+end
diff --git a/spec/models/product_spec.rb b/spec/models/product_spec.rb
new file mode 100644
index 0000000..c4ca2d6
--- /dev/null
+++ b/spec/models/product_spec.rb
@@ -0,0 +1,15 @@
+require "rails_helper"
+
+RSpec.describe Product, type: :model do
+ it "is not a final sale item by default" do
+ expect(create(:product).final_sale).to be(false)
+ end
+
+ it "reaches its orders through order items" do
+ product = create(:product)
+ order = create(:order)
+ create(:order_item, order: order, product: product)
+
+ expect(product.orders).to eq([ order ])
+ end
+end
diff --git a/spec/models/shipment_spec.rb b/spec/models/shipment_spec.rb
new file mode 100644
index 0000000..80e2204
--- /dev/null
+++ b/spec/models/shipment_spec.rb
@@ -0,0 +1,14 @@
+require "rails_helper"
+
+RSpec.describe Shipment, type: :model do
+ it "belongs to an order" do
+ expect(create(:shipment).order).to be_a(Order)
+ end
+
+ it "allows only one shipment per order" do
+ order = create(:order)
+ create(:shipment, order: order)
+
+ expect { create(:shipment, order: order) }.to raise_error(ActiveRecord::RecordNotUnique)
+ end
+end
diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb
new file mode 100644
index 0000000..bfcbc18
--- /dev/null
+++ b/spec/rails_helper.rb
@@ -0,0 +1,74 @@
+# This file is copied to spec/ when you run 'rails generate rspec:install'
+require 'spec_helper'
+ENV['RAILS_ENV'] ||= 'test'
+require_relative '../config/environment'
+# Prevent database truncation if the environment is production
+abort("The Rails environment is running in production mode!") if Rails.env.production?
+# Uncomment the line below in case you have `--require rails_helper` in the `.rspec` file
+# that will avoid rails generators crashing because migrations haven't been run yet
+# return unless Rails.env.test?
+require 'rspec/rails'
+# Add additional requires below this line. Rails is not loaded until this point!
+
+# Requires supporting ruby files with custom matchers and macros, etc, in
+# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are
+# run as spec files by default. This means that files in spec/support that end
+# in _spec.rb will both be required and run as specs, causing the specs to be
+# run twice. It is recommended that you do not name files matching this glob to
+# end with _spec.rb. You can configure this pattern with the --pattern
+# option on the command line or in ~/.rspec, .rspec or `.rspec-local`.
+#
+# The following line is provided for convenience purposes. It has the downside
+# of increasing the boot-up time by auto-requiring all files in the support
+# directory. Alternatively, in the individual `*_spec.rb` files, manually
+# require only the support files necessary.
+#
+# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f }
+
+# Ensures that the test database schema matches the current schema file.
+# If there are pending migrations it will invoke `db:test:prepare` to
+# recreate the test database by loading the schema.
+# If you are not using ActiveRecord, you can remove these lines.
+begin
+ ActiveRecord::Migration.maintain_test_schema!
+rescue ActiveRecord::PendingMigrationError => e
+ abort e.to_s.strip
+end
+RSpec.configure do |config|
+ config.include FactoryBot::Syntax::Methods
+
+ # Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
+ config.fixture_paths = [
+ Rails.root.join('spec/fixtures')
+ ]
+
+ # If you're not using ActiveRecord, or you'd prefer not to run each of your
+ # examples within a transaction, remove the following line or assign false
+ # instead of true.
+ config.use_transactional_fixtures = true
+
+ # You can uncomment this line to turn off ActiveRecord support entirely.
+ # config.use_active_record = false
+
+ # RSpec Rails uses metadata to mix in different behaviours to your tests,
+ # for example enabling you to call `get` and `post` in request specs. e.g.:
+ #
+ # RSpec.describe UsersController, type: :request do
+ # # ...
+ # end
+ #
+ # The different available types are documented in the features, such as in
+ # https://rspec.info/features/8-0/rspec-rails
+ #
+ # You can also infer these behaviours automatically by location, e.g.
+ # /spec/models would pull in the same behaviour as `type: :model` but this
+ # behaviour is considered legacy and will be removed in a future version.
+ #
+ # To enable this behaviour uncomment the line below.
+ # config.infer_spec_type_from_file_location!
+
+ # Filter lines from Rails gems in backtraces.
+ config.filter_rails_from_backtrace!
+ # arbitrary gems may also be filtered via:
+ # config.filter_gems_from_backtrace("gem name")
+end
diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb
new file mode 100644
index 0000000..327b58e
--- /dev/null
+++ b/spec/spec_helper.rb
@@ -0,0 +1,94 @@
+# This file was generated by the `rails generate rspec:install` command. Conventionally, all
+# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
+# The generated `.rspec` file contains `--require spec_helper` which will cause
+# this file to always be loaded, without a need to explicitly require it in any
+# files.
+#
+# Given that it is always loaded, you are encouraged to keep this file as
+# light-weight as possible. Requiring heavyweight dependencies from this file
+# will add to the boot time of your test suite on EVERY test run, even for an
+# individual file that may not need all of that loaded. Instead, consider making
+# a separate helper file that requires the additional dependencies and performs
+# the additional setup, and require it from the spec files that actually need
+# it.
+#
+# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
+RSpec.configure do |config|
+ # rspec-expectations config goes here. You can use an alternate
+ # assertion/expectation library such as wrong or the stdlib/minitest
+ # assertions if you prefer.
+ config.expect_with :rspec do |expectations|
+ # This option will default to `true` in RSpec 4. It makes the `description`
+ # and `failure_message` of custom matchers include text for helper methods
+ # defined using `chain`, e.g.:
+ # be_bigger_than(2).and_smaller_than(4).description
+ # # => "be bigger than 2 and smaller than 4"
+ # ...rather than:
+ # # => "be bigger than 2"
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
+ end
+
+ # rspec-mocks config goes here. You can use an alternate test double
+ # library (such as bogus or mocha) by changing the `mock_with` option here.
+ config.mock_with :rspec do |mocks|
+ # Prevents you from mocking or stubbing a method that does not exist on
+ # a real object. This is generally recommended, and will default to
+ # `true` in RSpec 4.
+ mocks.verify_partial_doubles = true
+ end
+
+ # This option will default to `:apply_to_host_groups` in RSpec 4 (and will
+ # have no way to turn it off -- the option exists only for backwards
+ # compatibility in RSpec 3). It causes shared context metadata to be
+ # inherited by the metadata hash of host groups and examples, rather than
+ # triggering implicit auto-inclusion in groups with matching metadata.
+ config.shared_context_metadata_behavior = :apply_to_host_groups
+
+# The settings below are suggested to provide a good initial experience
+# with RSpec, but feel free to customize to your heart's content.
+=begin
+ # This allows you to limit a spec run to individual examples or groups
+ # you care about by tagging them with `:focus` metadata. When nothing
+ # is tagged with `:focus`, all examples get run. RSpec also provides
+ # aliases for `it`, `describe`, and `context` that include `:focus`
+ # metadata: `fit`, `fdescribe` and `fcontext`, respectively.
+ config.filter_run_when_matching :focus
+
+ # Allows RSpec to persist some state between runs in order to support
+ # the `--only-failures` and `--next-failure` CLI options. We recommend
+ # you configure your source control system to ignore this file.
+ config.example_status_persistence_file_path = "spec/examples.txt"
+
+ # Limits the available syntax to the non-monkey patched syntax that is
+ # recommended. For more details, see:
+ # https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/
+ config.disable_monkey_patching!
+
+ # Many RSpec users commonly either run the entire suite or an individual
+ # file, and it's useful to allow more verbose output when running an
+ # individual spec file.
+ if config.files_to_run.one?
+ # Use the documentation formatter for detailed output,
+ # unless a formatter has already been configured
+ # (e.g. via a command-line flag).
+ config.default_formatter = "doc"
+ end
+
+ # Print the 10 slowest examples and example groups at the
+ # end of the spec run, to help surface which specs are running
+ # particularly slow.
+ config.profile_examples = 10
+
+ # Run specs in random order to surface order dependencies. If you find an
+ # order dependency and want to debug it, you can fix the order by providing
+ # the seed, which is printed after each run.
+ # --seed 1234
+ config.order = :random
+
+ # Seed global randomization in this process using the `--seed` CLI option.
+ # Setting this allows you to use `--seed` to deterministically reproduce
+ # test failures related to randomization by passing the same `--seed` value
+ # as the one that triggered the failure.
+ Kernel.srand config.seed
+=end
+end
diff --git a/spec/tasks/scenarios_spec.rb b/spec/tasks/scenarios_spec.rb
new file mode 100644
index 0000000..4e3e5c2
--- /dev/null
+++ b/spec/tasks/scenarios_spec.rb
@@ -0,0 +1,73 @@
+require "rails_helper"
+require "rake"
+
+RSpec.describe "data:plant_scenarios" do
+ let!(:orders) do
+ create_list(:order, 5).each do |order|
+ create(:order_item, order: order)
+ create(:payment, order: order)
+ end
+ end
+
+ before do
+ Rails.application.load_tasks unless Rake::Task.task_defined?("data:plant_scenarios")
+ allow($stdout).to receive(:puts)
+ end
+
+ def plant_scenarios
+ %w[data:generate_shipments data:plant_scenarios].each { |name| Rake::Task[name].reenable }
+ Rake::Task["data:plant_scenarios"].invoke
+ end
+
+ it "creates a shipment for every order that was shipped" do
+ plant_scenarios
+
+ expect(Shipment.count).to eq(5)
+ end
+
+ it "plants a delayed shipment: in transit for more than 12 days" do
+ plant_scenarios
+ delayed = orders[0].reload
+
+ expect(delayed.shipped_at).to be < 12.days.ago
+ expect(delayed.delivered_at).to be_nil
+ expect(delayed.shipment.status).to eq("in_transit")
+ end
+
+ it "plants a lost package: carrier status exception" do
+ plant_scenarios
+
+ expect(orders[1].reload.shipment.status).to eq("exception")
+ end
+
+ it "plants a closed return window: delivered more than 30 days ago" do
+ plant_scenarios
+ expired = orders[2].reload
+
+ expect(expired.status).to eq("delivered")
+ expect(expired.delivered_at).to be < 30.days.ago
+ end
+
+ it "plants a final sale item inside the return window" do
+ plant_scenarios
+ final_sale = orders[3].reload
+
+ expect(final_sale.delivered_at).to be > 30.days.ago
+ expect(final_sale.products.first.final_sale).to be(true)
+ end
+
+ it "plants a duplicate charge: two identical payments" do
+ plant_scenarios
+ first, second = orders[4].payments.order(:sequence)
+
+ expect(second.amount).to eq(first.amount)
+ expect(second.payment_method).to eq(first.payment_method)
+ end
+
+ it "does not stack another duplicate when run twice" do
+ plant_scenarios
+ plant_scenarios
+
+ expect(orders[4].payments.count).to eq(2)
+ end
+end
diff --git a/test/controllers/.keep b/test/controllers/.keep
deleted file mode 100644
index e69de29..0000000
diff --git a/test/fixtures/files/.keep b/test/fixtures/files/.keep
deleted file mode 100644
index e69de29..0000000
diff --git a/test/helpers/.keep b/test/helpers/.keep
deleted file mode 100644
index e69de29..0000000
diff --git a/test/integration/.keep b/test/integration/.keep
deleted file mode 100644
index e69de29..0000000
diff --git a/test/mailers/.keep b/test/mailers/.keep
deleted file mode 100644
index e69de29..0000000
diff --git a/test/models/.keep b/test/models/.keep
deleted file mode 100644
index e69de29..0000000
diff --git a/test/test_helper.rb b/test/test_helper.rb
deleted file mode 100644
index 0c22470..0000000
--- a/test/test_helper.rb
+++ /dev/null
@@ -1,15 +0,0 @@
-ENV["RAILS_ENV"] ||= "test"
-require_relative "../config/environment"
-require "rails/test_help"
-
-module ActiveSupport
- class TestCase
- # Run tests in parallel with specified workers
- parallelize(workers: :number_of_processors)
-
- # Setup all fixtures in test/fixtures/*.yml for all tests in alphabetical order.
- fixtures :all
-
- # Add more helper methods to be used by all tests here...
- end
-end