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