chore: init commit

in worker.../repo/GITFOLDER.zip is the .git folder.
This commit is contained in:
2026-08-11 14:44:09 -04:00
parent 0012380fd3
commit 392781f7aa
781 changed files with 65944 additions and 0 deletions

View File

@@ -0,0 +1,23 @@
# frozen_string_literal: true
require "test_helper"
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
include Devise::Test::IntegrationHelpers
# You can run the system test in an actual browser for visually validate the system test.
# eg. TEST_DRIVER=chrome rails test test/system/student_trading_flow_test.rb
# note: it does NOT work with Docker (needs standalone chrome + VNC)
driven_by :selenium,
using: ENV.fetch("TEST_DRIVER", "headless_chrome").to_sym,
screen_size: [1400, 1400] do |driver_option|
driver_option.add_argument("--no-sandbox")
driver_option.add_argument("--disable-dev-shm-usage")
end
private
def auto_accept_confirmations
page.execute_script("window.confirm = () => true")
end
end

View File

@@ -0,0 +1,15 @@
# frozen_string_literal: true
require "test_helper"
module ApplicationCable
class ConnectionTest < ActionCable::Connection::TestCase
# test "connects with cookies" do
# cookies.signed[:user_id] = 42
#
# connect
#
# assert_equal connection.user_id, "42"
# end
end
end

View File

@@ -0,0 +1,40 @@
# frozen_string_literal: true
require "test_helper"
class ScheduleTest < ActiveSupport::TestCase
test "recurring configuration file exists and is valid" do
recurring_file = Rails.root.join("config/recurring.yml")
assert File.exist?(recurring_file), "config/recurring.yml should exist"
assert_nothing_raised do
config = YAML.load_file(recurring_file, aliases: true)
assert_not_nil config, "recurring.yml should contain valid YAML configuration"
end
end
test "recurring configuration includes required jobs" do
recurring_file = Rails.root.join("config/recurring.yml")
content = File.read(recurring_file)
assert_match(/OrderExecutionJob/, content)
assert_match(/MonthlyPortfolioSnapshotJob/, content)
config = YAML.load_file(recurring_file, aliases: true)
default_config = config["development"] || config["test"] || config["production"]
assert_not_nil default_config, "Should have configuration for at least one environment"
assert default_config.key?("daily_order_execution"), "Should include daily_order_execution job"
assert default_config.key?("monthly_portfolio_snapshot"), "Should include monthly_portfolio_snapshot job"
end
test "scheduled job classes exist and can be loaded" do
assert_nothing_raised do
StockPricesUpdateJob
end
assert_equal ApplicationJob, OrderExecutionJob.superclass
assert_equal ApplicationJob, MonthlyPortfolioSnapshotJob.superclass
assert_equal ApplicationJob, StockPricesUpdateJob.superclass
end
end

View File

@@ -0,0 +1,202 @@
# frozen_string_literal: true
require "test_helper"
module Admin
class AnnouncementsControllerTest < ActionDispatch::IntegrationTest
setup do
@admin = create(:admin, admin: true)
sign_in(@admin)
@announcement = Announcement.create!(
title: "Test Announcement",
content: "Test content"
)
end
# Authorization tests
test "non-admin cannot access index" do
sign_out(@admin)
sign_in(create(:teacher))
get admin_announcements_path
assert_redirected_to root_path
assert_equal "Access denied. Admin privileges required.", flash[:alert]
end
test "unauthenticated user is redirected to sign in" do
sign_out(@admin)
get admin_announcements_path
assert_redirected_to new_user_session_path
end
# Index tests
test "should get index" do
get admin_announcements_path
assert_response :success
end
test "index lists all announcements" do
announcement2 = Announcement.create!(title: "Second Announcement", content: "More content")
get admin_announcements_path
assert_response :success
assert_match @announcement.title, response.body
assert_match announcement2.title, response.body
end
test "index sorts by created_at ascending by default" do
get admin_announcements_path
assert_response :success
end
test "index sorts by column when sort param is provided" do
get admin_announcements_path, params: { sort: "title", direction: "asc" }
assert_response :success
end
test "index sorts descending when direction is desc" do
get admin_announcements_path, params: { sort: "title", direction: "desc" }
assert_response :success
end
# Show tests
test "should show announcement" do
get admin_announcement_path(@announcement)
assert_response :success
assert_match @announcement.title, response.body
end
# New tests
test "should get new" do
get new_admin_announcement_path
assert_response :success
end
# Create tests
test "should create announcement" do
assert_difference("Announcement.count") do
post admin_announcements_path, params: {
announcement: {
title: "New Announcement",
content: "New content",
featured: false
}
}
end
assert_redirected_to admin_announcement_path(Announcement.last)
assert_equal "Announcement created successfully.", flash[:notice]
end
test "should create featured announcement" do
assert_difference("Announcement.count") do
post admin_announcements_path, params: {
announcement: {
title: "Featured Announcement",
content: "Important content",
featured: true
}
}
end
assert Announcement.last.featured?
end
test "should not create announcement without title" do
assert_no_difference("Announcement.count") do
post admin_announcements_path, params: {
announcement: {
title: "",
content: "Some content"
}
}
end
assert_response :unprocessable_content
end
test "should not create announcement without content" do
assert_no_difference("Announcement.count") do
post admin_announcements_path, params: {
announcement: {
title: "A title",
content: ""
}
}
end
assert_response :unprocessable_content
end
# Edit tests
test "should get edit" do
get edit_admin_announcement_path(@announcement)
assert_response :success
end
# Update tests
test "should update announcement" do
patch admin_announcement_path(@announcement), params: {
announcement: {
title: "Updated Title"
}
}
assert_redirected_to admin_announcement_path(@announcement)
assert_equal "Announcement updated successfully.", flash[:notice]
assert_equal "Updated Title", @announcement.reload.title
end
test "should not update announcement with invalid params" do
patch admin_announcement_path(@announcement), params: {
announcement: {
title: ""
}
}
assert_response :unprocessable_content
assert_equal "Test Announcement", @announcement.reload.title
end
# Destroy tests
test "should destroy announcement" do
assert_difference("Announcement.count", -1) do
delete admin_announcement_path(@announcement)
end
assert_redirected_to admin_announcements_path
assert_equal "Announcement deleted successfully.", flash[:notice]
end
test "non-admin cannot destroy announcement" do
sign_out(@admin)
sign_in(create(:teacher))
assert_no_difference("Announcement.count") do
delete admin_announcement_path(@announcement)
end
assert_redirected_to root_path
end
end
end

View File

@@ -0,0 +1,32 @@
# frozen_string_literal: true
require "test_helper"
module Admin
class BaseControllerTest < ActionDispatch::IntegrationTest
test "admin can access admin v2 dashboard" do
admin = create(:admin, admin: true)
sign_in(admin)
get admin_root_path
assert_response :success
end
test "non-admin cannot access admin v2 dashboard" do
teacher = create(:teacher)
sign_in(teacher)
get admin_root_path
assert_redirected_to root_path
assert_equal "Access denied. Admin privileges required.", flash[:alert]
end
test "unauthenticated user redirected to sign in" do
get admin_root_path
assert_redirected_to new_user_session_path
end
end
end

View File

@@ -0,0 +1,165 @@
# frozen_string_literal: true
require "test_helper"
module Admin
class ClassroomsControllerTest < ActionDispatch::IntegrationTest
test "index" do
classroom1 = create(:classroom, name: "Bravo")
classroom2 = create(:classroom, name: "Charlie")
classroom3 = create(:classroom, name: "Alpha")
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get admin_classrooms_path
rows = css_select("tbody tr[id^='classroom_']")
ordered_row_ids = rows.pluck("id")
assert_response :success
assert_select "h3", "Classrooms"
assert_equal(
[dom_id(classroom3), dom_id(classroom1), dom_id(classroom2)],
ordered_row_ids
)
end
test "show" do
grade9 = create(:grade, level: 9)
grade10 = create(:grade, level: 10)
classroom = create(:classroom, grades: [grade9, grade10])
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get admin_classroom_path(classroom)
assert_response :success
assert_select "h2", classroom.name
assert_select "[data-testid='grades_display'] dd", text: "9th-10th"
end
test "should get new" do
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get new_admin_classroom_path
assert_response :success
assert_select "h1", "New Classroom"
end
test "create" do
grade = create(:grade, level: 7)
school_year = create(:school_year)
params = {
classroom: {
name: "Abc123",
grade_ids: [grade.id],
school_year_id: school_year.id
}
}
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
assert_difference("Classroom.count") do
post(admin_classrooms_path, params:)
end
assert_redirected_to admin_classroom_path(Classroom.last)
assert_equal "Classroom created successfully.", flash[:notice]
end
test "create with invalid params" do
params = { classroom: { name: "" } }
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
assert_no_difference("Classroom.count") do
post(admin_classrooms_path, params:)
end
assert_response :unprocessable_entity
end
test "edit" do
classroom = create(:classroom)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get edit_admin_classroom_path(classroom)
assert_response :success
assert_select "h1", "Edit Classroom"
end
test "update" do
name = "Abc123"
classroom = create(:classroom)
params = { classroom: { name: } }
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
patch(admin_classroom_path(classroom), params:)
assert_redirected_to admin_classroom_path(classroom)
assert_equal "Classroom updated successfully.", flash[:notice]
assert_equal name, classroom.reload.name
end
test "update with invalid params" do
classroom = create(:classroom)
params = { classroom: { name: "" } }
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
patch(admin_classroom_path(classroom), params:)
assert_response :unprocessable_entity
end
test "toggle_archive" do
classroom = create(:classroom, archived: false)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
patch toggle_archive_admin_classroom_path(classroom)
classroom.reload
assert_redirected_to admin_classroom_path(classroom)
assert_equal "Classroom has been archived.", flash[:notice]
assert classroom.archived?
end
test "activate via toggle_archive" do
classroom = create(:classroom, archived: true)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
patch toggle_archive_admin_classroom_path(classroom)
classroom.reload
assert_redirected_to admin_classroom_path(classroom)
assert_equal "Classroom has been activated.", flash[:notice]
assert_not classroom.archived?
end
test "index when teacher" do
teacher = create(:teacher)
sign_in(teacher)
get admin_classrooms_path
assert_redirected_to root_path
assert_equal "Access denied. Admin privileges required.", flash[:alert]
end
test "toggle archive when teacher" do
classroom = create(:classroom)
teacher = create(:teacher)
sign_in(teacher)
patch toggle_archive_admin_classroom_path(classroom)
assert_redirected_to root_path
end
end
end

View File

@@ -0,0 +1,33 @@
# frozen_string_literal: true
require "test_helper"
module Admin
class DashboardControllerTest < ActionDispatch::IntegrationTest
test "admin can access admin dashboard" do
admin = create(:admin, admin: true)
sign_in(admin)
get admin_root_path
assert_response :success
assert_select "h1", "Admin Dashboard"
end
test "non-admin cannot access admin dashboard" do
teacher = create(:teacher)
sign_in(teacher)
get admin_root_path
assert_redirected_to root_path
assert_equal "Access denied. Admin privileges required.", flash[:alert]
end
test "unauthenticated user redirected to sign in" do
get admin_root_path
assert_redirected_to new_user_session_path
end
end
end

View File

@@ -0,0 +1,133 @@
# frozen_string_literal: true
require "test_helper"
module Admin
class PortfolioTransactionsControllerTest < ActionDispatch::IntegrationTest
test "show" do
portfolio = build(:portfolio)
create(:student, portfolio:)
portfolio_transaction = create(:portfolio_transaction, portfolio:)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get admin_portfolio_transaction_path(portfolio_transaction)
assert_response :success
end
test "new" do
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get new_admin_portfolio_transaction_path
assert_response :success
end
test "create" do
portfolio = build(:portfolio)
create(:student, portfolio:)
params = {
portfolio_transaction: {
portfolio_id: portfolio.id,
transaction_type: "deposit",
reason: "awards",
description: "LSP's royal allowance",
amount_cents: 5000
}
}
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
assert_difference("PortfolioTransaction.count") do
post(admin_portfolio_transactions_path, params:)
end
portfolio_transaction = PortfolioTransaction.last
assert_redirected_to(
admin_portfolio_transaction_path(portfolio_transaction)
)
assert_equal(
"Portfolio transaction created successfully.",
flash[:notice]
)
end
test "create with invalid params" do
params = { portfolio_transaction: { portfolio_id: "" } }
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
assert_no_difference("PortfolioTransaction.count") do
post(admin_portfolio_transactions_path, params:)
end
assert_response :unprocessable_content
end
test "edit" do
portfolio = build(:portfolio)
create(:student, portfolio:)
portfolio_transaction = create(:portfolio_transaction, portfolio:)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get edit_admin_portfolio_transaction_path(portfolio_transaction)
assert_response :success
end
test "update" do
description = "Finn's dungeon loot"
amount_cents = 7_500
portfolio = build(:portfolio)
create(:student, portfolio:)
portfolio_transaction = create(:portfolio_transaction, portfolio:)
params = { portfolio_transaction: { description:, amount_cents: } }
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
patch(admin_portfolio_transaction_path(portfolio_transaction), params:)
portfolio_transaction.reload
assert_redirected_to(
admin_portfolio_transaction_path(portfolio_transaction)
)
assert_equal(
"Portfolio transaction updated successfully.",
flash[:notice]
)
assert_equal description, portfolio_transaction.description
assert_equal amount_cents, portfolio_transaction.amount_cents
end
test "update with invalid params" do
portfolio = build(:portfolio)
create(:student, portfolio:)
portfolio_transaction = create(:portfolio_transaction, portfolio:)
params = { portfolio_transaction: { portfolio_id: "" } }
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
patch(admin_portfolio_transaction_path(portfolio_transaction), params:)
assert_response :unprocessable_content
end
test "destroy" do
portfolio = build(:portfolio)
create(:student, portfolio:)
portfolio_transaction = create(:portfolio_transaction, portfolio:)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
assert_difference("PortfolioTransaction.count", -1) do
delete admin_portfolio_transaction_path(portfolio_transaction)
end
assert_redirected_to admin_root_path
assert_equal "Portfolio transaction deleted successfully.", flash[:notice]
end
end
end

View File

@@ -0,0 +1,158 @@
# frozen_string_literal: true
require "test_helper"
module Admin
class SchoolYearsControllerTest < ActionDispatch::IntegrationTest
test "index" do
create(:school_year)
create(:school_year)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get admin_school_years_path
assert_response :success
assert_select "h3", "School Years"
assert_select "tbody tr", count: 2
end
test "show" do
school_name = "Candy Kingdom"
year_name = "1000"
school = create(:school, name: school_name)
year = create(:year, name: year_name)
school_year = create(:school_year, school:, year:)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get admin_school_year_path(school_year)
assert_response :success
assert_select "h2", "#{school_name} (#{year_name})"
assert_select "h3", "Quarters"
end
test "new" do
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get new_admin_school_year_path
assert_response :success
assert_select "h1", "New School Year"
end
test "create" do
school = create(:school)
year = create(:year)
params = { school_year: { school_id: school.id, year_id: year.id } }
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
assert_difference("SchoolYear.count") do
post(admin_school_years_path, params:)
end
school_year = SchoolYear.last
assert_redirected_to admin_school_year_path(school_year)
assert_equal "School year created successfully.", flash[:notice]
assert_equal(
["Quarter 1", "Quarter 2", "Quarter 3", "Quarter 4"],
school_year.quarters.order(:number).pluck(:name)
)
end
test "create with invalid params" do
params = { school_year: { school_id: "" } }
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
assert_no_difference("SchoolYear.count") do
post(admin_school_years_path, params:)
end
assert_response :unprocessable_content
end
test "create with duplicate school year shows error" do
school = create(:school)
year = create(:year)
create(:school_year, school:, year:)
params = { school_year: { school_id: school.id, year_id: year.id } }
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
assert_no_difference("SchoolYear.count") do
post(admin_school_years_path, params:)
end
assert_response :unprocessable_content
assert_select "p.text-red-600", /already exists/
end
test "edit" do
school_year = create(:school_year)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get edit_admin_school_year_path(school_year)
assert_response :success
assert_select "h1", "Edit School Year"
end
test "update" do
school_year = create(:school_year)
school = create(:school)
params = { school_year: { school_id: school.id } }
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
patch(admin_school_year_path(school_year), params:)
school_year.reload
assert_redirected_to admin_school_year_path(school_year)
assert_equal "School year updated successfully.", flash[:notice]
assert_equal school.id, school_year.school_id
end
test "update with invalid params" do
school_year = create(:school_year)
params = { school_year: { school_id: nil } }
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
patch(admin_school_year_path(school_year), params:)
assert_response :unprocessable_content
end
test "destroy" do
school_year = create(:school_year)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
assert_difference("SchoolYear.count", -1) do
delete admin_school_year_path(school_year)
end
assert_redirected_to admin_school_years_path
assert_equal "School year deleted successfully.", flash[:notice]
end
test "destroy with classrooms" do
school_year = create(:school_year)
create(:classroom, school_year:)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
assert_no_difference("SchoolYear.count") do
delete admin_school_year_path(school_year)
end
assert_redirected_to admin_school_years_path
assert_match(/Cannot delete school/, flash[:alert])
end
end
end

View File

@@ -0,0 +1,203 @@
# frozen_string_literal: true
require "test_helper"
module Admin
class SchoolsControllerTest < ActionDispatch::IntegrationTest
setup do
@admin = create(:admin, admin: true, classroom: nil)
sign_in(@admin)
end
test "index" do
create(:school, name: "Alpha School")
create(:school, name: "Beta School")
get admin_schools_path
assert_response :success
assert_select "h3", "Schools"
assert_select "tbody tr", count: 2
end
test "show" do
school = create(:school, name: "Test School")
get admin_school_path(school)
assert_response :success
assert_select "h2", school.name
end
test "show displays associated years" do
school = create(:school, name: "Test School")
year1 = create(:year, name: "2024 - 2025")
year2 = create(:year, name: "2025 - 2026")
school.years << [year1, year2]
get admin_school_path(school)
assert_response :success
assert_select "li", text: year1.name
assert_select "li", text: year2.name
end
test "new" do
get new_admin_school_path
assert_response :success
assert_select "h1", "New School"
end
test "new shows year checkboxes" do
year1 = create(:year, name: "2024 - 2025")
year2 = create(:year, name: "2025 - 2026")
get new_admin_school_path
assert_response :success
assert_select "input[type='checkbox'][name='school[year_ids][]']", count: 2
assert_select "label", text: year1.name
assert_select "label", text: year2.name
end
test "create" do
year1 = create(:year, name: "2024 - 2025")
year2 = create(:year, name: "2025 - 2026")
params = {
school: {
name: "New School",
year_ids: [year1.id, year2.id]
}
}
assert_difference("School.count") do
post(admin_schools_path, params:)
end
school = School.last
assert_redirected_to admin_school_path(school)
assert_equal "School created successfully.", flash[:notice]
assert_equal [year1, year2].sort_by(&:id), school.years.sort_by(&:id)
end
test "create without years" do
params = {
school: {
name: "School Without Years"
}
}
assert_difference("School.count") do
post(admin_schools_path, params:)
end
school = School.last
assert_redirected_to admin_school_path(school)
assert_empty school.years
end
test "create with invalid params" do
params = {
school: {
name: ""
}
}
assert_no_difference("School.count") do
post(admin_schools_path, params:)
end
assert_response :unprocessable_content
end
test "edit" do
school = create(:school, name: "Test School")
get edit_admin_school_path(school)
assert_response :success
assert_select "h1", "Edit School"
end
test "edit shows current year selections" do
year1 = create(:year, name: "2024 - 2025")
year2 = create(:year, name: "2025 - 2026")
school = create(:school, name: "Test School")
school.years << year1
get edit_admin_school_path(school)
assert_response :success
assert_select "input[type='checkbox'][name='school[year_ids][]'][value='#{year1.id}'][checked='checked']"
assert_select "input[type='checkbox'][name='school[year_ids][]'][value='#{year2.id}']:not([checked])"
end
test "update" do
year1 = create(:year, name: "2024 - 2025")
year2 = create(:year, name: "2025 - 2026")
school = create(:school, name: "Original Name")
school.years << year1
params = {
school: {
name: "Updated Name",
year_ids: [year2.id]
}
}
patch(admin_school_path(school), params:)
school.reload
assert_redirected_to admin_school_path(school)
assert_equal "School updated successfully.", flash[:notice]
assert_equal "Updated Name", school.name
assert_equal [year2], school.years
end
test "update can remove all years" do
year1 = create(:year, name: "2024 - 2025")
school = create(:school, name: "Test School")
school.years << year1
params = {
school: {
name: school.name,
year_ids: []
}
}
patch(admin_school_path(school), params:)
school.reload
assert_redirected_to admin_school_path(school)
assert_empty school.years
end
test "update with invalid params" do
school = create(:school, name: "Test School")
params = { school: { name: "" } }
patch(admin_school_path(school), params:)
assert_response :unprocessable_content
end
test "destroy" do
school = create(:school, name: "Test School")
assert_difference("School.count", -1) do
delete admin_school_path(school)
end
assert_redirected_to admin_schools_path
end
test "cannot destroy school with associated years" do
school = create(:school, name: "Test School")
school.years << create(:year, name: "2025 - 2026")
assert_no_difference("School.count") do
delete admin_school_path(school)
end
end
end
end

View File

@@ -0,0 +1,170 @@
# frozen_string_literal: true
require "test_helper"
module Admin
class StocksControllerTest < ActionDispatch::IntegrationTest
setup do
@admin = create(:admin, admin: true)
sign_in(@admin)
@stock1 = Stock.create!(ticker: "AAPL", company_name: "Apple Inc.", price_cents: 15_000, archived: false)
@stock2 = Stock.create!(ticker: "GOOGL", company_name: "Alphabet Inc.", price_cents: 14_000, archived: false)
@stock3 = Stock.create!(ticker: "MSFT", company_name: "Microsoft Corp.", price_cents: 38_000, archived: true)
end
# Index tests
test "should get index" do
get admin_stocks_path
assert_response :success
assert_select "h3", "Stocks"
end
test "index shows all stocks" do
get admin_stocks_path
assert_response :success
assert_select "tbody tr", count: 3
end
# Show tests
test "should show stock" do
get admin_stock_path(@stock1)
assert_response :success
assert_select "h2", @stock1.ticker
end
test "should show stock price information" do
get admin_stock_path(@stock1)
assert_response :success
assert_select "h3", "Price Information"
assert_select "dt", text: "Current Price"
end
# New tests
test "should get new" do
get new_admin_stock_path
assert_response :success
assert_select "h1", "New Stock"
end
# Create tests
test "should create stock" do
assert_difference("Stock.count") do
post admin_stocks_path, params: {
stock: {
ticker: "TSLA",
company_name: "Tesla Inc.",
company_website: "https://www.tesla.com",
price_cents: 25_000,
yesterday_price_cents: 24_000,
archived: false
}
}
end
assert_redirected_to admin_stock_path(Stock.last)
assert_equal "Stock created successfully.", flash[:notice]
end
test "should not create stock with invalid params" do
assert_no_difference("Stock.count") do
post admin_stocks_path, params: {
stock: {
ticker: "",
company_name: ""
}
}
end
assert_response :unprocessable_content
end
test "should not create stock with invalid website URL" do
assert_no_difference("Stock.count") do
post admin_stocks_path, params: {
stock: {
ticker: "TEST",
company_name: "Test Company",
company_website: "not-a-valid-url"
}
}
end
assert_response :unprocessable_content
end
# Edit tests
test "should get edit" do
get edit_admin_stock_path(@stock1)
assert_response :success
assert_select "h1", "Edit Stock"
end
# Update tests
test "should update stock" do
patch admin_stock_path(@stock1), params: {
stock: {
company_name: "Apple Incorporated"
}
}
assert_redirected_to admin_stock_path(@stock1)
assert_equal "Stock updated successfully.", flash[:notice]
assert_equal "Apple Incorporated", @stock1.reload.company_name
end
test "should not update stock with invalid params" do
patch admin_stock_path(@stock1), params: {
stock: {
ticker: ""
}
}
assert_response :unprocessable_content
end
# Destroy tests
test "should destroy stock" do
assert_difference("Stock.count", -1) do
delete admin_stock_path(@stock1)
end
assert_redirected_to admin_stocks_path
assert_equal "Stock deleted successfully.", flash[:notice]
end
# Authorization tests
test "non-admin cannot access index" do
sign_out(@admin)
classroom = create(:classroom)
student = User.create!(username: "student", type: "Student", password: "password", classroom: classroom)
sign_in(student)
get admin_stocks_path
assert_redirected_to root_path
assert_equal "Access denied. Admin privileges required.", flash[:alert]
end
test "non-admin cannot create stock" do
sign_out(@admin)
teacher = create(:teacher)
sign_in(teacher)
post admin_stocks_path, params: {
stock: {
ticker: "TEST",
company_name: "Test"
}
}
assert_redirected_to root_path
end
end
end

View File

@@ -0,0 +1,482 @@
# frozen_string_literal: true
require "test_helper"
module Admin
class StudentsControllerTest < ActionDispatch::IntegrationTest
setup do
@classroom1 = create(:classroom)
@classroom2 = create(:classroom)
@admin = create(:admin, admin: true, classroom: nil)
sign_in(@admin)
end
test "index" do
create(:student)
create(:student)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get admin_students_path
assert_response :success
assert_select "h3", "Students"
assert_select "tbody tr", count: 2
end
test "index with discarded filter" do
student = create(:student, :discarded)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get admin_students_path(discarded: true)
assert_response :success
assert_select "tbody tr", count: 1
assert_select "form[action=?]", restore_admin_student_path(student) do
assert_select "button", text: "Restore"
end
end
test "index with all filter" do
create(:student)
create(:student)
create(:student, :discarded)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get admin_students_path(all: true)
assert_response :success
assert_select "tbody tr", count: 3
assert_select "a[href*='edit']", count: 2
end
test "show" do
username = "finn"
student = create(:student, username:)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get admin_student_path(student)
assert_response :success
assert_select "h2", username
assert_select "h3", text: "Portfolio Details"
assert_select(
"[data-testid='cash_balance_label']",
text: "Cash Balance"
)
assert_select(
"[data-testid='total_portfolio_worth_label']",
text: "Total Portfolio Worth"
)
end
test "show displays attendance records" do
student = create(:student)
quarter = student.classroom.school_year.quarters.find_by!(number: 1)
grade_book = student.classroom.grade_books.find_by!(quarter: quarter)
create(:grade_entry, grade_book: grade_book, user: student, attendance_days: 42, is_perfect_attendance: true)
get admin_student_path(student)
assert_response :success
assert_select "h3", text: "Attendance"
assert_select "tbody tr", minimum: 1
assert_select "td", text: "Q1"
assert_select "td", text: "42"
end
test "show displays empty attendance message when no records" do
student = create(:student)
get admin_student_path(student)
assert_response :success
assert_select "p", text: "No attendance records found."
end
test "show displays earnings summary" do
student = create(:student)
portfolio = student.portfolio
portfolio.portfolio_transactions.create!(
amount_cents: 500, transaction_type: :deposit,
reason: :attendance_earnings
)
portfolio.portfolio_transactions.create!(amount_cents: 300, transaction_type: :deposit, reason: :math_earnings)
portfolio.portfolio_transactions.create!(amount_cents: 200, transaction_type: :deposit, reason: :awards)
get admin_student_path(student)
assert_response :success
assert_select "h4", text: "Earnings Summary"
assert_select "td", text: "Attendance Earnings"
assert_select "td", text: "Math Earnings"
assert_select "td", text: "Rewards"
assert_select "td", text: "Total Earnings"
assert_select "td", text: "Transaction Fees"
end
test "new" do
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get new_admin_student_path
assert_response :success
assert_select "h1", "New Student"
end
test "create" do
username = "jake"
classroom = create(:classroom, name: "Ice Kingdom")
params = {
student: {
username:,
classroom_id: classroom.id,
password: "password123",
password_confirmation: "password123"
}
}
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
assert_difference(["Student.count", "Portfolio.count"]) do
post(admin_students_path, params:)
end
student = Student.last
assert_redirected_to admin_student_path(student)
assert_equal(
"Student #{username} created successfully. Password: password123",
flash[:notice]
)
assert_not_nil student.portfolio
end
test "create with invalid params" do
params = { student: { username: "", classroom_id: nil } }
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
assert_no_difference("Student.count") do
post(admin_students_path, params:)
end
assert_response :unprocessable_content
end
test "edit" do
student = create(:student)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get edit_admin_student_path(student)
assert_response :success
assert_select "h1", "Edit Student"
end
test "update" do
username = "marceline"
student = create(:student)
params = { student: { username: } }
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
patch(admin_student_path(student), params:)
student.reload
assert_redirected_to admin_student_path(student)
assert_equal "Student updated successfully.", flash[:notice]
assert_equal username, student.username
end
test "update with invalid params" do
student = create(:student)
params = { student: { username: "" } }
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
patch(admin_student_path(student), params:)
assert_response :unprocessable_content
end
test "destroy" do
username = "gunter"
student = create(:student, username:)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
assert_no_difference("Student.count") do
delete admin_student_path(student)
end
student.reload
assert_redirected_to admin_students_path
assert_equal "Student #{username} archived successfully.", flash[:notice]
assert student.discarded?
end
test "restore" do
username = "lsp"
student = create(:student, :discarded, username:)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
patch restore_admin_student_path(student)
student.reload
assert_redirected_to admin_students_path(discarded: true)
assert_equal "Student #{username} restored successfully.", flash[:notice]
assert_not student.discarded?
end
test "add_transaction" do
portfolio = build(:portfolio, user: nil)
student = create(:student, portfolio:)
create(:portfolio_transaction, :deposit, portfolio:, amount_cents: 10_000)
params = {
transaction_type: "deposit",
add_fund_amount: "100.50",
transaction_reason: "awards",
transaction_description: "Test deposit"
}
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
post(add_transaction_admin_student_path(student), params:)
portfolio.reload
assert_redirected_to admin_student_path(student)
assert_equal "Transaction added successfully.", flash[:notice]
assert_equal 20_050, portfolio.cash_balance * 100
end
test "add_transaction debit" do
student = create(:student)
params = {
transaction_type: "debit",
add_fund_amount: "50.25",
transaction_reason: "administrative_adjustments",
transaction_description: "Test debit"
}
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
post(add_transaction_admin_student_path(student), params:)
transaction = student.portfolio.portfolio_transactions.last
assert_redirected_to admin_student_path(student)
assert_equal "debit", transaction.transaction_type
assert_equal 5_025, transaction.amount_cents
end
test "add_transaction invalid params" do
student = create(:student)
params = { transaction_type: "" }
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
post(add_transaction_admin_student_path(student), params:)
expected_error_message =
"Transaction Type must be present, Amount must be present, " \
"Reason must be present"
assert_redirected_to edit_admin_student_path(student)
assert_equal expected_error_message, flash[:alert]
end
test "add_transaction params are not nested under student key" do
student = create(:student)
params = {
student: {
transaction_type: "deposit",
add_fund_amount: "50.00",
transaction_reason: "awards",
transaction_description: "Nested params should be ignored"
}
}
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
assert_no_difference("PortfolioTransaction.count") do
post(add_transaction_admin_student_path(student), params:)
end
assert_redirected_to edit_admin_student_path(student)
end
# Import/Template tests
test "template should download CSV template" do
get template_admin_students_path
assert_response :success
assert_equal "text/csv", response.media_type
assert_includes response.headers["Content-Disposition"], "attachment; filename=\"student_import_template.csv\""
assert_match(/classroom_id,username/, response.body)
end
test "import should create students from valid CSV" do
csv_content = "classroom_id,username\n#{@classroom1.id},import_student1\n#{@classroom2.id},import_student2"
csv_file = Tempfile.new(["test_import", ".csv"])
csv_file.write(csv_content)
csv_file.rewind
assert_difference("Student.count", 2) do
post import_admin_students_path, params: {
csv_file: fixture_file_upload(csv_file.path, "text/csv")
}
end
csv_file.close
csv_file.unlink
assert_redirected_to admin_students_path
assert_match(/Successfully created 2 students/, flash[:notice])
assert_match(/import_student1/, flash[:notice])
assert_match(/import_student2/, flash[:notice])
end
test "import should skip existing students" do
create(:student, username: "student1", classroom: @classroom1)
csv_content = "classroom_id,username\n#{@classroom1.id},student1\n#{@classroom2.id},new_student"
csv_file = Tempfile.new(["test_import", ".csv"])
csv_file.write(csv_content)
csv_file.rewind
assert_difference("Student.count", 1) do
post import_admin_students_path, params: {
csv_file: fixture_file_upload(csv_file.path, "text/csv")
}
end
csv_file.close
csv_file.unlink
assert_redirected_to admin_students_path
assert_match(/Successfully created 1 students/, flash[:notice])
assert_match(/Skipped 1 existing usernames/, flash[:notice])
end
test "import should handle errors and show line numbers" do
csv_content = "classroom_id,username\n999,invalid_student\n#{@classroom1.id},valid_student"
csv_file = Tempfile.new(["test_import", ".csv"])
csv_file.write(csv_content)
csv_file.rewind
post import_admin_students_path, params: {
csv_file: fixture_file_upload(csv_file.path, "text/csv")
}
csv_file.close
csv_file.unlink
assert_redirected_to admin_students_path
assert_match(/errors occurred/, flash[:alert])
assert_match(/Row 2:/, flash[:alert])
end
test "import should reject missing file" do
assert_no_difference("Student.count") do
post import_admin_students_path
end
assert_redirected_to admin_students_path
assert_equal I18n.t("admin.students.import.errors.no_file"), flash[:alert]
end
test "import should handle malformed CSV" do
csv_content = "classroom_id,username\n1,\"unclosed quote\n2,another_row"
csv_file = Tempfile.new(["test_import", ".csv"])
csv_file.write(csv_content)
csv_file.rewind
assert_no_difference("Student.count") do
post import_admin_students_path, params: {
csv_file: fixture_file_upload(csv_file.path, "text/csv")
}
end
csv_file.close
csv_file.unlink
assert_redirected_to admin_students_path
assert_match(/Invalid CSV format/, flash[:alert])
end
test "import should handle empty CSV" do
csv_content = "classroom_id,username\n"
csv_file = Tempfile.new(["test_import", ".csv"])
csv_file.write(csv_content)
csv_file.rewind
assert_no_difference("Student.count") do
post import_admin_students_path, params: {
csv_file: fixture_file_upload(csv_file.path, "text/csv")
}
end
csv_file.close
csv_file.unlink
assert_redirected_to admin_students_path
assert_equal I18n.t("admin.students.import.errors.no_students"), flash[:alert]
end
test "import should show both success and error messages" do
csv_content = "classroom_id,username\n" \
"#{@classroom1.id},import_success1\n999,import_fail\n#{@classroom2.id},import_success2"
csv_file = Tempfile.new(["test_import", ".csv"])
csv_file.write(csv_content)
csv_file.rewind
post import_admin_students_path, params: {
csv_file: fixture_file_upload(csv_file.path, "text/csv")
}
csv_file.close
csv_file.unlink
assert_redirected_to admin_students_path
assert_match(/Successfully created 2 students/, flash[:notice])
assert_match(/1 errors occurred/, flash[:alert])
end
test "non-admin cannot access template" do
sign_out(@admin)
teacher = create(:teacher)
sign_in(teacher)
get template_admin_students_path
assert_redirected_to root_path
assert_equal "Access denied. Admin privileges required.", flash[:alert]
end
test "non-admin cannot import students" do
sign_out(@admin)
teacher = create(:teacher)
sign_in(teacher)
csv_content = "classroom_id,username\n#{@classroom1.id},import_student"
csv_file = Tempfile.new(["test_import", ".csv"])
csv_file.write(csv_content)
csv_file.rewind
assert_no_difference("Student.count") do
post import_admin_students_path, params: {
csv_file: fixture_file_upload(csv_file.path, "text/csv")
}
end
csv_file.close
csv_file.unlink
assert_redirected_to root_path
assert_equal "Access denied. Admin privileges required.", flash[:alert]
end
end
end

View File

@@ -0,0 +1,42 @@
# frozen_string_literal: true
require "test_helper"
module Admin
module Teachers
class DeactivationsControllerTest < ActionDispatch::IntegrationTest
setup do
@admin = create(:admin, admin: true)
sign_in(@admin)
@classroom1 = create(:classroom, name: "Math 101")
@teacher1 = create(:teacher, username: "teacher1", email: "teacher1@example.com")
@teacher1.classrooms << @classroom1
end
test "should deactivate teacher" do
assert_no_difference("Teacher.count") do
post admin_teacher_deactivation_path(@teacher1)
end
assert_redirected_to admin_teachers_path
assert_equal "Teacher teacher1@example.com deactivated successfully.", flash[:notice]
assert @teacher1.reload.discarded?
end
test "deactivate should soft delete teacher and remove from active list" do
post admin_teacher_deactivation_path(@teacher1)
# Teacher should still exist in database
assert_not_nil Teacher.with_discarded.find_by(id: @teacher1.id)
# But should not appear in kept scope
assert_nil Teacher.kept.find_by(id: @teacher1.id)
follow_redirect!
# Should not appear on the default (active) index
assert_select "##{dom_id(@teacher1)}", count: 0
end
end
end
end

View File

@@ -0,0 +1,45 @@
# frozen_string_literal: true
require "test_helper"
module Admin
module Teachers
class ReactivationsControllerTest < ActionDispatch::IntegrationTest
setup do
@admin = create(:admin, admin: true)
sign_in(@admin)
@classroom1 = create(:classroom, name: "Math 101")
@teacher1 = create(:teacher, username: "teacher1", email: "teacher1@example.com")
@teacher1.classrooms << @classroom1
end
test "should reactivate deactivated teacher" do
@teacher1.discard
assert @teacher1.discarded?
post admin_teacher_reactivation_path(@teacher1)
assert_redirected_to admin_teachers_path
assert_equal "Teacher teacher1@example.com reactivated successfully.", flash[:notice]
assert_not @teacher1.reload.discarded?
end
test "reactivate should restore teacher to active status and appear in active list" do
@teacher1.discard
assert_not_nil @teacher1.reload.discarded_at
post admin_teacher_reactivation_path(@teacher1)
# Should clear discarded_at timestamp
assert_nil @teacher1.reload.discarded_at
follow_redirect!
# Should appear on the default (active) index
assert_select "##{dom_id(@teacher1)}", count: 1
end
end
end
end

View File

@@ -0,0 +1,294 @@
# frozen_string_literal: true
require "test_helper"
module Admin
class TeachersControllerTest < ActionDispatch::IntegrationTest
test "index" do
teacher1 = create(:teacher, email: "z@example.com")
teacher2 = create(:teacher, email: "a@example.com")
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get admin_teachers_path
rows = css_select("tbody tr[id^='teacher_']")
row_ids = rows.pluck("id")
assert_response :success
assert_select "h3", "Teachers"
assert_equal [dom_id(teacher2), dom_id(teacher1)], row_ids
end
test "index sorts by username by default" do
create(:teacher, email: "teacher1@example.com")
create(:teacher, email: "teacher2@example.com")
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get admin_teachers_path
assert_response :success
# Default sort should be by username ascending (username == email for teachers)
assert_select "tbody tr:nth-child(1)", text: /teacher1/
assert_select "tbody tr:nth-child(2)", text: /teacher2/
end
test "index shows only active teachers by default" do
teacher1 = create(:teacher, username: "teacher1")
create(:teacher, username: "teacher2")
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
teacher1.discard
get admin_teachers_path
assert_response :success
assert_select "tbody tr", count: 1
assert_select "span.bg-green-50", text: "Active"
end
test "index shows both active and deactivated teachers with all filter" do
teacher1 = create(:teacher, username: "teacher1")
create(:teacher, username: "teacher2")
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
teacher1.discard
get admin_teachers_path(all: true)
assert_response :success
assert_select "tbody tr", count: 2
assert_select "span.bg-red-50", text: "Deactivated"
assert_select "span.bg-green-50", text: "Active"
end
test "index shows only deactivated teachers with discarded filter" do
teacher1 = create(:teacher, username: "teacher1")
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
teacher1.discard
get admin_teachers_path(discarded: true)
assert_response :success
assert_select "tbody tr", count: 1
assert_select "span.bg-red-50", text: "Deactivated"
end
# Show tests
test "should show teacher" do
teacher = create(:teacher)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get admin_teacher_path(teacher)
assert_response :success
assert_select "h2", teacher.username
end
test "should show teacher classrooms" do
classroom = create(:classroom, name: "Math 101")
teacher = create(:teacher)
teacher.classrooms << classroom
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get admin_teacher_path(teacher)
assert_response :success
assert_select "h2", teacher.username
assert_select "h3", "Classrooms"
assert_select "li", text: classroom.name
end
test "show when teacher has no classrooms" do
teacher = create(:teacher)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get admin_teacher_path(teacher)
assert_response :success
assert_select "p", text: "No classrooms assigned to this teacher yet."
end
test "new" do
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get new_admin_teacher_path
assert_response :success
assert_select "h1", "New Teacher"
end
test "new shows warning message when no classrooms available" do
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get new_admin_teacher_path
assert_response :success
assert_select "h1", "New Teacher"
assert_select "h3", text: "No Classrooms Available"
assert_select "p", text: /No Classrooms associated with this school and active year/
assert_select "a[href='#{admin_classrooms_path}']", text: "update classrooms"
end
test "new shows classrooms when available" do
current_year = Year.find_or_create_by(name: "2025 - 2026")
school = create(:school, name: "Test School")
school_year = create(:school_year, school: school, year: current_year)
create(:classroom, name: "Test Classroom", archived: false, school_year: school_year)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get new_admin_teacher_path
assert_response :success
assert_select "input[type='checkbox'][name='teacher[classroom_ids][]']"
assert_select "label", text: "Test Classroom"
assert_select "h3", text: "No Classrooms Available", count: 0
end
test "create" do
classroom1 = create(:classroom, name: "Ice Kingdom")
classroom2 = create(:classroom, name: "Candy Kingdom")
params = {
teacher: {
username: "lsp",
email: "lsp@lumpyspace.com",
name: "Lumpy Space Princess",
classroom_ids: [classroom1.id, classroom2.id]
}
}
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
assert_difference("Teacher.count") do
post(admin_teachers_path, params:)
end
teacher = Teacher.last
assert_redirected_to admin_teacher_path(teacher)
assert_equal(
"Teacher created successfully. Password reset email has been sent.",
flash[:notice]
)
assert_equal [classroom1, classroom2], teacher.classrooms
assert_not_nil teacher.reset_password_token
end
test "create with invalid params" do
params = {
teacher: {
username: "",
email: "invalid"
}
}
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
assert_no_difference("Teacher.count") do
post(admin_teachers_path, params:)
end
assert_response :unprocessable_content
end
test "edit" do
teacher = create(:teacher)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get edit_admin_teacher_path(teacher)
assert_response :success
assert_select "h1", "Edit Teacher"
end
test "update" do
name = "Lumpy Space Princess"
classroom1 = create(:classroom, name: "Ice Kingdom")
classroom2 = create(:classroom, name: "Candy Kingdom")
teacher = create(:teacher, classrooms: [classroom1])
params = { teacher: { name:, classroom_ids: [classroom2.id] } }
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
patch(admin_teacher_path(teacher), params:)
teacher.reload
assert_redirected_to admin_teacher_path(teacher)
assert_equal "Teacher updated successfully.", flash[:notice]
assert_equal name, teacher.name
assert_equal [classroom2], teacher.classrooms
end
test "update with invalid params" do
teacher = create(:teacher)
params = { teacher: { email: "" } }
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
patch(admin_teacher_path(teacher), params:)
assert_response :unprocessable_content
end
# Hard delete (destroy) tests
test "should permanently delete deactivated teacher" do
teacher = create(:teacher, email: "teacher1@example.com")
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
teacher.discard
assert_difference("Teacher.with_discarded.count", -1) do
delete admin_teacher_path(teacher)
end
assert_redirected_to admin_teachers_path
assert_equal "Teacher teacher1@example.com permanently deleted.", flash[:notice]
assert_nil Teacher.with_discarded.find_by(id: teacher.id)
end
test "should not permanently delete active teacher" do
teacher = create(:teacher, username: "teacher1")
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
assert_no_difference("Teacher.with_discarded.count") do
delete admin_teacher_path(teacher)
end
assert_redirected_to edit_admin_teacher_path(teacher)
assert_equal "Teacher must be deactivated before permanent deletion.", flash[:alert]
assert_not teacher.reload.discarded?
end
test "permanent delete should remove teacher from database and DOM" do
teacher = create(:teacher, username: "teacher1")
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
teacher.discard
assert_difference("Teacher.with_discarded.count", -1) do
delete admin_teacher_path(teacher)
end
follow_redirect!
assert_select "##{dom_id(teacher)}", count: 0
end
end
end

View File

@@ -0,0 +1,144 @@
# frozen_string_literal: true
require "test_helper"
module Admin
class UsersControllerTest < ActionDispatch::IntegrationTest
test "index" do
create(:admin, username: "marceline")
create(:teacher, username: "bonnibel")
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get admin_users_path
assert_response :success
assert_select "h3", "Users"
assert_select "tbody tr", count: User.count
end
test "index sorts by username by default" do
create(:admin, username: "zzz_last")
create(:admin, username: "aaa_first")
admin = create(:admin, username: "mmm_admin", admin: true, classroom: nil)
sign_in(admin)
get admin_users_path
rows = css_select("tbody tr td:nth-child(2)").map(&:text).map(&:strip)
assert_response :success
assert_equal rows, rows.sort
end
test "index sorts descending when direction is desc" do
create(:admin, username: "zzz_last")
create(:admin, username: "aaa_first")
admin = create(:admin, username: "mmm_admin", admin: true, classroom: nil)
sign_in(admin)
get admin_users_path, params: { sort: "username", direction: "desc" }
rows = css_select("tbody tr td:nth-child(2)").map(&:text).map(&:strip)
assert_response :success
assert_equal rows, rows.sort.reverse
end
test "show" do
user = create(:admin, username: "finn")
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get admin_user_path(user)
assert_response :success
assert_select "h2", "finn"
end
test "new" do
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get new_admin_user_path
assert_response :success
assert_select "h1", "New User"
end
test "edit" do
user = create(:admin)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
get edit_admin_user_path(user)
assert_response :success
assert_select "h1", "Edit User"
end
test "create" do
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
params = {
user: {
username: "bmo",
email: "bmo@example.com",
type: "User",
admin: false,
password: "Passw0rd",
password_confirmation: "Passw0rd"
}
}
assert_difference("User.count") do
post admin_users_path, params: params
end
assert_redirected_to admin_user_path(User.last)
assert_equal "User created successfully.", flash[:notice]
end
test "create with invalid params" do
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
params = { user: { username: "", email: "bad@example.com" } }
assert_no_difference("User.count") do
post admin_users_path, params: params
end
assert_response :unprocessable_content
end
test "update" do
user = create(:admin, username: "original")
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
patch admin_user_path(user), params: { user: { username: "updated" } }
user.reload
assert_redirected_to admin_user_path(user)
assert_equal "User updated successfully.", flash[:notice]
assert_equal "updated", user.username
end
test "update with invalid params" do
user = create(:admin)
admin = create(:admin, admin: true, classroom: nil)
sign_in(admin)
patch admin_user_path(user), params: { user: { username: "" } }
assert_response :unprocessable_content
end
test "non-admin cannot access index" do
sign_in(create(:teacher))
get admin_users_path
assert_redirected_to root_path
assert_equal "Access denied. Admin privileges required.", flash[:alert]
end
end
end

View File

@@ -0,0 +1,34 @@
# frozen_string_literal: true
require "test_helper"
class AnnouncementsControllerTest < ActionDispatch::IntegrationTest
setup do
@user = create(:student)
sign_in @user
@announcement = Announcement.create!(
title: "Test Announcement",
content: "Test content"
)
end
test "should show announcement" do
get announcement_url(@announcement)
assert_response :success
end
test "students can view announcements" do
get announcement_url(@announcement)
assert_response :success
assert_match @announcement.title, response.body
end
test "teachers can view announcements" do
sign_out @user
teacher = create(:teacher)
sign_in teacher
get announcement_url(@announcement)
assert_response :success
end
end

View File

@@ -0,0 +1,59 @@
# frozen_string_literal: true
require "test_helper"
class ApplicationControllerTest < ActionDispatch::IntegrationTest
test "after_sign_in_path_for redirects students to root" do
create(:student, username: "teststudent", password: "password123")
post user_session_path, params: {
user: { username: "teststudent", password: "password123" }
}
assert_redirected_to root_path
end
test "after_sign_in_path_for redirects teachers to root" do
create(:teacher, email: "testteacher@example.com", password: "password123")
post user_session_path, params: {
user: { username: "testteacher@example.com", password: "password123" }
}
assert_redirected_to root_path
end
test "after_sign_in_path_for redirects admins to root" do
create(:admin, username: "testadmin", password: "password123")
post user_session_path, params: {
user: { username: "testadmin", password: "password123" }
}
assert_redirected_to root_path
end
test "ensure_teacher_or_admin allows teachers" do
teacher = create(:teacher)
sign_in teacher
get classrooms_path
assert_response :success
end
test "ensure_teacher_or_admin allows admins" do
admin = create(:admin)
sign_in admin
get classrooms_path
assert_response :success
end
test "ensure_teacher_or_admin blocks students" do
student = create(:student)
sign_in student
get new_classroom_path
assert_redirected_to student.portfolio_path
end
end

View File

@@ -0,0 +1,87 @@
# frozen_string_literal: true
require "test_helper"
class ClassroomEnrollmentsControllerTest < ActionDispatch::IntegrationTest
setup do
@classroom = create(:classroom)
@student = create(:student, classroom: @classroom)
@teacher = create(:teacher)
@teacher.classrooms << @classroom
end
# create
test "create enrolls student in classroom" do
other_classroom = create(:classroom)
sign_in(@teacher)
assert_difference("ClassroomEnrollment.count") do
post classroom_classroom_enrollments_path(other_classroom),
params: { classroom_enrollment: { student_id: @student.id, primary: "false" } }
end
assert_redirected_to classroom_path(other_classroom)
assert_match @student.username, flash[:notice]
assert_match other_classroom.name, flash[:notice]
end
test "create enrolls student as primary" do
other_classroom = create(:classroom)
sign_in(@teacher)
post classroom_classroom_enrollments_path(other_classroom),
params: { classroom_enrollment: { student_id: @student.id, primary: "true" } }
enrollment = ClassroomEnrollment.last
assert enrollment.primary?
assert_redirected_to classroom_path(other_classroom)
end
# destroy
test "destroy removes enrollment" do
enrollment = create(:classroom_enrollment, classroom: @classroom, student: @student)
sign_in(@teacher)
assert_difference("ClassroomEnrollment.count", -1) do
delete classroom_classroom_enrollment_path(@classroom, enrollment)
end
assert_redirected_to classroom_path(@classroom)
assert_match @student.username, flash[:notice]
assert_match @classroom.name, flash[:notice]
end
# unenroll
test "unenroll sets unenrolled_at on enrollment" do
enrollment = create(:classroom_enrollment, classroom: @classroom, student: @student)
sign_in(@teacher)
patch unenroll_classroom_classroom_enrollment_path(@classroom, enrollment)
assert enrollment.reload.historical?
assert_redirected_to classroom_path(@classroom)
assert_match @student.username, flash[:notice]
end
# authorization
test "student cannot create enrollment" do
other_classroom = create(:classroom)
sign_in(@student)
post classroom_classroom_enrollments_path(other_classroom),
params: { classroom_enrollment: { student_id: @student.id, primary: "false" } }
assert_equal "You do not have access to this page.", flash[:alert]
end
test "unauthenticated user is redirected to sign in" do
post classroom_classroom_enrollments_path(@classroom),
params: { classroom_enrollment: { student_id: @student.id, primary: "false" } }
assert_redirected_to new_user_session_path
end
end

View File

@@ -0,0 +1,405 @@
# frozen_string_literal: true
require "test_helper"
class ClassroomsControllerTest < ActionDispatch::IntegrationTest
def setup
@admin = create(:admin)
@classroom = create(:classroom)
@teacher = create(:teacher)
@teacher.classrooms << @classroom
@student = create(:student, classroom: @classroom)
end
test "index" do
sign_in(@admin)
get classrooms_path
assert_response :success
end
test "new" do
sign_in(@admin)
get new_classroom_path
assert_response :success
end
test "create" do
sign_in(@admin)
school = create(:school)
year = create(:year)
grade = create(:grade, level: 7)
params = {
classroom: {
name: "Test Class",
grade_ids: [grade.id],
school_id: school.id,
year_id: year.id
}
}
assert_difference("Classroom.count") do
post(classrooms_path, params:)
end
classroom = Classroom.last
assert_redirected_to classroom_path(classroom)
assert_includes classroom.grades, grade
assert_equal t("classrooms.create.notice"), flash[:notice]
end
test "create with multiple grades" do
sign_in(@admin)
school = create(:school)
year = create(:year)
grade1 = create(:grade)
grade2 = create(:grade)
grade3 = create(:grade)
params = {
classroom: {
name: "Combined Class",
grade_ids: [grade1.id, grade2.id, grade3.id],
school_id: school.id,
year_id: year.id
}
}
assert_difference("Classroom.count", 1) do
assert_difference("ClassroomGrade.count", 3) do
post classrooms_path, params: params
end
end
classroom = Classroom.last
assert_redirected_to classroom_path(classroom)
assert_equal [grade1, grade2, grade3].sort, classroom.grades.sort
end
test "show" do
sign_in(@admin)
get classroom_path(@classroom)
assert_response :success
end
test "edit" do
sign_in(@admin)
get edit_classroom_path(@classroom)
assert_response :success
end
test "update" do
school = create(:school)
year = create(:year)
grade = create(:grade, level: 6)
params = { classroom: { name: "Abc123", grade_ids: [grade.id], school_id: school.id, year_id: year.id } }
sign_in(@admin)
assert_changes "@classroom.reload.updated_at" do
patch(classroom_path(@classroom), params:)
end
assert_redirected_to classroom_path(@classroom)
assert_equal t("classrooms.update.notice"), flash[:notice]
@classroom.reload
assert_includes @classroom.grades, grade
end
test "update replaces grades correctly" do
old_grade = create(:grade)
create(:classroom_grade, classroom: @classroom, grade: old_grade)
new_grade1 = create(:grade)
new_grade2 = create(:grade)
sign_in(@admin)
params = {
classroom: {
name: "Updated Name",
grade_ids: [new_grade1.id, new_grade2.id]
}
}
patch classroom_path(@classroom), params: params
@classroom.reload
assert_equal [new_grade1, new_grade2].sort, @classroom.grades.sort
assert_not_includes @classroom.grades, old_grade
end
test "archive classroom" do
sign_in(@admin)
assert_no_difference("Classroom.count") do
patch toggle_archive_admin_classroom_path(@classroom)
end
assert_redirected_to admin_classroom_path(@classroom)
assert @classroom.reload.archived?
assert_equal "Classroom has been archived.", flash[:notice]
end
test "activate classroom" do
sign_in(@admin)
@classroom.update!(archived: true)
patch toggle_archive_admin_classroom_path(@classroom)
assert_redirected_to admin_classroom_path(@classroom)
assert_not @classroom.reload.archived?
assert_equal "Classroom has been activated.", flash[:notice]
end
test "admins can see all classrooms in index" do
classroom1 = create(:classroom, name: "Class 1")
classroom2 = create(:classroom, name: "Class 2")
sign_in(@admin)
get classrooms_path
assert_response :success
assert_includes response.body, classroom1.name
assert_includes response.body, classroom2.name
end
test "show includes student management for teachers" do
create(:portfolio, user: @student)
# @teacher.classrooms << @classroom
sign_in @teacher
get classroom_path(@classroom)
assert_response :success
assert_includes response.body, @student.username
end
test "teachers can only see their own classroom in index" do
classroom1 = create(:classroom, name: "Teacher 1 Class")
classroom2 = create(:classroom, name: "Teacher 2 Class")
teacher = create(:teacher, classroom: classroom1)
teacher.classrooms << classroom1
sign_in teacher
get classrooms_path
assert_response :success
assert_includes response.body, classroom1.name
assert_not_includes response.body, classroom2.name
end
test "teachers cannot see archived classrooms in index" do
classroom1 = create(:classroom, name: "Active Class")
classroom2 = create(:classroom, name: "Archived Class", archived: true)
teacher = create(:teacher)
teacher.classrooms << classroom1
teacher.classrooms << classroom2
sign_in teacher
get classrooms_path
assert_response :success
assert_includes response.body, classroom1.name
assert_not_includes response.body, classroom2.name
end
test "teachers can only access details of their own classroom" do
classroom1 = create(:classroom, name: "Teacher 1 Class")
classroom2 = create(:classroom, name: "Teacher 2 Class")
teacher = create(:teacher)
teacher.classrooms << classroom1
sign_in teacher
get classroom_path(classroom1)
assert_response :success
assert_includes response.body, classroom1.name
get classroom_path(classroom2)
assert_redirected_to root_path
end
test "students cannot see classrooms" do
sign_in @student
get classrooms_path
assert_redirected_to @student.portfolio_path
end
test "students cannot see details of a classroom" do
sign_in @student
classroom1 = create(:classroom, name: "Teacher 1 Class")
get classroom_path(id: classroom1.id)
assert_redirected_to @student.portfolio_path
end
test "students cannot create or edit classrooms" do
sign_in @student
get new_classroom_path
assert_redirected_to @student.portfolio_path
get edit_classroom_path(@classroom)
assert_redirected_to @student.portfolio_path
end
test "teachers cannot create classrooms" do
sign_in @teacher
get new_classroom_path
assert_redirected_to root_path
end
test "teachers can toggle trading for their own classrooms" do
sign_in @teacher
assert_not @classroom.trading_enabled
patch toggle_trading_classroom_path(@classroom)
assert_redirected_to classroom_path(@classroom)
assert @classroom.reload.trading_enabled
end
test "teachers cannot edit classrooms" do
sign_in @teacher
get edit_classroom_path(@classroom)
assert_redirected_to root_path
end
test "teachers cannot toggle trading for other teachers' classrooms" do
other_classroom = create(:classroom, name: "Other Class")
sign_in @teacher
patch toggle_trading_classroom_path(other_classroom)
assert_redirected_to root_path
end
test "teachers cannot archive classrooms" do
sign_in @teacher
# Teachers shouldn't be able to archive
patch toggle_archive_admin_classroom_path(@classroom)
assert_redirected_to root_path
end
# Tests for dropdown functionality
test "new action renders form with school and year dropdowns" do
school1 = create(:school, name: "Elementary School")
school2 = create(:school, name: "High School")
year1 = create(:year, name: "2023-2024")
year2 = create(:year, name: "2024-2025")
sign_in(@admin)
get new_classroom_path
assert_response :success
assert_select "select[name='classroom[school_id]']" do
assert_select "option[value='#{school1.id}']", text: school1.name
assert_select "option[value='#{school2.id}']", text: school2.name
end
assert_select "select[name='classroom[year_id]']" do
assert_select "option[value='#{year1.id}']", text: year1.name
assert_select "option[value='#{year2.id}']", text: year2.name
end
end
test "edit action renders form with school and year dropdowns" do
school1 = create(:school, name: "Elementary School")
school2 = create(:school, name: "High School")
year1 = create(:year, name: "2023-2024")
year2 = create(:year, name: "2024-2025")
sign_in(@admin)
get edit_classroom_path(@classroom)
assert_response :success
assert_select "select[name='classroom[school_id]']" do
assert_select "option[value='#{school1.id}']", text: school1.name
assert_select "option[value='#{school2.id}']", text: school2.name
end
assert_select "select[name='classroom[year_id]']" do
assert_select "option[value='#{year1.id}']", text: year1.name
assert_select "option[value='#{year2.id}']", text: year2.name
end
end
test "create with new school_year creates quarters and gradebooks" do
school = create(:school)
year = create(:year)
grade = create(:grade)
sign_in(@admin)
params = { classroom: { name: "New Class", grade_ids: [grade.id], school_id: school.id, year_id: year.id } }
assert_difference("Quarter.count", 4) do
assert_difference("GradeBook.count", 4) do
post(classrooms_path, params:)
end
end
classroom = Classroom.last
assert_equal 4, classroom.grade_books.count
end
test "create with existing school_year uses existing quarters for gradebooks" do
school = create(:school)
year = create(:year)
grade = create(:grade)
school_year = SchoolYear.create!(school: school, year: year)
sign_in(@admin)
params = { classroom: { name: "New Class", grade_ids: [grade.id], school_id: school.id, year_id: year.id } }
assert_no_difference("Quarter.count") do
assert_difference("GradeBook.count", 4) do
post(classrooms_path, params:)
end
end
classroom = Classroom.last
assert_equal school_year, classroom.school_year
assert_equal 4, classroom.grade_books.count
end
test "create with valid school_id and year_id creates classroom with correct school_year" do
school = create(:school, name: "Test School")
year = create(:year, name: "2024-2025")
grade = create(:grade)
sign_in(@admin)
params = { classroom: { name: "Test Class", grade_ids: [grade.id], school_id: school.id, year_id: year.id } }
assert_difference("Classroom.count") do
post(classrooms_path, params:)
end
classroom = Classroom.last
assert_equal school, classroom.school
assert_equal year, classroom.year
end
test "update with valid school_id and year_id updates classroom school_year" do
new_school = create(:school, name: "New School")
new_year = create(:year, name: "2025-2026")
sign_in(@admin)
params = { classroom: { name: "Updated Class", school_id: new_school.id, year_id: new_year.id } }
patch(classroom_path(@classroom), params:)
@classroom.reload
assert_equal new_school, @classroom.school
assert_equal new_year, @classroom.year
end
test "edit form shows current school and year selected" do
school = create(:school, name: "Current School")
year = create(:year, name: "Current Year")
school_year = create(:school_year, school: school, year: year)
classroom = create(:classroom, school_year: school_year)
sign_in(@admin)
get edit_classroom_path(classroom)
assert_response :success
assert_select "select[name='classroom[school_id]']" do
assert_select "option[value='#{school.id}'][selected='selected']", text: school.name
end
assert_select "select[name='classroom[year_id]']" do
assert_select "option[value='#{year.id}'][selected='selected']", text: year.name
end
end
test "index page includes link to classroom show page" do
sign_in(@admin)
classroom = create(:classroom, name: "Linked Class")
get classrooms_path
assert_response :success
assert_select "a[href='#{classroom_path(classroom)}']", text: "Linked Class"
end
end

View File

@@ -0,0 +1,209 @@
# frozen_string_literal: true
require "test_helper"
class GradeBooksControllerTest < ActionDispatch::IntegrationTest
def setup
@classroom = create(:classroom)
@teacher = create(:teacher)
@teacher.classrooms << @classroom
@student = create(:student, classroom: @classroom)
@first_quarter = @classroom.school_year.quarters.find_by!(number: 1)
@second_quarter = @classroom.school_year.quarters.find_by!(number: 2)
@grade_book = @classroom.grade_books.find_by!(quarter: @first_quarter)
create(:grade_entry, grade_book: @grade_book, user: @student)
end
test "show" do
sign_in(@teacher)
get classroom_grade_book_path(@classroom, @grade_book)
assert_response :success
end
test "update" do
sign_in(@teacher)
entry = @grade_book.grade_entries.first
params = {
grade_entries: {
entry.id => {
math_grade: "A",
reading_grade: "B",
attendance_days: 30
}
}
}
patch classroom_grade_book_path(@classroom, @grade_book), params: params
assert_redirected_to classroom_grade_book_path(@classroom, @grade_book)
entry.reload
assert_equal "A", entry.math_grade
assert_equal "B", entry.reading_grade
assert_equal 30, entry.attendance_days
end
test "students cannot access grade book" do
sign_in(@student)
get classroom_grade_book_path(@classroom, @grade_book)
assert_redirected_to @student.portfolio_path
end
test "teachers cannot finalize a grade book" do
DistributeEarnings.expects(:execute).never
sign_in(@teacher)
post finalize_classroom_grade_book_path(@classroom, @grade_book)
assert_redirected_to root_path
@grade_book.reload
assert_not @grade_book.verified?
end
test "finalize runs the DistributeFunds service" do
DistributeEarnings.expects(:execute).with(@grade_book).once
sign_in(create(:admin))
# Fill out all entries to make the grade book finalizable
@grade_book.grade_entries.each do |entry|
entry.update!(math_grade: "A", reading_grade: "B", attendance_days: 30)
end
post finalize_classroom_grade_book_path(@classroom, @grade_book)
assert_redirected_to classroom_grade_book_path(@classroom, @grade_book)
@grade_book.reload
assert @grade_book.verified?
end
test "finalize grade book with incomplete grade entry and no previous quarter" do
sign_in(create(:admin))
@grade_book.grade_entries.first.update!(
math_grade: nil, reading_grade: nil, attendance_days: 30
)
post finalize_classroom_grade_book_path(@classroom, @grade_book)
assert_redirected_to classroom_grade_book_path(@classroom, @grade_book)
assert_equal "Grade book finalized. Funds have been distributed.", flash[:notice]
@grade_book.reload
assert @grade_book.completed?
end
test "finalize grade book with incomplete grade entry (empty string) and no previous quarter" do
sign_in(create(:admin))
@grade_book.grade_entries.first.update!(
math_grade: "", reading_grade: "", attendance_days: 30
)
post finalize_classroom_grade_book_path(@classroom, @grade_book)
assert_redirected_to classroom_grade_book_path(@classroom, @grade_book)
assert_equal "Grade book finalized. Funds have been distributed.", flash[:notice]
@grade_book.reload
assert @grade_book.completed?
end
test "finalize grade book with complete grade entry and no previous quarter" do
sign_in(create(:admin))
@grade_book.grade_entries.first.update!(
math_grade: "A", reading_grade: "B", attendance_days: 30
)
post finalize_classroom_grade_book_path(@classroom, @grade_book)
assert_redirected_to classroom_grade_book_path(@classroom, @grade_book)
assert_equal "Grade book finalized. Funds have been distributed.", flash[:notice]
@grade_book.reload
assert @grade_book.completed?
end
# Scenario 2: Finalize grade book with previous quarter grade book
test "finalize grade book with incomplete grade entry and previous quarter exists" do
sign_in(create(:admin))
# Mark first quarter as completed (acts as previous quarter)
@grade_book.update!(status: :completed)
@grade_book.grade_entries.first.update!(
math_grade: "A", reading_grade: "B", attendance_days: 30
)
# Create new grade book for second quarter with incomplete grades
new_grade_book = @classroom.grade_books.find_by!(quarter: @second_quarter)
create(
:grade_entry, grade_book: new_grade_book, user: @student,
math_grade: nil, reading_grade: nil, attendance_days: 30
)
post finalize_classroom_grade_book_path(@classroom, new_grade_book)
assert_redirected_to classroom_grade_book_path(@classroom, new_grade_book)
assert_equal "Grade book finalized. Funds have been distributed.", flash[:notice]
new_grade_book.reload
assert new_grade_book.completed?
end
test "finalize grade book with complete grade entry and previous quarter exists" do
sign_in(create(:admin))
# Mark first quarter as completed (acts as previous quarter)
@grade_book.update!(status: :completed)
@grade_book.grade_entries.first.update!(
math_grade: "B", reading_grade: nil, attendance_days: 30
)
# Create new grade book for second quarter with complete grades
new_grade_book = @classroom.grade_books.find_by!(quarter: @second_quarter)
create(
:grade_entry, grade_book: new_grade_book, user: @student,
math_grade: "A", reading_grade: "A", attendance_days: 30
)
post finalize_classroom_grade_book_path(@classroom, new_grade_book)
assert_redirected_to classroom_grade_book_path(@classroom, new_grade_book)
assert_equal "Grade book finalized. Funds have been distributed.", flash[:notice]
new_grade_book.reload
assert new_grade_book.completed?
end
test "finalize grade book with complete grade entry and previous quarter exists (empty string)" do
sign_in(create(:admin))
# Mark first quarter as completed (acts as previous quarter)
@grade_book.update!(status: :completed)
@grade_book.grade_entries.first.update!(
math_grade: "", reading_grade: "", attendance_days: 30
)
# Create new grade book for second quarter with complete grades
new_grade_book = @classroom.grade_books.find_by!(quarter: @second_quarter)
create(
:grade_entry, grade_book: new_grade_book, user: @student,
math_grade: "A", reading_grade: "A", attendance_days: 30
)
post finalize_classroom_grade_book_path(@classroom, new_grade_book)
assert_redirected_to classroom_grade_book_path(@classroom, new_grade_book)
assert_equal "Grade book finalized. Funds have been distributed.", flash[:notice]
new_grade_book.reload
assert new_grade_book.completed?
end
test "does not finalize already completed grade book" do
sign_in(create(:admin))
@grade_book.update!(status: :completed)
post finalize_classroom_grade_book_path(@classroom, @grade_book)
assert_redirected_to classroom_grade_book_path(@classroom, @grade_book)
assert_equal "Cannot finalize because it's already completed.", flash[:alert]
end
end

View File

@@ -0,0 +1,130 @@
# frozen_string_literal: true
require "test_helper"
class HomeControllerTest < ActionDispatch::IntegrationTest
test "should redirect to sign in when not authenticated" do
get root_url
assert_redirected_to new_user_session_url
end
test "should get index when authenticated" do
sign_in create(:student)
get root_url
assert_response :success
end
test "index page has expected content when authenticated" do
sign_in create(:student)
get root_url
assert_response :success
assert_select "title", /StocksInTheFuture/i
end
test "index responds with HTML when authenticated" do
sign_in create(:student)
get root_url
assert_response :success
assert_equal "text/html; charset=utf-8", response.content_type
end
test "should show current announcement on home page" do
sign_in create(:student)
announcement = Announcement.create!(
title: "Welcome Students!",
content: "This is an important announcement for all students.",
featured: true
)
get root_url
assert_response :success
assert_select "h3", text: "Welcome Students!"
assert_select "a[href=?]", announcement_path(announcement), text: "Read More", count: 0
end
test "should show featured announcement when multiple exist" do
sign_in create(:student)
Announcement.create!(
title: "Old Announcement",
content: "This is old news.",
created_at: 2.days.ago,
featured: false
)
Announcement.create!(
title: "Featured News",
content: "This is the featured announcement.",
featured: true
)
get root_url
assert_response :success
assert_select "h3", text: "Featured News"
assert_no_match(/Old Announcement/, response.body)
end
test "should show no announcements message when none exist" do
sign_in create(:student)
get root_url
assert_response :success
assert_select "p", text: "No announcements yet."
end
test "should show full announcement content in scrollable box" do
sign_in create(:student)
long_content = "This is a long announcement content that is displayed in full in a scrollable box. " * 5
Announcement.create!(
title: "Long Announcement",
content: long_content,
featured: true
)
get root_url
assert_response :success
assert_select "h3", text: "Long Announcement"
assert_select "a", text: "Read More", count: 0
end
test "should show positive balance message when student has money" do
student = create(:student)
portfolio = student.portfolio
create(:portfolio_transaction, :deposit, portfolio: portfolio, amount_cents: 5000) # $50.00
sign_in student
get root_url
assert_response :success
assert_select "span", text: /You have.*\$50\.00.*to invest! Lets Get Trading!/
assert_select "img[alt='Party popper celebration']"
end
test "should show no earnings message when student has zero balance" do
student = create(:student)
sign_in student
get root_url
assert_response :success
assert_select "span", text: "Sorry, you don't have any earnings to invest yet"
assert_no_match(/to invest! Lets Get Trading!/, response.body)
end
test "should not show balance message for non-student users" do
teacher = create(:teacher)
sign_in teacher
get root_url
assert_response :success
assert_no_match(/You have.*to invest/, response.body)
assert_no_match(/Sorry, you don't have any earnings/, response.body)
end
end

View File

@@ -0,0 +1,169 @@
# frozen_string_literal: true
require "test_helper"
class OrdersControllerTest < ActionDispatch::IntegrationTest
test "index" do
user = create(:teacher)
sign_in(user)
get orders_path
assert_response :success
end
test "index as guest" do
get orders_path
assert_redirected_to new_user_session_path
end
test "new" do
user = create(:student)
stock = create(:stock)
sign_in(user)
get new_order_path(stock_id: stock.id, transaction_type: "buy")
assert_response :success
end
test "create" do
student = create(:student)
student.portfolio.portfolio_transactions.create!(amount_cents: 10_000, transaction_type: :deposit) # $100
stock = create(:stock, price_cents: 1_000) # $10 per share
params = { order: { user_id: student.id, stock_id: stock.id, shares: 1, action: "buy" } }
sign_in(student)
assert_difference("Order.count", 1) do
post(orders_path, params:)
end
assert_redirected_to orders_path
end
test "create with invalid shares handles validation errors" do
student = create(:student)
student.portfolio.portfolio_transactions.create!(amount_cents: 10_000, transaction_type: :deposit)
stock = create(:stock, price_cents: 1_000)
sign_in(student)
[nil, "", "not_a_number"].each do |invalid_shares|
buy_params = { order: { stock_id: stock.id, shares: invalid_shares, action: :buy } }
sell_params = { order: { stock_id: stock.id, shares: invalid_shares, action: :sell } }
assert_no_difference("Order.count") do
assert_nothing_raised do
post(orders_path, params: buy_params)
assert_response :unprocessable_content
post(orders_path, params: sell_params)
assert_response :unprocessable_content
end
end
end
end
test "edit" do
user = create(:student)
stock = create(:stock)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 10)
order = create(:order, action: :sell, user: user, stock: stock, shares: 1)
sign_in(order.user)
get edit_order_path(order)
assert_response :success
end
test "update" do
user = create(:student)
stock = create(:stock)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 10)
params = { order: { shares: 3 } }
order = create(:order, :pending, action: :sell, user: user, stock: stock, shares: 1)
sign_in(user)
assert_changes "order.reload.updated_at" do
patch(order_path(order), params:)
end
assert_redirected_to orders_path
assert order.shares, 3
end
test "update with invalid shares handles validation errors" do
user = create(:student)
stock = create(:stock, price_cents: 1_000)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 10)
order = create(:order, :pending, action: :sell, user: user, stock: stock, shares: 5)
sign_in(user)
[nil, "", "not_a_number", -1, 0].each do |invalid_shares|
params = { order: { shares: invalid_shares } }
assert_no_changes "order.reload.shares" do
assert_nothing_raised do
patch(order_path(order), params: params)
assert_response :unprocessable_content
end
end
end
end
test "cancel" do
user = create(:student)
stock = create(:stock)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 10)
order = create(:order, :pending, action: :sell, user: user, stock: stock, shares: 1)
sign_in(order.user)
assert_difference("Order.pending.count", -1) do
assert_difference("Order.canceled.count", 1) do
patch(cancel_order_path(order))
end
end
assert_redirected_to orders_path
assert_equal "Order was successfully canceled", flash[:notice]
end
test "cancel route exists" do
assert_routing(
{ path: "orders/1/cancel", method: "patch" },
{ controller: "orders", action: "cancel", id: "1" }
)
end
test "cancel with unauthorized user" do
user = create(:student)
stock = create(:stock)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 10)
order = create(:order, :pending, action: :sell, user: user, stock: stock, shares: 1)
unauthorized_user = create(:student)
sign_in(unauthorized_user)
assert_no_difference("Order.count") do
patch(cancel_order_path(order))
end
assert_response :redirect
assert_equal "You do not have access to this page.", flash[:alert]
end
test "cancel with non-pending order" do
user = create(:student)
stock = create(:stock)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 10)
order = create(:order, :completed, action: :sell, user: user, stock: stock, shares: 1)
sign_in(order.user)
assert_no_difference("Order.count") do
patch(cancel_order_path(order))
end
assert_response :redirect
assert_equal "Only pending orders can be canceled", flash[:alert]
end
end

View File

@@ -0,0 +1,64 @@
# frozen_string_literal: true
require "test_helper"
class PortfoliosControllerTest < ActionDispatch::IntegrationTest
test "show with empty portfolio" do
portfolio = create(:portfolio)
sign_in(portfolio.user)
get portfolio_path(portfolio)
assert_response :success
end
test "show with portfolio positions renders" do
portfolio = create(:portfolio)
stock = create(:stock, price_cents: 15_000, yesterday_price_cents: 14_500)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 10)
sign_in(portfolio.user)
get portfolio_path(portfolio)
assert_response :success
end
test "trade button links to trading floor" do
portfolio = create(:portfolio)
stock = create(:stock)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 5)
sign_in(portfolio.user)
get portfolio_path(portfolio)
assert_response :success
assert_select "a[href='#{stocks_path}']", text: "Trade"
end
test "show displays empty state when less than 2 snapshots" do
portfolio = create(:portfolio)
create(:portfolio_snapshot, portfolio: portfolio, date: Date.current, worth_cents: 10_000)
sign_in(portfolio.user)
get portfolio_path(portfolio)
assert_response :success
assert_select ".text-gray-600", text: "Not Enough Data"
end
test "show displays chart when 2 or more snapshots exist" do
portfolio = create(:portfolio)
create(:portfolio_snapshot, portfolio: portfolio, date: 1.month.ago.to_date, worth_cents: 10_000)
create(:portfolio_snapshot, portfolio: portfolio, date: Date.current, worth_cents: 15_000)
sign_in(portfolio.user)
get portfolio_path(portfolio)
assert_response :success
assert_select "canvas[data-controller='portfolio-chart']"
end
end

View File

@@ -0,0 +1,63 @@
# frozen_string_literal: true
require "test_helper"
class SchoolsControllerTest < ActionDispatch::IntegrationTest
setup do
@school = create(:school)
@admin_user = create(:admin)
sign_in @admin_user
end
test "should get index" do
get admin_schools_url
assert_response :success
assert_select "title", /Admin/i
end
test "should get new" do
get new_admin_school_url
assert_response :success
assert_select "form"
end
test "should create school" do
assert_difference("School.count") do
post admin_schools_url, params: { school: { name: "New Test School" } }
end
assert_redirected_to admin_school_url(School.last)
assert_equal "New Test School", School.last.name
end
test "should show school" do
get admin_school_url(@school)
assert_response :success
assert_select "h2", /#{@school.name}/
end
test "should get edit" do
get edit_admin_school_url(@school)
assert_response :success
assert_select "form"
end
test "should update school" do
patch admin_school_url(@school), params: { school: { name: "Updated School Name" } }
assert_redirected_to admin_school_url(@school)
@school.reload
assert_equal "Updated School Name", @school.name
end
test "should destroy school" do
assert_difference("School.count", -1) do
delete admin_school_url(@school)
end
assert_redirected_to admin_schools_url
end
test "requires authentication" do
sign_out @admin_user
get admin_schools_url
assert_redirected_to new_user_session_url
end
end

View File

@@ -0,0 +1,35 @@
# frozen_string_literal: true
require "test_helper"
class StocksControllerTest < ActionDispatch::IntegrationTest
test "should get index" do
admin = create(:admin)
sign_in admin
get stocks_url
assert_response :success
end
test "should get show" do
stock = create(:stock)
admin = create(:admin)
sign_in admin
get stock_url(stock)
assert_response :success
end
test "trade stock button links to trading floor for students" do
stock = create(:stock)
student = create(:student)
create(:portfolio, user: student)
sign_in student
get stock_url(stock)
assert_select "a[href='#{stocks_path}']", text: "Trade Stock"
end
end

View File

@@ -0,0 +1,131 @@
# frozen_string_literal: true
require "test_helper"
class StudentsControllerTest < ActionDispatch::IntegrationTest
setup do
@classroom = create(:classroom)
@teacher = create(:teacher, classroom: @classroom)
@student = create(:student, classroom: @classroom)
@other_classroom = create(:classroom)
@other_student = create(:student, classroom: @other_classroom)
end
test "students cannot access student management actions" do
sign_in @student
get new_classroom_student_path(@classroom)
assert_redirected_to @student.portfolio_path
get edit_classroom_student_path(@classroom, @student)
assert_redirected_to @student.portfolio_path
patch classroom_student_path(@classroom, @student), params: { student: { username: "hacked" } }
assert_redirected_to @student.portfolio_path
assert_no_difference("User.kept.count") do
delete classroom_student_path(@classroom, @student)
end
assert_redirected_to @student.portfolio_path
end
test "teacher can create student in their classroom" do
sign_in @teacher
assert_difference("User.count") do
assert_difference("Student.count") do
post classroom_students_path(@classroom), params: {
student: {
username: "newstudent",
email: "newstudent@example.com"
}
}
end
end
student = Student.last
assert_equal @classroom, student.classroom
assert_not_nil student.encrypted_password
assert_redirected_to classroom_path(@classroom)
assert_match(/created successfully/, flash[:notice])
end
test "student creation creates portfolio automatically" do
sign_in @teacher
assert_difference("Portfolio.count") do
post classroom_students_path(@classroom), params: {
student: {
username: "newstudent",
email: "newstudent@example.com"
}
}
end
student = Student.last
assert_not_nil student.portfolio
assert_equal 0, student.portfolio.calculate_total_value
end
test "student creation generates memorable password" do
sign_in @teacher
post classroom_students_path(@classroom), params: {
student: {
username: "newstudent",
email: "newstudent@example.com"
}
}
assert_match(/password:/, flash[:notice].downcase)
end
test "teacher can update student in their classroom" do
sign_in @teacher
patch classroom_student_path(@classroom, @student), params: {
student: {
username: "updatedname",
email: "updated@example.com"
}
}
@student.reload
assert_equal "updatedname", @student.username
assert_equal "updated@example.com", @student.email
assert_redirected_to classroom_path(@classroom)
end
test "teacher can delete student from their classroom" do
sign_in @teacher
assert_difference("User.kept.count", -1) do
delete classroom_student_path(@classroom, @student)
end
assert_redirected_to classroom_path(@classroom)
assert_match(/deleted successfully/, flash[:notice])
assert @student.reload.discarded?
end
test "teacher can reset student password" do
sign_in @teacher
original_password = @student.encrypted_password
patch reset_password_classroom_student_path(@classroom, @student)
@student.reload
assert_not_equal original_password, @student.encrypted_password
assert_redirected_to classroom_path(@classroom)
assert_match(/password reset/i, flash[:notice])
assert_match(/new password:/i, flash[:notice])
end
test "password reset generates memorable password" do
sign_in @teacher
patch reset_password_classroom_student_path(@classroom, @student)
assert_match(/new password: \w+\d+/i, flash[:notice])
end
end

View File

@@ -0,0 +1,176 @@
# frozen_string_literal: true
require "test_helper"
class TeachersControllerTest < ActionDispatch::IntegrationTest
test "index" do
admin = create(:admin)
sign_in(admin)
get admin_teachers_path
assert_response :success
end
test "new" do
admin = create(:admin)
sign_in(admin)
get new_admin_teacher_path
assert_response :success
end
test "show" do
teacher = create(:teacher)
admin = create(:admin)
sign_in(admin)
get admin_teacher_path(teacher)
assert_response :success
end
test "edit" do
teacher = create(:teacher)
admin = create(:admin)
sign_in(admin)
get edit_admin_teacher_path(teacher)
assert_response :success
end
test "update" do
teacher = create(:teacher)
admin = create(:admin)
sign_in(admin)
params = { teacher: { name: "Jane Smith" } }
assert_changes "teacher.reload.updated_at" do
patch admin_teacher_path(teacher), params: params
end
assert_redirected_to admin_teacher_path(teacher)
end
test "create creates a teacher, associates classroom, sends reset email, and redirects with notice" do
admin = create(:admin)
classroom = create(:classroom)
sign_in(admin)
ActionMailer::Base.deliveries.clear
params = {
teacher: {
email: "t1@example.com",
username: "teacher1",
classroom_ids: [classroom.id]
}
}
assert_difference %w[Teacher.count ActionMailer::Base.deliveries.size], 1 do
post admin_teachers_path, params: params
end
teacher = Teacher.find_by!(email: "t1@example.com")
assert_redirected_to admin_teacher_path(teacher)
assert_not_nil flash[:notice]
assert_includes teacher.classrooms, classroom
teacher.reload
assert_not_nil teacher.reset_password_sent_at, "reset_password_instructions should be sent"
assert teacher.encrypted_password.present?, "temp password should be set"
end
test "create with no classroom id still creates teacher, and does not associate classroom" do
admin = create(:admin)
sign_in(admin)
ActionMailer::Base.deliveries.clear
params = {
teacher: {
email: "t2@example.com",
username: "teacher2"
}
}
assert_difference %w[Teacher.count ActionMailer::Base.deliveries.size], 1 do
post admin_teachers_path, params: params
end
teacher = Teacher.find_by!(email: "t2@example.com")
assert_redirected_to admin_teacher_path(teacher)
assert_equal 0, teacher.classrooms.count, "no classroom should be associated"
end
test "create sends email with password reset link" do
admin = create(:admin)
sign_in(admin)
params = {
teacher: {
email: "newteacher@example.com",
username: "newteacher"
}
}
emails = capture_emails do
post admin_teachers_path, params: params
end
assert_equal 1, emails.size
email = emails.first
assert_equal ["newteacher@example.com"], email.to
assert_match(/reset_password_token=/, email.body.encoded)
assert_match(/Change my password/, email.body.encoded)
end
test "teacher can set password via reset link from creation email" do
admin = create(:admin)
sign_in(admin)
ActionMailer::Base.deliveries.clear
post admin_teachers_path, params: {
teacher: { email: "resettest@example.com", username: "resettest" }
}
email = ActionMailer::Base.deliveries.last
token = email.body.encoded.match(/reset_password_token=([^"]+)/)[1]
sign_out(admin)
# Teacher follows the link from the email
get edit_user_password_path(reset_password_token: token)
assert_response :success
# Teacher sets a new password
put user_password_path, params: {
user: {
reset_password_token: token,
password: "newpassword123",
password_confirmation: "newpassword123"
}
}
teacher = Teacher.find_by!(email: "resettest@example.com")
assert teacher.valid_password?("newpassword123")
end
test "create with invalid params renders unprocessable_entity and does not create teacher" do
admin = create(:admin)
sign_in(admin)
params = {
teacher: {
email: ""
}
}
assert_no_difference "Teacher.count" do
post admin_teachers_path, params: params
end
assert_response :unprocessable_entity
end
end

View File

@@ -0,0 +1,14 @@
{
"Global Quote": {
"01. symbol": "DIS",
"02. open": "101.5200",
"03. high": "104.0800",
"04. low": "101.4100",
"05. price": "103.9100",
"06. volume": "14735791",
"07. latest trading day": "2024-05-31",
"08. previous close": "101.7000",
"09. change": "2.2100",
"10. change percent": "2.1731%"
}
}

View File

@@ -0,0 +1,14 @@
{
"Global Quote": {
"01. symbol": "EA",
"02. open": "131.2100",
"03. high": "133.0200",
"04. low": "129.0800",
"05. price": "132.8800",
"06. volume": "4525269",
"07. latest trading day": "2024-05-31",
"08. previous close": "131.2300",
"09. change": "1.6500",
"10. change percent": "1.2573%"
}
}

View File

@@ -0,0 +1,14 @@
{
"Global Quote": {
"01. symbol": "F",
"02. open": "11.7700",
"03. high": "12.1500",
"04. low": "11.6600",
"05. price": "12.1300",
"06. volume": "57205750",
"07. latest trading day": "2024-05-31",
"08. previous close": "11.7700",
"09. change": "0.3600",
"10. change percent": "3.0586%"
}
}

View File

@@ -0,0 +1,14 @@
{
"Global Quote": {
"01. symbol": "FB",
"02. open": "n/a",
"03. high": "n/a",
"04. low": "n/a" ,
"05. price": "467.29",
"06. volume": "n/a",
"07. latest trading day": "2024-05-31",
"08. previous close": "n/a",
"09. change": "n/a",
"10. change percent": "n/a"
}
}

View File

@@ -0,0 +1,14 @@
{
"Global Quote": {
"01. symbol": "GPS",
"02. open": "n/a",
"03. high": "n/a",
"04. low": "n/a" ,
"05. price": "28.96",
"06. volume": "n/a",
"07. latest trading day": "2024-05-31",
"08. previous close": "n/a",
"09. change": "n/a",
"10. change percent": "n/a"
}
}

View File

@@ -0,0 +1,14 @@
{
"Global Quote": {
"01. symbol": "IBM",
"02. open": "165.5600",
"03. high": "166.7300",
"04. low": "164.2300",
"05. price": "165.6300",
"06. volume": "3852963",
"07. latest trading day": "2024-05-30",
"08. previous close": "167.0500",
"09. change": "-1.4200",
"10. change percent": "-0.8500%"
}
}

View File

@@ -0,0 +1,14 @@
{
"Global Quote": {
"01. symbol": "KO",
"02. open": "61.9900",
"03. high": "63.0250",
"04. low": "61.6900",
"05. price": "62.9300",
"06. volume": "19816630",
"07. latest trading day": "2024-05-31",
"08. previous close": "61.9700",
"09. change": "0.9600",
"10. change percent": "1.5491%"
}
}

View File

@@ -0,0 +1,14 @@
{
"Global Quote": {
"01. symbol": "LUV",
"02. open": "n/a",
"03. high": "n/a",
"04. low": "n/a" ,
"05. price": "26.84",
"06. volume": "n/a",
"07. latest trading day": "2024-05-31",
"08. previous close": "n/a",
"09. change": "n/a",
"10. change percent": "n/a"
}
}

View File

@@ -0,0 +1,14 @@
{
"Global Quote": {
"01. symbol": "300135.SHZ",
"02. open": "2.4700",
"03. high": "2.5100",
"04. low": "2.3900",
"05. price": "2.4400",
"06. volume": "38891270",
"07. latest trading day": "2024-03-01",
"08. previous close": "2.4500",
"09. change": "-0.0100",
"10. change percent": "-0.4082%"
}
}

View File

@@ -0,0 +1,14 @@
{
"Global Quote": {
"01. symbol": "SIRI",
"02. open": "2.7350",
"03. high": "2.9300",
"04. low": "2.7200",
"05. price": "2.8200",
"06. volume": "101093349",
"07. latest trading day": "2024-05-31",
"08. previous close": "2.7400",
"09. change": "0.0800",
"10. change percent": "2.9197%"
}
}

View File

@@ -0,0 +1,14 @@
{
"Global Quote": {
"01. symbol": "SNE",
"02. open": "81.7500",
"03. high": "82.3750",
"04. low": "81.4700",
"05. price": "82.3400",
"06. volume": "968344",
"07. latest trading day": "2024-05-31",
"08. previous close": "80.7900",
"09. change": "1.5500",
"10. change percent": "1.9186%"
}
}

View File

@@ -0,0 +1,14 @@
{
"Global Quote": {
"01. symbol": "TWX",
"02. open": "98.7700",
"03. high": "98.7700",
"04. low": "98.7700",
"05. price": "98.7700",
"06. volume": "8475",
"07. latest trading day": "2018-06-15",
"08. previous close": "98.7700",
"09. change": "0.0000",
"10. change percent": "0.0000%"
}
}

View File

@@ -0,0 +1,14 @@
{
"Global Quote": {
"01. symbol": "UA",
"02. open": "6.7800",
"03. high": "6.9700",
"04. low": "6.6900",
"05. price": "6.9600",
"06. volume": "3699250",
"07. latest trading day": "2024-05-31",
"08. previous close": "6.7400",
"09. change": "0.2200",
"10. change percent": "3.2641%"
}
}

View File

@@ -0,0 +1 @@
["KO", "SNE", "TWX", "DIS", "SIRI", "F", "EA", "FB", "UA", "LUV", "GPS"]

View File

@@ -0,0 +1,112 @@
# frozen_string_literal: true
require "test_helper"
class ClassroomFacadeTest < ActiveSupport::TestCase
test "students returns students enrolled via enrollments" do
classroom = create(:classroom)
student = create(:student)
create(:classroom_enrollment, student: student, classroom: classroom)
facade = ClassroomFacade.new(classroom)
assert_includes facade.students, student
end
test "students returns students assigned via legacy classroom_id" do
classroom = create(:classroom)
student = create(:student, classroom: classroom)
facade = ClassroomFacade.new(classroom)
assert_includes facade.students, student
end
test "students returns both enrollment and legacy students without duplicates" do
classroom = create(:classroom)
# Student with enrollment
student_with_enrollment = create(:student)
create(:classroom_enrollment, student: student_with_enrollment, classroom: classroom)
# Student with legacy classroom_id
student_with_legacy = create(:student, classroom: classroom)
facade = ClassroomFacade.new(classroom)
students = facade.students
assert_equal 2, students.count
assert_includes students, student_with_enrollment
assert_includes students, student_with_legacy
end
test "students does not return duplicate if student has both enrollment and classroom_id" do
classroom = create(:classroom)
student = create(:student, classroom: classroom)
create(:classroom_enrollment, student: student, classroom: classroom)
facade = ClassroomFacade.new(classroom)
students = facade.students
assert_equal 1, students.count
assert_includes students, student
end
test "students only returns current enrollments, not historical" do
classroom = create(:classroom)
current_student = create(:student, :without_enrollment)
historical_student = create(:student, :without_enrollment)
create(
:classroom_enrollment,
student: current_student,
classroom: classroom,
unenrolled_at: nil
)
create(
:classroom_enrollment,
student: historical_student,
classroom: classroom,
enrolled_at: 2.weeks.ago,
unenrolled_at: 1.week.ago
)
facade = ClassroomFacade.new(classroom)
students = facade.students
assert_includes students, current_student
assert_not_includes students, historical_student
end
test "students excludes discarded students" do
classroom = create(:classroom)
active_student = create(:student, classroom: classroom)
discarded_student = create(:student, classroom: classroom)
discarded_student.discard
facade = ClassroomFacade.new(classroom)
students = facade.students
assert_includes students, active_student
assert_not_includes students, discarded_student
end
test "students includes necessary associations for performance" do
classroom = create(:classroom, :with_trading)
student = create(:student, classroom: classroom)
portfolio = create(:portfolio, user: student)
create(:portfolio_transaction, :credit, portfolio: portfolio, amount_cents: 15_000)
create(:order, user: student, action: :buy)
facade = ClassroomFacade.new(classroom)
# Should not raise N+1 queries when accessing associations
assert_nothing_raised do
facade.students.each do |s|
s.portfolio
s.orders.to_a
s.portfolio&.portfolio_transactions&.to_a
end
end
end
end

View File

@@ -0,0 +1,24 @@
# frozen_string_literal: true
FactoryBot.define do
factory :classroom_enrollment do
student { association :student }
classroom { association :classroom }
enrolled_at { Time.current }
unenrolled_at { nil }
primary { false }
trait :current do
unenrolled_at { nil }
end
trait :historical do
enrolled_at { 1.year.ago }
unenrolled_at { 6.months.ago }
end
trait :primary do
primary { true }
end
end
end

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
FactoryBot.define do
factory :classroom_grade do
association :classroom
association :grade
end
end

View File

@@ -0,0 +1,33 @@
# frozen_string_literal: true
FactoryBot.define do
factory :classroom do
sequence(:name) { |n| "Classroom #{n}" }
trading_enabled { false }
association :school_year
after(:build) do |classroom|
classroom.grades << build(:grade) if classroom.grades.empty?
end
trait :with_trading do
trading_enabled { true }
end
trait :with_grade do
after(:create) do |classroom|
create(:classroom_grade, classroom: classroom)
end
end
trait :with_grades do
transient do
grades_count { 3 }
end
after(:create) do |classroom, evaluator|
create_list(:classroom_grade, evaluator.grades_count, classroom: classroom)
end
end
end
end

View File

@@ -0,0 +1,10 @@
# frozen_string_literal: true
FactoryBot.define do
factory :grade_book do
quarter { create(:school_year).quarters.first }
association :classroom
status { "draft" }
end
end

View File

@@ -0,0 +1,14 @@
# frozen_string_literal: true
# test/factories/grade_entries.rb
FactoryBot.define do
factory :grade_entry do
association :grade_book
association :user, factory: :student
math_grade { nil }
reading_grade { nil }
attendance_days { 45 }
is_perfect_attendance { false }
end
end

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
FactoryBot.define do
factory :grade do
sequence(:level) { |n| n }
sequence(:name) { |n| "Grade #{n}" }
end
end

View File

@@ -0,0 +1,51 @@
# frozen_string_literal: true
FactoryBot.define do
factory :order do
stock
shares { 1 }
user { create(:student) }
action { :sell } # Default to sell to avoid validation issues
end
trait :pending do
status { :pending }
end
trait :completed do
status { :completed }
end
trait :canceled do
status { :canceled }
end
trait :buy do
action { :buy }
end
trait :sell do
action { :sell }
end
trait :with_sufficient_funds do
after(:create) do |order|
order.user.portfolio.portfolio_transactions.create!(
amount_cents: 100_000, # $1000 - plenty for testing
transaction_type: :deposit
)
end
end
trait :with_sufficient_shares do
after(:create) do |order|
create(
:portfolio_stock,
portfolio: order.user.portfolio,
stock: order.stock,
shares: 100, # Plenty of shares for testing
purchase_price: order.stock.price_cents
)
end
end
end

View File

@@ -0,0 +1,9 @@
# frozen_string_literal: true
FactoryBot.define do
factory :portfolio_snapshot do
portfolio { create(:portfolio) }
date { Date.current }
worth_cents { 50_000 }
end
end

View File

@@ -0,0 +1,10 @@
# frozen_string_literal: true
FactoryBot.define do
factory :portfolio_stock do
association :portfolio
association :stock
shares { 10.0 }
purchase_price { 100.0 }
end
end

View File

@@ -0,0 +1,30 @@
# frozen_string_literal: true
FactoryBot.define do
factory :portfolio_transaction do
portfolio
amount_cents { 1_000 }
transaction_type { :credit }
end
trait :debit do
transaction_type { :debit }
end
trait :credit do
transaction_type { :credit }
end
trait :deposit do
transaction_type { :deposit }
end
trait :withdrawal do
transaction_type { :withdrawal }
end
trait :fee do
transaction_type { :fee }
amount_cents { PortfolioTransaction::TRANSACTION_FEE_CENTS }
end
end

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
FactoryBot.define do
factory :portfolio do
user { create(:student) }
end
end

View File

@@ -0,0 +1,18 @@
# frozen_string_literal: true
# test/factories/quarters.rb
# SchoolYear auto-creates 4 quarters via after_create, so create(:quarter) finds
# the existing quarter rather than inserting a new one (which would violate the
# uniqueness constraint on number scoped to school_year).
FactoryBot.define do
factory :quarter do
association :school_year
number { 1 }
name { "Quarter #{number}" }
to_create do |instance|
instance.id = instance.school_year.quarters.find_by!(number: instance.number).id
instance.reload
end
end
end

View File

@@ -0,0 +1,9 @@
# frozen_string_literal: true
# test/factories/school_years.rb
FactoryBot.define do
factory :school_year do
association :school
association :year
end
end

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
FactoryBot.define do
factory :school do
sequence(:name) { |n| "School #{n}" }
end
end

View File

@@ -0,0 +1,24 @@
# frozen_string_literal: true
FactoryBot.define do
factory :stock do
sequence(:ticker) { |n| "STOCK#{n}" }
stock_exchange { "NYSE" }
sequence(:company_name) { |n| "Sample Company#{n} Inc." }
company_website { "https://example.com" }
description { "A sample stock for testing" }
industry { "Technology" }
management { "CEO: John Doe" }
employees { 1000 }
sequence(:competitor_names) { |n| "Competitor #{n}, Competitor #{n + 1}" }
sales_growth { 5.5 }
industry_avg_sales_growth { 4.2 }
debt_to_equity { 0.3 }
industry_avg_debt_to_equity { 0.4 }
profit_margin { 15.2 }
industry_avg_profit_margin { 12.8 }
cash_flow { 1_000_000.00 }
debt { 500_000.00 }
price_cents { 12_500 } # $125.00
end
end

View File

@@ -0,0 +1,8 @@
# frozen_string_literal: true
FactoryBot.define do
factory :teacher_classroom do
teacher
classroom
end
end

View File

@@ -0,0 +1,39 @@
# frozen_string_literal: true
FactoryBot.define do
factory :admin, class: "User" do
password { "Passw0rd" }
sequence(:username) { |n| "admin_#{n}" }
sequence(:email) { |n| "admin_#{n}@example.com" }
admin { true }
end
factory :student, class: "Student" do
type { "Student" }
password { "Passw0rd" }
classroom { create(:classroom, :with_trading) }
sequence(:username) { |n| "student_#{n}" }
sequence(:email) { |n| "student_#{n}@example.com" }
trait :with_portfolio do
after(:create) do |student|
create(:portfolio, user: student)
end
end
trait :without_enrollment do
classroom { nil }
end
trait :discarded do
discarded_at { Time.current }
end
end
factory :teacher, class: "Teacher" do
type { "Teacher" }
password { "Passw0rd" }
classroom { create(:classroom) }
sequence(:email) { |n| "teacher_#{n}@example.com" }
end
end

View File

@@ -0,0 +1,7 @@
# frozen_string_literal: true
FactoryBot.define do
factory :year do
sequence(:name) { |n| "#{200 + n} - #{2000 + n + 1}" }
end
end

View File

@@ -0,0 +1,4 @@
# one:
# record: name_of_fixture (ClassOfFixture)
# name: content
# body: <p>In a <i>million</i> stars!</p>

View File

@@ -0,0 +1,7 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
title: "Important School Announcement"
two:
title: "Draft Announcement"

View File

@@ -0,0 +1,91 @@
# frozen_string_literal: true
require "test_helper"
class AdminHelperTest < ActionView::TestCase
test "format_attribute formats boolean true" do
user = build(:admin)
result = format_attribute(user, :admin)
assert_match(/Yes/, result)
assert_match(/bg-green-100/, result)
end
test "format_attribute formats boolean false" do
user = build(:student)
result = format_attribute(user, :admin)
assert_match(/No/, result)
assert_match(/bg-gray-100/, result)
end
test "format_attribute formats date" do
date = Date.new(2025, 12, 22)
user = build(:student, created_at: date)
result = format_attribute(user, :created_at)
assert_equal "December 22, 2025", result
end
test "format_attribute formats nil value" do
user = build(:student, name: nil)
result = format_attribute(user, :name)
assert_match(/—/, result)
assert_match(/text-gray-400/, result)
end
test "format_attribute formats string" do
user = build(:student, email: "test@example.com")
result = format_attribute(user, :email)
assert_equal "test@example.com", result
end
test "boolean_badge renders yes badge" do
result = boolean_badge(true)
assert_match(/Yes/, result)
assert_match(/bg-green-100/, result)
assert_match(/text-green-800/, result)
end
test "boolean_badge renders no badge" do
result = boolean_badge(false)
assert_match(/No/, result)
assert_match(/bg-gray-100/, result)
assert_match(/text-gray-800/, result)
end
test "sort_icon returns up arrow for asc sort" do
params[:sort] = "name"
params[:direction] = "asc"
assert_equal "↑", sort_icon(:name)
end
test "sort_icon returns down arrow for desc sort" do
params[:sort] = "name"
params[:direction] = "desc"
assert_equal "↓", sort_icon(:name)
end
test "sort_icon returns both arrows for unsorted column" do
params[:sort] = "email"
assert_equal "⇅", sort_icon(:name)
end
# TODO: Fix sort_link routing issues in Admin - create separate ticket
# Error: No route matches {direction: "desc", sort: :name}
test "sort_link generates correct direction toggle" do
skip "Broken due to routing issues"
# params[:sort] = "name"
# params[:direction] = "asc"
# result = sort_link(:name, "Name")
# assert_match(/direction=desc/, result)
# assert_match(/Name/, result)
end
# TODO: Fix sort_link routing issues in Admin - create separate ticket
# Error: No route matches {direction: "asc", sort: :name}
test "sort_link defaults to asc for new sort" do
skip "Broken due to routing issues"
# params[:sort] = nil
# params[:direction] = nil
# result = sort_link(:name, "Name")
# assert_match(/direction=asc/, result)
end
end

View File

@@ -0,0 +1,81 @@
# frozen_string_literal: true
require "test_helper"
class ApplicationHelperTest < ActionView::TestCase
test "safe_url returns valid http URL" do
url = "http://example.com"
result = safe_url(url)
assert_equal url, result
end
test "safe_url returns valid https URL" do
url = "https://example.com"
result = safe_url(url)
assert_equal url, result
end
test "safe_url returns valid https URL with path and query" do
url = "https://example.com/path?query=value"
result = safe_url(url)
assert_equal url, result
end
test "safe_url returns nil for javascript URL" do
url = "javascript:alert('XSS')"
result = safe_url(url)
assert_nil result
end
test "safe_url returns nil for data URL" do
url = "data:text/html,<script>alert('XSS')</script>"
result = safe_url(url)
assert_nil result
end
test "safe_url returns nil for invalid URL" do
url = "not a valid url"
result = safe_url(url)
assert_nil result
end
test "safe_url returns nil for blank string" do
result = safe_url("")
assert_nil result
end
test "safe_url returns nil for nil" do
result = safe_url(nil)
assert_nil result
end
test "safe_url returns nil for ftp URL" do
url = "ftp://example.com"
result = safe_url(url)
assert_nil result
end
test "safe_url returns nil for file URL" do
url = "file:///etc/passwd"
result = safe_url(url)
assert_nil result
end
end

View File

@@ -0,0 +1,17 @@
# frozen_string_literal: true
def assert_permit(user, record, action)
msg = "User #{user.inspect} should be permitted to #{action} #{record}, but isn't permitted"
assert permit(user, record, action), msg
end
def refute_permit(user, record, action)
msg = "User #{user.inspect} should NOT be permitted to #{action} #{record}, but is permitted"
assert_not permit(user, record, action), msg
end
def permit(user, record, action)
test_name = self.class.ancestors.select { |a| a.to_s.match(/PolicyTest/) }.first
klass = test_name.to_s.gsub("Test", "")
klass.constantize.new(user, record).public_send("#{action}?")
end

View File

@@ -0,0 +1,76 @@
# frozen_string_literal: true
require "test_helper"
class NavbarPolicyVisibilityTest < ActionDispatch::IntegrationTest
include Devise::Test::IntegrationHelpers
def setup
classroom = create(:classroom)
@student = User.create!(username: "student", type: "Student", password: "password", classroom: classroom)
@teacher = User.create!(username: "teacher", type: "Teacher", password: "password", email: "teacher@test.com")
@admin = User.create!(
username: "admin", type: "Teacher", admin: true, password: "password",
email: "admin@test.com"
)
@portfolio = Portfolio.create!(user: @student)
end
test "student sees My Portfolio if permitted" do
sign_in @student
get root_path
assert_select "a", text: "My Portfolio"
end
test "teacher sees Classes if permitted" do
sign_in @teacher
get root_path
assert_select "a", text: "Classes"
end
test "admin sees Admin if permitted" do
sign_in @admin
get root_path
assert_select "a", text: "Admin"
end
test "navbar shows only active stocks" do
active_stock = create(:stock, archived: false, ticker: "AAPL")
archived_stock = create(:stock, archived: true, ticker: "DEAD")
sign_in @student
get root_path
assert_select "p", text: active_stock.ticker
assert_select "p", text: archived_stock.ticker, count: 0
end
test "navbar stocks are displayed in trading floor dropdown" do
active_stock1 = create(:stock, archived: false, ticker: "AAPL")
active_stock2 = create(:stock, archived: false, ticker: "GOOGL")
archived_stock = create(:stock, archived: true, ticker: "DEAD")
sign_in @student
get root_path
assert_select "details[data-controller='stock-navbar-toggle']" do
assert_select "p", text: active_stock1.ticker
assert_select "p", text: active_stock2.ticker
end
assert_select "details[data-controller='stock-navbar-toggle']" do
assert_select "p", text: archived_stock.ticker, count: 0
end
end
test "navbar stocks link to correct stock paths" do
active_stock = create(:stock, archived: false, ticker: "AAPL")
sign_in @student
get root_path
assert_select "a[href='#{stock_path(active_stock)}']" do
assert_select "p", text: active_stock.ticker
end
end
end

View File

@@ -0,0 +1,169 @@
# frozen_string_literal: true
require "test_helper"
class OrderExecutionWorkflowTest < ActionDispatch::IntegrationTest
test "buy order execution updates portfolio correctly" do
classroom = create(:classroom, :with_trading)
student = create(:student, classroom: classroom)
portfolio = student.portfolio
initial_deposit_cents = 100_000
create(:portfolio_transaction, :deposit, portfolio: portfolio, amount_cents: initial_deposit_cents)
stock = create(:stock, ticker: "AAPL", price_cents: 15_000)
shares_to_buy = 3
order = create(:order, :pending, :buy, user: student, stock: stock, shares: shares_to_buy)
purchase_cost_cents = stock.price_cents * shares_to_buy
# Sanity check: order is pending
assert order.pending?, "order should be pending before job execution"
OrderExecutionJob.perform_now
order.reload
portfolio.reload
assert order.completed?, "order should be marked as completed"
assert_not_nil order.portfolio_transaction, "order should have linked transaction"
assert_not_nil order.portfolio_stock, "order should have linked portfolio stock"
assert order.portfolio_transaction.debit?, "buy order should create debit transaction"
assert_equal purchase_cost_cents, order.portfolio_transaction.amount_cents
assert_equal shares_to_buy, order.portfolio_stock.shares
assert_equal stock.id, order.portfolio_stock.stock_id
# Verify cash balance (cash_balance returns dollars)
expected_cash_cents = initial_deposit_cents - purchase_cost_cents - PortfolioTransaction::TRANSACTION_FEE_CENTS
actual_cash_cents = (portfolio.cash_balance * 100).to_i
assert_equal expected_cash_cents, actual_cash_cents
end
test "sell order execution updates portfolio correctly" do
classroom = create(:classroom, :with_trading)
student = create(:student, classroom: classroom)
portfolio = student.portfolio
stock = create(:stock, ticker: "GOOGL", price_cents: 20_000)
shares_owned = 10
shares_to_sell = 4
create(
:portfolio_stock, portfolio: portfolio, stock: stock, shares: shares_owned,
purchase_price: stock.current_price
)
order = create(:order, :pending, :sell, user: student, stock: stock, shares: shares_to_sell)
sale_proceeds_cents = stock.price_cents * shares_to_sell
# Sanity check
assert order.pending?, "order should be pending before job execution"
OrderExecutionJob.perform_now
order.reload
portfolio.reload
assert order.completed?, "order should be marked as completed"
assert order.portfolio_transaction.credit?, "sell order should create credit transaction"
assert_equal sale_proceeds_cents, order.portfolio_transaction.amount_cents
assert_equal(-shares_to_sell, order.portfolio_stock.shares, "sell order should create negative shares record")
remaining_shares = portfolio.shares_owned(stock.id).to_i
assert_equal shares_owned - shares_to_sell, remaining_shares
end
test "multiple orders in single job run with single fee per user" do
classroom = create(:classroom, :with_trading)
student1 = create(:student, classroom: classroom)
create(:portfolio_transaction, :deposit, portfolio: student1.portfolio, amount_cents: 200_000)
student2 = create(:student, classroom: classroom)
create(:portfolio_transaction, :deposit, portfolio: student2.portfolio, amount_cents: 200_000)
stock = create(:stock, ticker: "AAPL", price_cents: 10_000)
order1_s1 = create(:order, :pending, :buy, user: student1, stock: stock, shares: 2)
order2_s1 = create(:order, :pending, :buy, user: student1, stock: stock, shares: 1)
order1_s2 = create(:order, :pending, :buy, user: student2, stock: stock, shares: 3)
# Sanity checks
assert order1_s1.pending?
assert order2_s1.pending?
assert order1_s2.pending?
assert_equal 0, student1.portfolio.portfolio_transactions.fees.count
assert_equal 0, student2.portfolio.portfolio_transactions.fees.count
OrderExecutionJob.perform_now
[order1_s1, order2_s1, order1_s2].each do |order|
order.reload
assert order.completed?, "all orders should be completed"
end
student1.portfolio.reload
student2.portfolio.reload
# Each user should have exactly 1 fee transaction, regardless of order count
assert_equal 1, student1.portfolio.portfolio_transactions.fees.count, "student1 should have exactly 1 fee"
assert_equal 1, student2.portfolio.portfolio_transactions.fees.count, "student2 should have exactly 1 fee"
assert_equal PortfolioTransaction::TRANSACTION_FEE_CENTS,
student1.portfolio.portfolio_transactions.fees.sum(:amount_cents)
s1_purchase_cost_cents = stock.price_cents * 3 # 2 + 1 shares
s1_expected_cash_cents = 200_000 - s1_purchase_cost_cents - PortfolioTransaction::TRANSACTION_FEE_CENTS
s1_actual_cash_cents = (student1.portfolio.cash_balance * 100).to_i
assert_equal s1_expected_cash_cents, s1_actual_cash_cents
s2_purchase_cost_cents = stock.price_cents * 3
s2_expected_cash_cents = 200_000 - s2_purchase_cost_cents - PortfolioTransaction::TRANSACTION_FEE_CENTS
s2_actual_cash_cents = (student2.portfolio.cash_balance * 100).to_i
assert_equal s2_expected_cash_cents, s2_actual_cash_cents
end
test "mixed buy and sell orders execute correctly" do
classroom = create(:classroom, :with_trading)
student = create(:student, classroom: classroom)
portfolio = student.portfolio
initial_deposit_cents = 100_000
create(:portfolio_transaction, :deposit, portfolio: portfolio, amount_cents: initial_deposit_cents)
stock1 = create(:stock, ticker: "AAPL", price_cents: 10_000)
stock2 = create(:stock, ticker: "GOOGL", price_cents: 20_000)
initial_stock2_shares = 5
create(
:portfolio_stock, portfolio: portfolio, stock: stock2, shares: initial_stock2_shares,
purchase_price: stock2.current_price
)
buy_shares = 2
sell_shares = 3
buy_order = create(:order, :pending, :buy, user: student, stock: stock1, shares: buy_shares)
sell_order = create(:order, :pending, :sell, user: student, stock: stock2, shares: sell_shares)
# Sanity checks
assert buy_order.pending?
assert sell_order.pending?
buy_cost_cents = stock1.price_cents * buy_shares
sell_proceeds_cents = stock2.price_cents * sell_shares
OrderExecutionJob.perform_now
buy_order.reload
sell_order.reload
portfolio.reload
assert buy_order.completed?, "buy order should be completed"
assert sell_order.completed?, "sell order should be completed"
assert_equal buy_shares, portfolio.shares_owned(stock1.id).to_i
assert_equal initial_stock2_shares - sell_shares, portfolio.shares_owned(stock2.id).to_i
# Cash = initial + sell proceeds - buy cost - fee
expected_cash_cents = initial_deposit_cents + sell_proceeds_cents - buy_cost_cents - PortfolioTransaction::TRANSACTION_FEE_CENTS
actual_cash_cents = (portfolio.cash_balance * 100).to_i
assert_equal expected_cash_cents, actual_cash_cents
end
end

View File

@@ -0,0 +1,91 @@
# frozen_string_literal: true
require "test_helper"
class OrdersScopingTest < ActionDispatch::IntegrationTest
test "student sees only their own orders" do
stock = create(:stock, price_cents: 1_000)
classroom = create(:classroom, :with_trading)
student1, order1 = create_student_and_order(stock, classroom)
_student2, order2 = create_student_and_order(stock, classroom)
sign_in(student1)
get orders_path
assert_response :success
assert_select "tr##{dom_id(order1)}"
assert_select "tr##{dom_id(order2)}", count: 0
end
test "teacher sees orders from students in their classrooms" do
stock = create(:stock, price_cents: 1_000)
classroom1, order1 = create_classroom_and_student_order(stock)
_classroom2, order2 = create_classroom_and_student_order(stock)
classroom3, order3 = create_classroom_and_student_order(stock)
teacher = create(:teacher, classrooms: [classroom1, classroom3])
sign_in(teacher)
get orders_path
assert_response :success
assert_select "tr##{dom_id(order1)}"
assert_select "tr##{dom_id(order3)}"
assert_select "tr##{dom_id(order2)}", count: 0
end
test "admin sees all orders" do
admin = create(:admin)
stock = create(:stock, price_cents: 1_000)
_classroom1, order1 = create_classroom_and_student_order(stock)
_classroom2, order2 = create_classroom_and_student_order(stock)
sign_in(admin)
get orders_path
assert_response :success
assert_select "tr##{dom_id(order1)}"
assert_select "tr##{dom_id(order2)}"
end
test "user with no orders sees empty list" do
stock = create(:stock, price_cents: 1_000)
create_classroom_and_student_order(stock)
student = create(:student)
sign_in(student)
get orders_path
assert_response :success
assert_select "tr[data-testid='no-orders']"
end
test "teacher with no classrooms sees no orders" do
stock = create(:stock, price_cents: 1_000)
create_classroom_and_student_order(stock)
teacher = create(:teacher, classrooms: [])
sign_in(teacher)
get orders_path
assert_response :success
assert_select "tr[data-testid='no-orders']"
end
private
def create_classroom_and_student_order(stock)
classroom = create(:classroom, :with_trading)
_student, order = create_student_and_order(stock, classroom)
[classroom, order]
end
def create_student_and_order(stock, classroom)
student = create(:student, classroom:)
portfolio = create(:portfolio, user: student)
create(:portfolio_transaction, :deposit, portfolio:, amount_cents: 10_000)
order = create(:order, :buy, user: student, stock:)
[student, order]
end
end

View File

@@ -0,0 +1,41 @@
# frozen_string_literal: true
require "test_helper"
class MonthlyPortfolioSnapshotJobTest < ActiveJob::TestCase
setup do
@user = create(:student)
@portfolio = @user.portfolio
@target_date = Date.new(2024, 12, 1)
end
test "creates snapshot calculating cash and stock holdings" do
create(:portfolio_transaction, :deposit, portfolio: @portfolio, amount_cents: 50_000)
stock = create(:stock, price_cents: 10_000)
create(:portfolio_stock, portfolio: @portfolio, stock: stock, shares: 10)
assert_difference("PortfolioSnapshot.count", 1) do
MonthlyPortfolioSnapshotJob.perform_now(@target_date)
end
snapshot = @portfolio.portfolio_snapshots.find_by(date: @target_date)
expected_value = @portfolio.calculate_total_value_cents
assert_equal expected_value, snapshot.worth_cents
end
test "handles empty portfolio database" do
Portfolio.delete_all
assert_no_difference("PortfolioSnapshot.count") do
MonthlyPortfolioSnapshotJob.perform_now(@target_date)
end
end
test "skips existing snapshots" do
create(:portfolio_snapshot, portfolio: @portfolio, date: @target_date)
assert_no_difference("PortfolioSnapshot.count") do
MonthlyPortfolioSnapshotJob.perform_now(@target_date)
end
end
end

View File

@@ -0,0 +1,53 @@
# frozen_string_literal: true
require "test_helper"
class OrderExecutionJobTest < ActiveJob::TestCase
test "with pending orders" do
portfolio1 = create(:portfolio)
student1 = create(:student, portfolio: portfolio1)
create(
:portfolio_transaction,
:deposit,
portfolio: portfolio1,
amount_cents: 10_000
)
portfolio2 = create(:portfolio)
student2 = create(:student, portfolio: portfolio2)
create(
:portfolio_transaction,
:deposit,
portfolio: portfolio2,
amount_cents: 10_000
)
stock = create(:stock, price_cents: 1_000)
order1 = create(:order, :pending, :buy, user: student1, stock:, shares: 1)
order2 = create(:order, :pending, :buy, user: student2, stock:, shares: 2)
ExecuteOrder.expects(:execute).with(order1).once
ExecuteOrder.expects(:execute).with(order2).once
TransactionFeeProcessor.expects(:execute).once
OrderExecutionJob.perform_now
end
test "with no pending orders" do
ExecuteOrder.expects(:execute).never
TransactionFeeProcessor.expects(:execute).never
OrderExecutionJob.perform_now
end
test "when an error occurs" do
portfolio = create(:portfolio)
student = create(:student, portfolio:)
create(:portfolio_transaction, :deposit, portfolio:, amount_cents: 10_000)
stock = create(:stock, price_cents: 1_000)
create(:order, :pending, :buy, user: student, stock:, shares: 1)
ExecuteOrder.stubs(:execute).raises(StandardError.new("Test error"))
assert_raises(StandardError) do
OrderExecutionJob.perform_now
end
end
end

View File

@@ -0,0 +1,74 @@
# frozen_string_literal: true
require "test_helper"
class StockAttributeUpdateJobTest < ActiveJob::TestCase
test "processes all stocks in the database" do
stock1 = create(:stock, ticker: "STOCK1")
stock2 = create(:stock, ticker: "STOCK2")
stock3 = create(:stock, ticker: "STOCK3")
Net::HTTP.expects(:get).times(3).returns(
{ "Symbol" => "STOCK1", "Name" => "Company 1" }.to_json,
{ "Symbol" => "STOCK2", "Name" => "Company 2" }.to_json,
{ "Symbol" => "STOCK3", "Name" => "Company 3" }.to_json
)
StockAttributeUpdateJob.perform_now
stock1.reload
stock2.reload
stock3.reload
assert_equal "Company 1", stock1.company_name
assert_equal "Company 2", stock2.company_name
assert_equal "Company 3", stock3.company_name
end
test "handles stocks with invalid API responses" do
stock1 = create(:stock, ticker: "VALID")
stock2 = create(:stock, ticker: "INVALID")
original_name = stock2.company_name
Net::HTTP.expects(:get).times(2).returns(
{ "Symbol" => "VALID", "Name" => "Valid Company" }.to_json,
"{}"
)
StockAttributeUpdateJob.perform_now
stock1.reload
stock2.reload
assert_equal "Valid Company", stock1.company_name
assert_equal original_name, stock2.company_name
end
test "skips stocks without ticker" do
stock_with_ticker = create(:stock, ticker: "HAS_TICKER")
stock_without_ticker = Stock.new(description: "Test stock")
stock_without_ticker.save(validate: false)
Net::HTTP.expects(:get).once.returns(
{ "Symbol" => "HAS_TICKER", "Name" => "Has Ticker Company" }.to_json
)
StockAttributeUpdateJob.perform_now
stock_with_ticker.reload
stock_without_ticker.reload
assert_equal "Has Ticker Company", stock_with_ticker.company_name
assert_nil stock_without_ticker.company_name
end
test "handles empty stock table" do
Stock.delete_all
Net::HTTP.expects(:get).never
assert_nothing_raised do
StockAttributeUpdateJob.perform_now
end
end
end

View File

@@ -0,0 +1,125 @@
# frozen_string_literal: true
require "test_helper"
class StockPricesUpdateJobTest < ActiveJob::TestCase
STOCK_URL_MATCHER = %r{https://www\.alphavantage\.co/query\?apikey=[^&]+&function=GLOBAL_QUOTE&symbol=[A-Z]+}
setup do
ENV["ALPHA_VANTAGE_API_KEY"] = "test_api_key"
@last_trading_date = Date.parse("2024-05-31")
stub_request(:get, STOCK_URL_MATCHER)
.with(
headers: {
"Accept" => "*/*",
"Accept-Encoding" => "gzip;q=1.0,deflate;q=0.6,identity;q=0.3",
"Host" => "www.alphavantage.co",
"User-Agent" => "Ruby"
}
)
.to_return(status: 200, body: Rails.root.join("./test/data/global_quote_f.json").open)
end
test "makes API calls" do
create(:stock, ticker: "AAPL")
create(:stock, ticker: "KO")
StockPricesUpdateJob.perform_now
assert_requested :get, STOCK_URL_MATCHER, times: 2
end
test "updates existing Stock records" do
stock1 = create(:stock, ticker: "AAPL", price_cents: 0)
stock2 = create(:stock, ticker: "KO", price_cents: 0)
assert_no_difference("Stock.count") do
StockPricesUpdateJob.perform_now
end
stock1.reload
stock2.reload
assert stock1.price_cents.positive?
assert stock2.price_cents.positive?
end
test "sets Stock ticker and price" do
test_tickers = %w[AAPL KO DIS]
test_tickers.each { |ticker| create(:stock, ticker: ticker, price_cents: 0) }
StockPricesUpdateJob.perform_now
test_tickers.each do |ticker|
stock = Stock.find_by(ticker: ticker)
assert stock.present?
assert stock.price_cents.present?
end
end
test "handles empty stock database gracefully" do
# Ensure no stocks exist
Stock.delete_all
assert_no_difference("Stock.count") do
assert_nothing_raised do
StockPricesUpdateJob.perform_now
end
end
assert_not_requested :get, STOCK_URL_MATCHER
end
test "stores current price as yesterday price before updating" do
initial_price = 12_345
stock = create(:stock, ticker: "AAPL", price_cents: initial_price, yesterday_price_cents: nil)
StockPricesUpdateJob.perform_now
stock.reload
assert_equal initial_price, stock.yesterday_price_cents
assert_not_equal initial_price, stock.price_cents
end
test "preserves yesterday price when API call fails" do
initial_price = 12_345
stock = create(:stock, ticker: "AAPL", price_cents: initial_price, yesterday_price_cents: nil)
stub_request(:get, STOCK_URL_MATCHER).to_return(status: 200, body: "{}")
StockPricesUpdateJob.perform_now
stock.reload
assert_equal initial_price, stock.yesterday_price_cents
assert_equal initial_price, stock.price_cents
end
test "stores trading day when updating stock price" do
stock = create(:stock, ticker: "AAPL", price_cents: 10_000, last_trading_day: nil)
StockPricesUpdateJob.perform_now
stock.reload
assert_equal @last_trading_date, stock.last_trading_day
end
test "skips update when trading day hasn't changed" do
initial_price = 10_000
stock = create(:stock, ticker: "AAPL", price_cents: initial_price, last_trading_day: @last_trading_date)
StockPricesUpdateJob.perform_now
stock.reload
assert_equal initial_price, stock.price_cents
assert_equal @last_trading_date, stock.last_trading_day
end
test "updates when trading day is newer" do
prev_trading_date = @last_trading_date - 1.day
stock = create(:stock, ticker: "AAPL", price_cents: 10_000, last_trading_day: prev_trading_date)
StockPricesUpdateJob.perform_now
stock.reload
assert_equal @last_trading_date, stock.last_trading_day
assert stock.price_cents.positive?
end
end

View File

@@ -0,0 +1,25 @@
# frozen_string_literal: true
# test/mailers/previews/devise_mailer_preview.rb
class DeviseMailerPreview < ActionMailer::Preview
def confirmation_instructions
Devise::Mailer.confirmation_instructions(User.first || User.new(email: "test@example.com"), "faketoken")
end
def reset_password_instructions
Devise::Mailer.reset_password_instructions(User.first || User.new(email: "test@example.com"), "faketoken")
end
def unlock_instructions
Devise::Mailer.unlock_instructions(User.first || User.new(email: "test@example.com"), "faketoken")
end
def email_changed
Devise::Mailer.email_changed(User.first || User.new(email: "test@example.com"))
end
def password_change
Devise::Mailer.password_change(User.first || User.new(email: "test@example.com"))
end
end

View File

@@ -0,0 +1,125 @@
# frozen_string_literal: true
require "test_helper"
class AnnouncementTest < ActiveSupport::TestCase
test "should be valid with valid attributes" do
announcement = Announcement.new(title: "Test Title", content: "Test content")
assert announcement.valid?
end
test "should require title" do
announcement = Announcement.new(content: "Test content")
assert_not announcement.valid?
assert_includes announcement.errors[:title], "can't be blank"
end
test "should require content" do
announcement = Announcement.new(title: "Test Title")
assert_not announcement.valid?
assert_includes announcement.errors[:content], "can't be blank"
end
test "should validate content length" do
long_title = "a" * 256
announcement = Announcement.new(title: long_title, content: "Test content")
assert_not announcement.valid?
assert_includes announcement.errors[:title], "is too long (maximum is 255 characters)"
end
test "should have rich text content" do
announcement = Announcement.new(title: "Test", content: "Test content")
assert_respond_to announcement, :content
# ActionText creates a rich_text association
assert_respond_to announcement, :rich_text_content
end
test "should create announcement with ActionText content" do
announcement = Announcement.create!(
title: "Test with Rich Text",
content: "<p>This is <strong>bold</strong> text</p>"
)
assert_not_nil announcement.content
assert_equal "<div class=\"trix-content\">\n <p>This is <strong>bold</strong> text</p>\n</div>\n",
announcement.content.to_s
end
test "excerpt method should return truncated plain text content" do
long_content = "This is a very long announcement content that should be truncated when excerpted. " * 5
announcement = Announcement.create!(title: "Long Content", content: long_content)
excerpt = announcement.excerpt(limit: 100)
assert excerpt.length <= 100
assert excerpt.end_with?("...")
assert_not_includes excerpt, "<p>"
assert_includes excerpt, "This is a very long announcement content"
end
test "published_at should return created_at" do
announcement = Announcement.create!(title: "Test", content: "Test content")
assert_equal announcement.created_at, announcement.published_at
end
test "latest scope should return announcements in reverse chronological order" do
old_announcement = Announcement.create!(title: "Old", content: "Old content", created_at: 1.day.ago)
new_announcement = Announcement.create!(title: "New", content: "New content")
latest_announcements = Announcement.latest
assert_equal new_announcement, latest_announcements.first
assert_equal old_announcement, latest_announcements.last
end
test "current should return the featured announcement" do
Announcement.create!(title: "Old", content: "Old content", featured: false)
featured_announcement = Announcement.create!(title: "Featured", content: "Featured content", featured: true)
Announcement.create!(title: "New", content: "New content", featured: false)
assert_equal featured_announcement, Announcement.current
end
test "current should return nil when no announcement is featured" do
Announcement.create!(title: "Old", content: "Old content", featured: false)
assert_nil Announcement.current
end
test "current should return nil when no announcements exist" do
assert_nil Announcement.current
end
test "should default featured to false" do
announcement = Announcement.create!(title: "Test", content: "Test content")
assert_equal false, announcement.featured
end
test "should allow setting featured to true" do
announcement = Announcement.create!(title: "Test", content: "Test content", featured: true)
assert_equal true, announcement.featured
end
test "should unfeature other announcements when one is featured" do
announcement1 = Announcement.create!(title: "First", content: "First content", featured: true)
announcement2 = Announcement.create!(title: "Second", content: "Second content", featured: true)
announcement1.reload
assert_equal false, announcement1.featured
assert_equal true, announcement2.featured
end
test "should allow multiple announcements with featured false" do
announcement1 = Announcement.create!(title: "First", content: "First content", featured: false)
announcement2 = Announcement.create!(title: "Second", content: "Second content", featured: false)
assert_equal false, announcement1.featured
assert_equal false, announcement2.featured
end
test "should unfeature announcement when updating another to featured" do
announcement1 = Announcement.create!(title: "First", content: "First content", featured: true)
announcement2 = Announcement.create!(title: "Second", content: "Second content", featured: false)
announcement2.update!(featured: true)
announcement1.reload
assert_equal false, announcement1.featured
assert_equal true, announcement2.featured
end
end

View File

@@ -0,0 +1,183 @@
# frozen_string_literal: true
require "test_helper"
class ClassroomEnrollmentTest < ActiveSupport::TestCase
test "factory" do
assert build(:classroom_enrollment).valid?
end
test "belongs to student" do
enrollment = build(:classroom_enrollment)
assert enrollment.respond_to?(:student)
end
test "belongs to classroom" do
enrollment = build(:classroom_enrollment)
assert enrollment.respond_to?(:classroom)
end
test "requires student_id" do
enrollment = build(:classroom_enrollment, student: nil)
assert_not enrollment.valid?
assert_includes enrollment.errors[:student], "must exist"
end
test "requires classroom_id" do
enrollment = build(:classroom_enrollment, classroom: nil)
assert_not enrollment.valid?
assert_includes enrollment.errors[:classroom], "must exist"
end
test "requires enrolled_at" do
enrollment = build(:classroom_enrollment, enrolled_at: nil)
assert_not enrollment.valid?
assert_includes enrollment.errors[:enrolled_at], "can't be blank"
end
test "unenrolled_at must be after enrolled_at" do
enrollment = build(
:classroom_enrollment,
enrolled_at: 1.day.ago,
unenrolled_at: 2.days.ago
)
assert_not enrollment.valid?
assert_includes enrollment.errors[:unenrolled_at], "must be after enrolled_at"
end
test "unenrolled_at can be equal to enrolled_at" do
time = Time.current
enrollment = build(
:classroom_enrollment,
enrolled_at: time,
unenrolled_at: time
)
assert enrollment.valid?
end
test "student can only have one primary enrollment" do
student = create(:student, :without_enrollment)
classroom1 = create(:classroom)
classroom2 = create(:classroom)
create(:classroom_enrollment, student: student, classroom: classroom1, primary: true)
enrollment2 = build(:classroom_enrollment, student: student, classroom: classroom2, primary: true)
assert_not enrollment2.valid?
assert_includes enrollment2.errors[:primary], "student can only have one primary enrollment"
end
test "current scope returns enrollments with nil unenrolled_at" do
student = create(:student, :without_enrollment)
current = create(:classroom_enrollment, student: student, unenrolled_at: nil)
historical = create(:classroom_enrollment, student: student, enrolled_at: 2.days.ago, unenrolled_at: 1.day.ago)
assert_includes ClassroomEnrollment.current, current
assert_not_includes ClassroomEnrollment.current, historical
end
test "historical scope returns enrollments with unenrolled_at set" do
student = create(:student, :without_enrollment)
current = create(:classroom_enrollment, student: student, unenrolled_at: nil)
historical = create(:classroom_enrollment, student: student, enrolled_at: 2.days.ago, unenrolled_at: 1.day.ago)
assert_includes ClassroomEnrollment.historical, historical
assert_not_includes ClassroomEnrollment.historical, current
end
test "primary_enrollment scope returns only primary enrollments" do
student1 = create(:student, :without_enrollment)
student2 = create(:student, :without_enrollment)
primary = create(:classroom_enrollment, student: student1, primary: true)
non_primary = create(:classroom_enrollment, student: student2, primary: false)
assert_includes ClassroomEnrollment.primary_enrollment, primary
assert_not_includes ClassroomEnrollment.primary_enrollment, non_primary
end
test "make_primary! marks enrollment as primary" do
student = create(:student)
enrollment = create(:classroom_enrollment, student: student, primary: false)
enrollment.make_primary!
assert enrollment.reload.primary
end
test "make_primary! demotes other primary enrollments for same student" do
student = create(:student, :without_enrollment)
classroom1 = create(:classroom)
classroom2 = create(:classroom)
old_primary = create(:classroom_enrollment, student: student, classroom: classroom1, primary: true)
new_enrollment = create(:classroom_enrollment, student: student, classroom: classroom2, primary: false)
new_enrollment.make_primary!
assert_not old_primary.reload.primary
assert new_enrollment.reload.primary
end
test "make_primary! returns self" do
enrollment = create(:classroom_enrollment)
result = enrollment.make_primary!
assert_equal enrollment, result
end
test "unenroll! sets unenrolled_at to current time by default" do
student = create(:student, :without_enrollment)
enrollment = create(
:classroom_enrollment, student: student, enrolled_at: 1.hour.ago, unenrolled_at: nil,
primary: true
)
freeze_time do
enrollment.unenroll!
assert_in_delta Time.current, enrollment.reload.unenrolled_at, 1.second
end
end
test "unenroll! accepts custom unenrollment time" do
student = create(:student, :without_enrollment)
enrollment = create(:classroom_enrollment, student: student, enrolled_at: 2.weeks.ago, unenrolled_at: nil)
custom_time = 1.week.ago
enrollment.unenroll!(at: custom_time)
assert_in_delta custom_time, enrollment.reload.unenrolled_at, 1.second
end
test "unenroll! sets primary to false" do
student = create(:student, :without_enrollment)
enrollment = create(:classroom_enrollment, student: student, primary: true)
enrollment.unenroll!
assert_not enrollment.reload.primary
end
test "unenroll! returns self" do
enrollment = create(:classroom_enrollment)
result = enrollment.unenroll!
assert_equal enrollment, result
end
test "current? returns true when unenrolled_at is nil" do
enrollment = build(:classroom_enrollment, unenrolled_at: nil)
assert enrollment.current?
end
test "current? returns false when unenrolled_at is set" do
enrollment = build(:classroom_enrollment, unenrolled_at: 1.day.ago)
assert_not enrollment.current?
end
test "historical? returns false when unenrolled_at is nil" do
enrollment = build(:classroom_enrollment, unenrolled_at: nil)
assert_not enrollment.historical?
end
test "historical? returns true when unenrolled_at is set" do
enrollment = build(:classroom_enrollment, unenrolled_at: 1.day.ago)
assert enrollment.historical?
end
end

View File

@@ -0,0 +1,42 @@
# frozen_string_literal: true
require "test_helper"
class ClassroomGradeTest < ActiveSupport::TestCase
test "factory" do
assert build(:classroom_grade).validate!
end
test "belongs to classroom" do
classroom = create(:classroom)
cg = create(:classroom_grade, classroom: classroom)
assert_equal classroom, cg.classroom
end
test "belongs to grade" do
grade = create(:grade)
cg = create(:classroom_grade, grade: grade)
assert_equal grade, cg.grade
end
test "validates uniqueness of grade scoped to classroom" do
classroom = create(:classroom)
grade = create(:grade)
create(:classroom_grade, classroom: classroom, grade: grade)
duplicate = build(:classroom_grade, classroom: classroom, grade: grade)
assert_not duplicate.valid?
assert_includes duplicate.errors[:grade_id], "has already been taken"
end
test "destroying classroom destroys classroom_grade" do
classroom = create(:classroom)
create(:classroom_grade, classroom: classroom)
assert_difference "ClassroomGrade.count", -2 do
classroom.destroy
end
end
end

View File

@@ -0,0 +1,167 @@
# frozen_string_literal: true
require "test_helper"
class ClassroomTest < ActiveSupport::TestCase
test "factory" do
assert build(:classroom).validate!
end
test "name is required" do
classroom = build(:classroom, name: "")
assert_not classroom.valid?
assert_includes classroom.errors[:name], "can't be blank"
end
test "destroying classroom nullifies user classroom_id instead of destroying users" do
classroom = create(:classroom)
student = create(:student, classroom: classroom)
teacher = create(:teacher)
teacher.classrooms << classroom
admin = create(:admin, classroom: classroom)
user_ids = [student.id, teacher.id, admin.id]
classroom.destroy!
user_ids.each do |user_id|
assert User.exists?(user_id), "User #{user_id} should still exist after classroom destruction"
end
# Students and admins should have their classroom_id nullified
[student, admin].each do |user|
user.reload
assert_nil user.classroom_id, "User #{user.id} classroom_id should be null after classroom destruction"
end
# Teachers should still exist but have no classrooms
teacher.reload
assert_equal 0, teacher.classrooms.count, "Teacher should have no classrooms after classroom destruction"
end
test "teachers can belong to multiple classrooms" do
teacher = create(:teacher)
classroom1 = create(:classroom)
classroom2 = create(:classroom)
teacher.classrooms << classroom1
teacher.classrooms << classroom2
assert_equal 2, teacher.classrooms.count
assert_includes teacher.classrooms, classroom1
assert_includes teacher.classrooms, classroom2
end
test "has many students through classrooms association" do
classroom = create(:classroom)
student1 = create(:student, classroom: classroom)
student2 = create(:student, classroom: classroom)
assert_includes classroom.students, student1
assert_includes classroom.students, student2
assert_equal 2, classroom.students.count
end
test "has many teachers through teacher_classrooms association" do
classroom = create(:classroom)
teacher1 = create(:teacher, classrooms: [classroom])
teacher2 = create(:teacher, classrooms: [classroom])
assert_includes classroom.teachers, teacher1
assert_includes classroom.teachers, teacher2
assert_equal 2, classroom.teachers.count
end
test "students association only includes Student type users" do
classroom = create(:classroom)
student = create(:student, classroom: classroom)
teacher = create(:teacher, classroom: classroom)
admin = create(:admin, classroom: classroom)
assert_includes classroom.students, student
assert_not_includes classroom.students, teacher
assert_not_includes classroom.students, admin
end
test "teachers association only includes Teacher type users" do
classroom = create(:classroom)
student = create(:student)
admin = create(:admin)
assert_raises(ActiveRecord::AssociationTypeMismatch) { classroom.teachers << student }
assert_raises(ActiveRecord::AssociationTypeMismatch) { classroom.teachers << admin }
end
test "students association excludes discarded students" do
classroom = create(:classroom)
kept_student = create(:student, classroom: classroom)
discarded_student = create(:student, classroom: classroom)
discarded_student.discard
assert_includes classroom.students, kept_student
assert_not_includes classroom.students, discarded_student
assert_equal [kept_student.id], classroom.students.pluck(:id)
end
test "creates gradebooks for all quarters when classroom is created" do
school_year = create(:school_year)
assert_difference("GradeBook.count", 4) do
classroom = create(:classroom, school_year: school_year)
school_year.quarters.each do |quarter|
assert GradeBook.exists?(classroom: classroom, quarter: quarter)
end
end
end
# Tests for sorting scopes
test "order_by_name sorts by name ascending" do
create(:classroom, name: "Zebra")
create(:classroom, name: "Alpha")
create(:classroom, name: "Middle")
classrooms = Classroom.order_by_name(:asc)
assert_equal %w[Alpha Middle Zebra], classrooms.pluck(:name)
end
test "order_by_name sorts by name descending" do
create(:classroom, name: "Zebra")
create(:classroom, name: "Alpha")
create(:classroom, name: "Middle")
classrooms = Classroom.order_by_name(:desc)
assert_equal %w[Zebra Middle Alpha], classrooms.pluck(:name)
end
test "order_by_student_count sorts by student count" do
classroom1 = create(:classroom)
classroom2 = create(:classroom)
create(:student, classroom: classroom1)
create(:student, classroom: classroom1)
create(:student, classroom: classroom2)
classrooms = Classroom.order_by_student_count(:desc)
assert_equal classroom1.id, classrooms.first.id
assert_equal classroom2.id, classrooms.last.id
end
test "apply_sorting defaults to name ascending" do
create(:classroom, name: "Zebra")
create(:classroom, name: "Alpha")
classrooms = Classroom.apply_sorting(nil, nil, nil)
assert_equal %w[Alpha Zebra], classrooms.pluck(:name)
end
test "apply_sorting with sort_column applies correct scope" do
classroom1 = create(:classroom, name: "Zebra")
classroom2 = create(:classroom, name: "Alpha")
create(:student, classroom: classroom1)
create(:student, classroom: classroom2)
create(:student, classroom: classroom1)
classrooms = Classroom.apply_sorting(nil, "student_count", :desc)
assert_equal classroom1.id, classrooms.first.id
end
end

View File

@@ -0,0 +1,79 @@
# frozen_string_literal: true
require "test_helper"
class EarningsSummaryTest < ActiveSupport::TestCase
setup do
@student = create(:student)
@portfolio = @student.portfolio
@earnings_summary = EarningsSummary.new(@portfolio)
end
test "should calculate attendance earnings" do
create(:portfolio_transaction, :deposit, portfolio: @portfolio, amount_cents: 500, reason: :attendance_earnings)
create(:portfolio_transaction, :deposit, portfolio: @portfolio, amount_cents: 300, reason: :attendance_earnings)
assert_equal 800, @earnings_summary.attendance_earnings_cents
end
test "should calculate reading earnings" do
create(:portfolio_transaction, :deposit, portfolio: @portfolio, amount_cents: 600, reason: :reading_earnings)
assert_equal 600, @earnings_summary.reading_earnings_cents
end
test "should calculate math earnings" do
create(:portfolio_transaction, :deposit, portfolio: @portfolio, amount_cents: 400, reason: :math_earnings)
create(:portfolio_transaction, :deposit, portfolio: @portfolio, amount_cents: 100, reason: :math_earnings)
assert_equal 500, @earnings_summary.math_earnings_cents
end
test "should calculate awards" do
create(:portfolio_transaction, :deposit, portfolio: @portfolio, amount_cents: 1000, reason: :awards)
assert_equal 1000, @earnings_summary.awards_cents
end
test "should calculate total earnings from all sources" do
create(:portfolio_transaction, :deposit, portfolio: @portfolio, amount_cents: 500, reason: :attendance_earnings)
create(:portfolio_transaction, :deposit, portfolio: @portfolio, amount_cents: 600, reason: :reading_earnings)
create(:portfolio_transaction, :deposit, portfolio: @portfolio, amount_cents: 400, reason: :math_earnings)
create(:portfolio_transaction, :deposit, portfolio: @portfolio, amount_cents: 1000, reason: :awards)
assert_equal 2500, @earnings_summary.total_earnings_cents
end
test "should calculate transaction fees" do
create(:portfolio_transaction, :deposit, portfolio: @portfolio, amount_cents: 100, reason: :transaction_fees)
create(:portfolio_transaction, :deposit, portfolio: @portfolio, amount_cents: 50, reason: :transaction_fees)
assert_equal 150, @earnings_summary.transaction_fees_cents
end
test "should return zero for earnings with no transactions" do
assert_equal 0, @earnings_summary.attendance_earnings_cents
assert_equal 0, @earnings_summary.reading_earnings_cents
assert_equal 0, @earnings_summary.math_earnings_cents
assert_equal 0, @earnings_summary.awards_cents
assert_equal 0, @earnings_summary.total_earnings_cents
assert_equal 0, @earnings_summary.transaction_fees_cents
end
test "should only sum deposits not debits" do
create(:portfolio_transaction, :deposit, portfolio: @portfolio, amount_cents: 500, reason: :math_earnings)
create(:portfolio_transaction, :debit, portfolio: @portfolio, amount_cents: 200, reason: :math_earnings)
assert_equal 500, @earnings_summary.math_earnings_cents
end
test "should only sum transactions for the specific portfolio" do
other_student = create(:student)
other_portfolio = other_student.portfolio
create(:portfolio_transaction, :deposit, portfolio: @portfolio, amount_cents: 500, reason: :math_earnings)
create(:portfolio_transaction, :deposit, portfolio: other_portfolio, amount_cents: 1000, reason: :math_earnings)
assert_equal 500, @earnings_summary.math_earnings_cents
end
end

View File

@@ -0,0 +1,27 @@
# frozen_string_literal: true
# test/models/grade_book_test.rb
require "test_helper"
class GradeBookTest < ActiveSupport::TestCase
test "factory" do
assert build(:grade_book).validate!
end
test "default status is draft" do
book = create(:grade_book)
assert_equal "draft", book.status
assert_predicate book, :draft?
end
test "status enum transitions work" do
book = create(:grade_book)
book.verified!
assert_equal "verified", book.status
assert_predicate book, :verified?
book.completed!
assert_equal "completed", book.status
assert_predicate book, :completed?
end
end

View File

@@ -0,0 +1,101 @@
# frozen_string_literal: true
# test/models/grade_entry_test.rb
require "test_helper"
class GradeEntryTest < ActiveSupport::TestCase
test "factory" do
assert build(:grade_entry).validate!
end
test "math_grade, reading_grade, and attendance_days accept any string or nil" do
entry = build(
:grade_entry,
math_grade: "A+",
reading_grade: "B-",
attendance_days: 45
)
assert entry.valid?
entry.math_grade = nil
entry.reading_grade = nil
entry.attendance_days = nil
assert entry.valid?
end
test "#earnings_for_attendance is 20 cents per day" do
entry = build(:grade_entry, attendance_days: 0)
assert_equal 0, entry.earnings_for_attendance
entry.attendance_days = 5
assert_equal 100, entry.earnings_for_attendance
entry.attendance_days = 12
assert_equal 240, entry.earnings_for_attendance
end
test "#attendance_perfect_earnings returns $1 only for perfect attendance" do
entry = build(:grade_entry, attendance_days: 5, is_perfect_attendance: true)
assert_equal 1_00, entry.attendance_perfect_earnings
entry.is_perfect_attendance = false
assert_equal 0, entry.attendance_perfect_earnings
end
test "#earnings_for_reading grade is $2 for B, $3 for an A" do
entry = build(:grade_entry, reading_grade: nil)
["F", "D", "C-", "C", "C+"].each do |grade|
entry.reading_grade = grade
assert_equal 0, entry.earnings_for_reading
end
["B-", "B", "B+"].each do |grade|
entry.reading_grade = grade
assert_equal 2_00, entry.earnings_for_reading
end
["A-", "A", "A+"].each do |grade|
entry.reading_grade = grade
assert_equal 3_00, entry.earnings_for_reading
end
end
test "#earnings_for_math grade is $2 for B, $3 for an A" do
entry = build(:grade_entry, math_grade: nil)
["F", "D", "C-", "C", "C+"].each do |grade|
entry.math_grade = grade
assert_equal 0, entry.earnings_for_math
end
["B-", "B", "B+"].each do |grade|
entry.math_grade = grade
assert_equal 2_00, entry.earnings_for_math
end
["A-", "A", "A+"].each do |grade|
entry.math_grade = grade
assert_equal 3_00, entry.earnings_for_math
end
end
test "subject improvement earnings returns 0 when no previous entry or no improvement" do
entry = build(:grade_entry, math_grade: "A", reading_grade: "A")
assert_equal 0, entry.math_improvement_earnings(nil)
assert_equal 0, entry.reading_improvement_earnings(nil)
previous_entry = build(:grade_entry, math_grade: "B", reading_grade: "B")
worse_entry = build(:grade_entry, math_grade: "C", reading_grade: "C")
assert_equal 0, worse_entry.math_improvement_earnings(previous_entry)
assert_equal 0, worse_entry.reading_improvement_earnings(previous_entry)
end
test "subject improvement earnings returns $2 when grade has improved" do
previous_entry = build(:grade_entry, math_grade: "C", reading_grade: "C")
improved_entry = build(:grade_entry, math_grade: "B", reading_grade: "B")
assert_equal 2_00, improved_entry.math_improvement_earnings(previous_entry)
assert_equal 2_00, improved_entry.reading_improvement_earnings(previous_entry)
end
end

View File

@@ -0,0 +1,76 @@
# frozen_string_literal: true
require "test_helper"
class GradeTest < ActiveSupport::TestCase
test "factory" do
assert build(:grade).validate!
end
test "has many classroom_grades" do
grade = create(:grade)
cg1 = create(:classroom_grade, grade: grade)
cg2 = create(:classroom_grade, grade: grade)
assert_equal 2, grade.classroom_grades.count
assert_includes grade.classroom_grades, cg1
assert_includes grade.classroom_grades, cg2
end
test "has many classrooms through classroom_grades" do
grade = create(:grade)
classroom1 = create(:classroom)
classroom2 = create(:classroom)
create(:classroom_grade, grade: grade, classroom: classroom1)
create(:classroom_grade, grade: grade, classroom: classroom2)
assert_equal 2, grade.classrooms.count
assert_includes grade.classrooms, classroom1
assert_includes grade.classrooms, classroom2
end
test "can destroy a grade with no classrooms" do
grade = create(:grade)
assert_difference "Grade.count", -1 do
grade.destroy
end
end
test "cannot destroy a grade that is used by classrooms" do
grade = create(:grade)
create(:classroom_grade, grade: grade)
assert_not grade.destroy
assert grade.errors.any?
assert_includes grade.errors[:base], "Cannot delete record because dependent classroom grades exist"
end
test "name must be present" do
grade = build(:grade, name: nil)
assert_not grade.valid?
assert_includes grade.errors[:name], "can't be blank"
end
test "level must be present" do
grade = build(:grade, level: nil)
assert_not grade.valid?
assert_includes grade.errors[:level], "can't be blank"
end
test "name is unique and case-insensitively" do
create(:grade, name: "5th Grade", level: 6)
duplicate = build(:grade, name: "5TH grade", level: 6)
assert_not duplicate.valid?
assert_includes duplicate.errors[:name], "has already been taken"
end
test "level is unique" do
create(:grade, name: "5th Grade", level: 6)
duplicate = build(:grade, name: "5th Grade", level: 6)
assert_not duplicate.valid?
assert_includes duplicate.errors[:level], "has already been taken"
end
end

View File

@@ -0,0 +1,456 @@
# frozen_string_literal: true
require "test_helper"
class OrderTest < ActiveSupport::TestCase
test "factory" do
user = create(:student)
stock = create(:stock)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 10)
order = create(:order, user: user, stock: stock, shares: 1, action: :sell)
assert order.valid?
end
test "defaults to pending status when created" do
user = create(:student)
stock = create(:stock)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 10)
order = create(:order, user: user, stock: stock, shares: 1, action: :sell)
assert_equal "pending", order.status
end
test ".pending" do
user = create(:student)
stock = create(:stock)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 10)
order1 = create(:order, status: :pending, action: :sell, user: user, stock: stock, shares: 1)
create(:order, status: :completed, action: :sell, user: user, stock: stock, shares: 1)
create(:order, status: :canceled, action: :sell, user: user, stock: stock, shares: 1)
order4 = create(:order, status: :pending, action: :sell, user: user, stock: stock, shares: 1)
assert_equal [order1, order4], Order.pending
end
test ".completed" do
user = create(:student)
stock = create(:stock)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 10)
order1 = create(:order, status: :completed, action: :sell, user: user, stock: stock, shares: 1)
create(:order, status: :pending, action: :sell, user: user, stock: stock, shares: 1)
create(:order, status: :canceled, action: :sell, user: user, stock: stock, shares: 1)
order4 = create(:order, status: :completed, action: :sell, user: user, stock: stock, shares: 1)
assert_equal [order1, order4], Order.completed
end
test ".canceled" do
user = create(:student)
stock = create(:stock)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 10)
order1 = create(:order, status: :canceled, action: :sell, user: user, stock: stock, shares: 1)
create(:order, status: :pending, action: :sell, user: user, stock: stock, shares: 1)
create(:order, status: :completed, action: :sell, user: user, stock: stock, shares: 1)
order4 = create(:order, status: :canceled, action: :sell, user: user, stock: stock, shares: 1)
assert_equal [order1, order4], Order.canceled
end
test "cannot buy archived stocks" do
user = create(:student)
stock = create(:stock, archived: true)
order = build(:order, action: :buy, user: user, stock: stock, shares: 1)
assert_not order.valid?
assert_includes order.errors[:stock], "Cannot purchase shares of archived stocks"
end
test "can sell archived stocks" do
user = create(:student)
stock = create(:stock, archived: true)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 10)
order = build(:order, action: :sell, user: user, stock: stock, shares: 1)
assert order.valid?
end
test "#purchase_cost" do
user = create(:student)
stock = create(:stock, price_cents: 1_000)
# Add funds for buy order
user.portfolio.portfolio_transactions.create!(amount_cents: 10_000, transaction_type: :deposit)
order = create(:order, action: :buy, user: user, stock: stock, shares: 5.1)
assert_equal 5_100, order.purchase_cost
end
test "creates a buy order without portfolio transaction" do
user = create(:student)
user.portfolio.portfolio_transactions.create!(amount_cents: 5000, transaction_type: :deposit) # $50.00
stock = create(:stock, price_cents: 1_000)
order = build(:order, action: :buy, user: user, stock: stock, shares: 2.5)
assert_difference "PortfolioTransaction.count", 0 do
order.save!
end
assert_equal "buy", order.action
assert order.buy?
end
test "sell order validation allows selling when sufficient shares owned" do
user = create(:student)
portfolio = create(:portfolio, user: user)
stock = create(:stock, price_cents: 1_000)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 10, purchase_price: 200.0)
order = build(:order, action: :sell, user: user, stock: stock, shares: 5)
assert order.valid?
end
test "sell order validation prevents selling more shares than owned" do
user = create(:student)
portfolio = create(:portfolio, user: user)
stock = create(:stock, price_cents: 1_000)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 5, purchase_price: 200.0)
order = build(:order, action: :sell, user: user, stock: stock, shares: 10)
assert_not order.valid?
assert_includes order.errors[:base], "Cannot sell more shares than you own (5 available)"
end
test "sell order validation prevents selling when no shares owned" do
user = create(:student)
create(:portfolio, user: user)
stock = create(:stock, price_cents: 1_000)
order = build(:order, action: :sell, user: user, stock: stock, shares: 1)
assert_not order.valid?
assert_includes order.errors[:base], "Cannot sell more shares than you own (0 available)"
end
test "sell order validation with multiple portfolio_stock records" do
user = create(:student)
portfolio = create(:portfolio, user: user)
stock = create(:stock, price_cents: 1_000)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 10, purchase_price: 200.0)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 5, purchase_price: 250.0)
order = build(:order, action: :sell, user: user, stock: stock, shares: 12)
assert order.valid?
order = build(:order, action: :sell, user: user, stock: stock, shares: 20)
assert_not order.valid?
assert_includes order.errors[:base], "Cannot sell more shares than you own (15 available)"
end
test "buy order validation is not affected by sell validation" do
user = create(:student)
portfolio = create(:portfolio, user: user)
portfolio.portfolio_transactions.create!(amount_cents: 15_000, transaction_type: :deposit) # $150.00
stock = create(:stock, price_cents: 1_000)
order = build(:order, :buy, user: user, stock: stock, shares: 10)
assert order.valid?
end
test "creates a sell order without portfolio transaction" do
user = create(:student)
stock = create(:stock, price_cents: 1_000)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 5, purchase_price: 800)
order = build(:order, action: :sell, user: user, stock: stock, shares: 2.5)
assert_difference "PortfolioTransaction.count", 0 do
order.save!
end
assert_equal "sell", order.action
assert order.sell?
end
test "buy order validation prevents buying when insufficient funds" do
user = create(:student)
portfolio = create(:portfolio, user: user)
portfolio.portfolio_transactions.create!(amount_cents: 100, transaction_type: :deposit)
stock = create(:stock, price_cents: 1000)
order = build(:order, action: :buy, user: user, stock: stock, shares: 5)
assert_not order.valid?
assert_includes order.errors[:shares], "Insufficient funds. You have $1.00 but need $51.00"
end
test "buy order validation prevents buying if not enough funds to cover stocks and transaction fee" do
user = create(:student)
portfolio = create(:portfolio, user: user)
portfolio.portfolio_transactions.create!(amount_cents: 10_00, transaction_type: :deposit)
stock = create(:stock, price_cents: 10_00)
order = build(:order, action: :buy, user: user, stock: stock, shares: 1)
assert_not order.valid?(:create)
assert_includes order.errors[:shares], "Insufficient funds. You have $10.00 but need $11.00"
end
test "buy order validation allows buying if enough funds when multiple orders share transaction fees" do
user = create(:student)
portfolio = create(:portfolio, user: user)
portfolio.portfolio_transactions.create!(amount_cents: 21_00, transaction_type: :deposit)
stock = create(:stock, price_cents: 10_00)
pending_order = build(:order, :pending, action: :buy, user: user, stock: stock, shares: 1)
assert_nothing_raised do
pending_order.save!
end
order = build(:order, action: :buy, user: user, stock: stock, shares: 1)
assert order.valid?(:create)
end
test "buy order validation allows buying when sufficient funds" do
user = create(:student)
portfolio = create(:portfolio, user: user)
portfolio.portfolio_transactions.create!(amount_cents: 100_00, transaction_type: :deposit)
stock = create(:stock, price_cents: 10_00)
order = build(:order, action: :buy, user: user, stock: stock, shares: 5)
assert order.valid?(:create)
end
test "update order allows user to update pending buy order when transaction amount less than portfolio value" do
user = create(:student)
portfolio = create(:portfolio, user: user)
portfolio.portfolio_transactions.create!(amount_cents: 100_00, transaction_type: :deposit)
stock = create(:stock, price_cents: 10_00)
order = build(:order, action: :buy, user: user, stock: stock, shares: 5)
assert_difference "PortfolioTransaction.count", 0 do
order.save!
end
assert order.valid?
# NOTE: PortfolioTransaction updates are handled by ExecuteOrder service
order.update!(shares: 6)
assert_equal 6, order.shares
end
test "update order does not allow user to update pending buy order when transaction amount exceeds portfolio value" do
user = create(:student)
portfolio = create(:portfolio, user: user)
portfolio.portfolio_transactions.create!(amount_cents: 10_00, transaction_type: :deposit)
stock = create(:stock, price_cents: 1_00)
order = build(:order, :pending, action: :buy, user: user, stock: stock, shares: 5)
assert_no_changes "PortfolioTransaction.count" do
order.save!
end
order.shares = 10
assert_not order.valid?(:update)
assert_includes order.errors[:shares], "Insufficient funds. You have $10.00 but need $11.00"
end
test "update order allows user to update pending sell order when shares less than portfolio value" do
user = create(:student)
stock = create(:stock, price_cents: 1_000)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 5, purchase_price: 800)
order = build(:order, action: :sell, user: user, stock: stock, shares: 2.5)
assert_difference "PortfolioTransaction.count", 0 do
order.save!
end
order.update!(shares: 3)
assert_equal 3, order.shares
end
test "update order prevents overselling shares when updating sell order" do
user = create(:student)
stock = create(:stock, price_cents: 1_000)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 5, purchase_price: 1_000)
order = build(:order, action: :sell, user: user, stock: stock, shares: 2)
order.save!
order.shares = 6
assert_not order.valid?
assert_includes order.errors[:base], "Cannot sell more shares than you own (5 available)"
end
test "validations handle invalid shares safely without exceptions" do
user = create(:student)
stock = create(:stock)
user.portfolio.portfolio_transactions.create!(amount_cents: 10_000, transaction_type: :deposit)
[nil, "", "not_a_number"].each do |invalid_shares|
buy_order = build(:order, action: :buy, user: user, stock: stock, shares: invalid_shares)
sell_order = build(:order, action: :sell, user: user, stock: stock, shares: invalid_shares)
assert_nothing_raised do
assert_equal false, buy_order.valid?
assert_equal false, sell_order.valid?
end
assert buy_order.errors[:shares].any?
assert sell_order.errors[:shares].any?
end
end
test ".for_student" do
stock = create(:stock, price_cents: 1_000)
classroom = create(:classroom, :with_trading)
student1, order1 = create_student_and_order(stock, classroom)
_student2, _order2 = create_student_and_order(stock, classroom)
result = Order.for_student(student1)
assert_equal [order1], result
end
test ".for_teacher" do
stock = create(:stock, price_cents: 1_000)
classroom1, order1 = create_classroom_and_student_order(stock)
_classroom2, _order2 = create_classroom_and_student_order(stock)
classroom3, order3 = create_classroom_and_student_order(stock)
teacher = create(:teacher, classrooms: [classroom1, classroom3])
result = Order.for_teacher(teacher)
assert_equal [order1.id, order3.id], result.pluck(:id).sort
end
test ".for_teacher with no classrooms" do
teacher = create(:teacher)
stock = create(:stock, price_cents: 1_000)
_classroom, _order = create_classroom_and_student_order(stock)
result = Order.for_teacher(teacher)
assert_empty result
end
# Tests for sorting scopes
test "order_by_created_at sorts ascending" do
user = create(:student)
stock = create(:stock)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 10)
order1 = create(
:order, status: :completed, action: :sell, user: user, stock: stock, shares: 1,
created_at: 1.day.ago
)
order2 = create(
:order, status: :completed, action: :sell, user: user, stock: stock, shares: 1,
created_at: Time.current
)
result = Order.order_by_created_at(:asc)
assert_equal [order1.id, order2.id], result.pluck(:id)
end
test "order_by_created_at sorts descending" do
user = create(:student)
stock = create(:stock)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 10)
order1 = create(
:order, status: :completed, action: :sell, user: user, stock: stock, shares: 1,
created_at: 1.day.ago
)
order2 = create(
:order, status: :completed, action: :sell, user: user, stock: stock, shares: 1,
created_at: Time.current
)
result = Order.order_by_created_at(:desc)
assert_equal [order2.id, order1.id], result.pluck(:id)
end
test "order_by_stock sorts by stock ticker" do
user = create(:student)
stock1 = create(:stock, ticker: "ZZZ")
stock2 = create(:stock, ticker: "AAA")
create(:portfolio_stock, portfolio: user.portfolio, stock: stock1, shares: 10)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock2, shares: 10)
create(
:order, status: :completed, action: :sell, user: user, stock: stock1, shares: 1,
created_at: 1.day.ago
)
create(
:order, status: :completed, action: :sell, user: user, stock: stock2, shares: 1,
created_at: Time.current
)
result = Order.order_by_stock(:asc)
assert_equal "AAA", result.first.stock.ticker
end
test "apply_sorting defaults to created_at descending" do
user = create(:student)
stock = create(:stock)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 10)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock, shares: 5)
create(:order, status: :completed, action: :sell, user: user, stock: stock, created_at: 1.day.ago)
create(:order, status: :completed, action: :sell, user: user, stock: stock, created_at: Time.current)
result = Order.apply_sorting(nil, nil, nil)
assert_equal :desc, result.order_values.first.direction
end
test "apply_sorting with sort_column applies correct scope" do
user = create(:student)
stock1 = create(:stock, ticker: "ZZZ")
stock2 = create(:stock, ticker: "AAA")
create(:portfolio_stock, portfolio: user.portfolio, stock: stock1, shares: 10)
create(:portfolio_stock, portfolio: user.portfolio, stock: stock2, shares: 5)
create(:order, status: :completed, action: :sell, user: user, stock: stock1, created_at: 1.day.ago)
create(:order, status: :completed, action: :sell, user: user, stock: stock2, created_at: Time.current)
result = Order.apply_sorting(nil, "stock", :asc)
assert_equal "AAA", result.first.stock.ticker
end
private
def create_classroom_and_student_order(stock)
classroom = create(:classroom, :with_trading)
_student, order = create_student_and_order(stock, classroom)
[classroom, order]
end
def create_student_and_order(stock, classroom)
student = create(:student, classroom:)
portfolio = create(:portfolio, user: student)
create(:portfolio_transaction, :deposit, portfolio:, amount_cents: 10_000)
order = create(:order, :buy, user: student, stock:)
[student, order]
end
end

View File

@@ -0,0 +1,106 @@
# frozen_string_literal: true
require "test_helper"
class PortfolioPositionTest < ActiveSupport::TestCase
test "current_value and current_value_cents calculation" do
portfolio = create(:portfolio)
stock = create(:stock, price_cents: 12_000)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 8, purchase_price: 100.0)
position = PortfolioPosition.for_portfolio(portfolio).first
assert_equal 960.0, position.current_value
assert_equal 96_000, position.current_value_cents
end
test "for_portfolio aggregates buy/sell transactions correctly" do
portfolio = create(:portfolio)
stock = create(:stock, ticker: "AAPL")
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 10)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 5)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: -3)
positions = PortfolioPosition.for_portfolio(portfolio)
assert_equal 1, positions.length
position = positions.first
assert_equal 12, position.shares
assert_equal stock, position.stock
end
test "for_portfolio excludes stocks with zero net holdings" do
portfolio = create(:portfolio)
stock1 = create(:stock, ticker: "ZERO")
stock2 = create(:stock, ticker: "POSITIVE")
create(:portfolio_stock, portfolio: portfolio, stock: stock1, shares: 10)
create(:portfolio_stock, portfolio: portfolio, stock: stock1, shares: -10)
create(:portfolio_stock, portfolio: portfolio, stock: stock2, shares: 5)
positions = PortfolioPosition.for_portfolio(portfolio)
assert_equal 1, positions.length
assert_equal "POSITIVE", positions.first.stock_ticker
assert_equal 5, positions.first.shares
end
test "change_amount with multiple purchases and sells" do
portfolio = create(:portfolio)
stock = create(:stock, price_cents: 15_000)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 10, purchase_price: 100.0)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 5, purchase_price: 120.0)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: -3, purchase_price: 130.0)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 8, purchase_price: 140.0)
positions = PortfolioPosition.for_portfolio(portfolio)
position = positions.first
cost_basis = (100.0 * 10) + (120.0 * 5) + (-3 * 130.0) + (140.0 * 8)
expected_change = (150.0 * 20) - cost_basis
assert_equal expected_change, position.change_amount
end
test "change_amount calculates profit correctly" do
portfolio = create(:portfolio)
stock = create(:stock, price_cents: 700)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 4, purchase_price: 5.0)
position = PortfolioPosition.for_portfolio(portfolio).first
assert_equal 4, position.shares
assert_in_delta 8.0, position.change_amount, 0.001
end
test "change_amount calculates loss correctly" do
portfolio = create(:portfolio)
stock = create(:stock, price_cents: 100)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 2, purchase_price: 5.0)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 3, purchase_price: 7.0)
position = PortfolioPosition.for_portfolio(portfolio).first
assert_equal 5, position.shares
assert_in_delta(-26.0, position.change_amount, 0.001)
end
test "total_return_amount with multiple purchases and sells" do
portfolio = create(:portfolio)
stock = create(:stock, price_cents: 15_000)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 10, purchase_price: 100.0)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 5, purchase_price: 120.0)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: -3, purchase_price: 130.0)
positions = PortfolioPosition.for_portfolio(portfolio)
position = positions.first
expected_value = 150.0 * 12
assert_equal expected_value, position.total_return_amount
end
end

View File

@@ -0,0 +1,14 @@
# frozen_string_literal: true
require "test_helper"
class PortfolioSnapshotTest < ActiveSupport::TestCase
test "factory" do
assert build(:portfolio_snapshot).validate!
end
test "#current_worth converts cents to dollars" do
portfolio_snapshot = build(:portfolio_snapshot, worth_cents: 50_000)
assert_equal 500.0, portfolio_snapshot.current_worth
end
end

View File

@@ -0,0 +1,9 @@
# frozen_string_literal: true
require "test_helper"
class PortfolioStockTest < ActiveSupport::TestCase
test "factory" do
assert build(:portfolio_stock).validate!
end
end

View File

@@ -0,0 +1,208 @@
# frozen_string_literal: true
require "test_helper"
class PortfolioTest < ActiveSupport::TestCase
test "factory" do
assert build(:portfolio).validate!
end
test "#cash_balance" do
portfolio = create(:portfolio)
user = portfolio.user
# should increase cash_balance
create(:portfolio_transaction, :deposit, portfolio: portfolio, amount_cents: 10_00)
stock = create(:stock, price_cents: 100)
# This simulates previous completed purchases
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 20, purchase_price: 100)
# pending debit for stock purchase, should decrease cash_balance
# -$2.00 - $1.00 fee = -$3.00
create(:order, :pending, :buy, stock: stock, shares: 2, user: user)
# pending credit for stock sale, should NOT affect cash_balance
create(:order, :pending, :sell, stock:, shares: 3, user:)
# canceled debit for stock purchase, should NOT affect cash_balance
create(:order, :canceled, :buy, stock:, shares: 1, user:)
# canceled credit for stock sale, should NOT affect cash_balance
create(:order, :canceled, :sell, stock:, shares: 4, user:)
# successful completed stock purchase, should decrease cash_balance
# -$5.00
buy_order = create(:order, :completed, :buy, stock: stock, shares: 5, user: user)
create(
:portfolio_transaction, :debit, portfolio: portfolio, amount_cents: 5_00,
order: buy_order
)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 5, purchase_price: 100)
# successful completed stock sale, should increase cash_balance
# # +$6.00
sell_order = create(:order, :completed, :sell, stock: stock, shares: 6, user: user)
create(
:portfolio_transaction, :credit, portfolio: portfolio, amount_cents: 6_00,
order: sell_order
)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: -6, purchase_price: 100)
# withdrawal from the account, should decrease cash_balance
create(:portfolio_transaction, :withdrawal, portfolio:, amount_cents: 2_00) # -$2.00
# fee, should decrease cash_balance
# -$1.00
create(:portfolio_transaction, :fee, portfolio:, amount_cents: 1_00)
# expected balance = (10.00 - 3.00 - 5.00 + 6.00 - 2.00 - 1.00) = 5.00
result = portfolio.cash_balance
assert_equal 5.0, result
end
test "#shares_owned with single purchase" do
portfolio = create(:portfolio)
stock = create(:stock)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 15, purchase_price: 200.0)
result = portfolio.shares_owned(stock.id)
assert_equal 15, result
end
test "#shares_owned with multiple purchases aggregates correctly" do
portfolio = create(:portfolio)
stock = create(:stock)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 10, purchase_price: 200.0)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 5, purchase_price: 250.0)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 3, purchase_price: 300.0)
result = portfolio.shares_owned(stock.id)
assert_equal 18, result # 10 + 5 + 3
end
test "#cash_balance with transactions without orders" do
portfolio = create(:portfolio)
create(:portfolio_transaction, :credit, portfolio: portfolio, amount_cents: 10_00)
create(:portfolio_transaction, :debit, portfolio: portfolio, amount_cents: 5_00)
create(:portfolio_transaction, :deposit, portfolio: portfolio, amount_cents: 20_00)
# expected balance = (2000 + 1000 - 500) / 100.0 = 25.0
result = portfolio.cash_balance
assert_equal 25.0, result
end
test "#shares_owned with buy and sell orders" do
portfolio = create(:portfolio)
stock = create(:stock)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 20, purchase_price: 200.0)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: -7, purchase_price: 250.0)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 3, purchase_price: 300.0)
result = portfolio.shares_owned(stock.id)
assert_equal 16, result # 20 - 7 + 3
end
test "#shares_owned returns 0 when no shares owned" do
portfolio = create(:portfolio)
stock = create(:stock)
result = portfolio.shares_owned(stock.id)
assert_equal 0, result
end
test "#shares_owned with different stocks" do
portfolio = create(:portfolio)
stock1 = create(:stock)
stock2 = create(:stock)
create(:portfolio_stock, portfolio: portfolio, stock: stock1, shares: 10, purchase_price: 200.0)
create(:portfolio_stock, portfolio: portfolio, stock: stock2, shares: 5, purchase_price: 300.0)
assert_equal 10, portfolio.shares_owned(stock1.id)
assert_equal 5, portfolio.shares_owned(stock2.id)
end
test "#holdings_value_cents calculates stock holdings value" do
portfolio = create(:portfolio)
stock1 = create(:stock, price_cents: 10_000)
stock2 = create(:stock, price_cents: 20_000)
create(:portfolio_stock, portfolio: portfolio, stock: stock1, shares: 5)
create(:portfolio_stock, portfolio: portfolio, stock: stock2, shares: 3)
expected_value = (5 * 10_000) + (3 * 20_000)
assert_equal expected_value, portfolio.holdings_value_cents
end
test "#calculate_total_value_cents includes cash and stock holdings" do
portfolio = create(:portfolio)
create(:portfolio_transaction, :deposit, portfolio: portfolio, amount_cents: 50_000)
stock = create(:stock, price_cents: 10_000)
create(:portfolio_stock, portfolio: portfolio, stock: stock, shares: 10)
expected_value = 50_000 + (10 * 10_000)
assert_equal expected_value, portfolio.calculate_total_value_cents
end
test "#calculate_total_value converts cents to dollars" do
portfolio = create(:portfolio)
create(:portfolio_transaction, :deposit, portfolio: portfolio, amount_cents: 50_000)
assert_equal 500.0, portfolio.calculate_total_value
end
test "#chart_data returns empty array when no snapshots exist" do
portfolio = create(:portfolio)
result = portfolio.chart_data
assert_equal [], result
end
test "#chart_data returns formatted data for single snapshot" do
portfolio = create(:portfolio)
create(:portfolio_snapshot, portfolio: portfolio, date: Date.new(2025, 1, 1), worth_cents: 10_000)
result = portfolio.chart_data
assert_equal 1, result.length
assert_equal "Jan 2025", result.first[:label]
assert_equal 100.0, result.first[:value]
end
test "#chart_data returns last 12 snapshots in chronological order" do
portfolio = create(:portfolio)
15.times do |i|
create(
:portfolio_snapshot,
portfolio: portfolio,
date: (14 - i).months.ago.beginning_of_month.to_date,
worth_cents: (100 + (i * 10)) * 100
)
end
result = portfolio.chart_data
assert_equal 12, result.length
end
test "#chart_data formats dates as 'Mon YYYY'" do
portfolio = create(:portfolio)
create(:portfolio_snapshot, portfolio: portfolio, date: Date.new(2025, 3, 15), worth_cents: 15_000)
create(:portfolio_snapshot, portfolio: portfolio, date: Date.new(2024, 12, 1), worth_cents: 20_000)
result = portfolio.chart_data
assert_equal(["Dec 2024", "Mar 2025"], result.pluck(:label))
end
test "#chart_data converts worth_cents to dollars" do
portfolio = create(:portfolio)
create(:portfolio_snapshot, portfolio: portfolio, date: Date.current, worth_cents: 123_456)
result = portfolio.chart_data
assert_equal 1234.56, result.first[:value]
end
end

View File

@@ -0,0 +1,45 @@
# frozen_string_literal: true
require "test_helper"
class PortfolioTransactionTest < ActiveSupport::TestCase
test "factory" do
assert build(:portfolio_transaction).validate!
end
test ".deposits" do
transaction1 = create(:portfolio_transaction, :deposit)
create(:portfolio_transaction, :debit)
create(:portfolio_transaction, :credit)
transaction4 = create(:portfolio_transaction, :deposit)
assert_equal [transaction1, transaction4], PortfolioTransaction.deposits
end
test ".debits" do
transaction1 = create(:portfolio_transaction, :debit)
create(:portfolio_transaction, :deposit)
create(:portfolio_transaction, :credit)
transaction4 = create(:portfolio_transaction, :debit)
assert_equal [transaction1, transaction4], PortfolioTransaction.debits
end
test ".credits" do
transaction1 = create(:portfolio_transaction, :credit)
create(:portfolio_transaction, :deposit)
create(:portfolio_transaction, :debit)
transaction4 = create(:portfolio_transaction, :credit)
assert_equal [transaction1, transaction4], PortfolioTransaction.credits
end
test ".withdrawals" do
transaction1 = create(:portfolio_transaction, :withdrawal)
create(:portfolio_transaction, :deposit)
create(:portfolio_transaction, :debit)
transaction4 = create(:portfolio_transaction, :withdrawal)
assert_equal [transaction1, transaction4], PortfolioTransaction.withdrawals
end
end

View File

@@ -0,0 +1,94 @@
# frozen_string_literal: true
# test/models/quarter_test.rb
require "test_helper"
class QuarterTest < ActiveSupport::TestCase
test "can be destroyed when no grade books exist" do
school_year = create(:school_year)
quarter = school_year.quarters.first
assert quarter.destroy
end
test "cannot be destroyed when grade books exist" do
school_year = create(:school_year)
quarter = school_year.quarters.first
classroom = create(:classroom, school_year: school_year)
grade_book = classroom.grade_books.find_by!(quarter: quarter)
assert_not quarter.destroy
assert quarter.errors[:base].any?
assert GradeBook.exists?(grade_book.id)
end
test "factory is valid and assigns sequential numbers" do
school_year = build(:school_year)
q1 = build_stubbed(:quarter, school_year: school_year, number: 1)
q2 = build_stubbed(:quarter, school_year: school_year, number: 2)
assert q1.valid?
assert q2.valid?
assert_equal 1, q1.number
assert_equal 2, q2.number
end
test "validates presence of number" do
quarter = build_stubbed(:quarter, number: nil)
assert_not quarter.valid?
assert_includes quarter.errors[:number], "can't be blank"
end
test "validates inclusion of number in 1..4" do
%w[0 5 10].each do |bad|
quarter = build_stubbed(:quarter, number: bad.to_i)
assert_not quarter.valid?, "#{bad} should be invalid"
assert_includes quarter.errors[:number], "is not included in the list"
end
end
test "validates uniqueness of number per school_year" do
school_year = create(:school_year)
dup = build(:quarter, school_year: school_year, number: 1)
assert_not dup.valid?
assert_includes dup.errors[:number], "has already been taken"
end
test "default scope orders by number" do
school_year = create(:school_year)
assert_equal [1, 2, 3, 4], school_year.quarters.ordered.pluck(:number)
end
test "next and previous helpers" do
school_year = create(:school_year)
q1 = school_year.quarters.find_by!(number: 1)
q2 = school_year.quarters.find_by!(number: 2)
q4 = school_year.quarters.find_by!(number: 4)
assert_nil q1.previous
assert_equal q2, q1.next
assert_equal q1, q2.previous
assert_nil q4.next
end
test "next and previous across school years" do
school = create(:school)
year1 = create(:year, name: "2023 - 2024")
year2 = create(:year, name: "2024 - 2025")
sy1 = SchoolYear.create!(school: school, year: year1)
sy2 = SchoolYear.create!(school: school, year: year2)
q4_y1 = sy1.quarters.find_by!(number: 4)
q1_y2 = sy2.quarters.find_by!(number: 1)
assert_equal q1_y2, q4_y1.next
assert_equal q4_y1, q1_y2.previous
# Edge cases where there is no next/previous year
year4 = create(:year, name: "2026 - 2027")
sy4 = SchoolYear.create!(school: school, year: year4)
q1_y4 = sy4.quarters.find_by!(number: 1)
q4_y4 = sy4.quarters.find_by!(number: 4)
assert_nil q1_y4.previous
assert_nil q4_y4.next
end
end

View File

@@ -0,0 +1,129 @@
# frozen_string_literal: true
require "test_helper"
class SchoolTest < ActiveSupport::TestCase
# Factory validation
test "factory is valid" do
school = build(:school)
assert school.valid?
end
# Validation tests
test "is valid with valid attributes" do
school = build(:school, name: "Test School")
assert school.valid?
end
test "is invalid without a name" do
school = build(:school, name: nil)
assert_not school.valid?
assert_includes school.errors[:name], "can't be blank"
end
test "is invalid with an empty name" do
school = build(:school, name: "")
assert_not school.valid?
assert_includes school.errors[:name], "can't be blank"
end
test "is invalid with a name containing only whitespace" do
school = build(:school, name: " ")
assert_not school.valid?
assert_includes school.errors[:name], "can't be blank"
end
# Association tests
test "has many school_years" do
school = create(:school)
assert_respond_to school, :school_years
end
test "has many years through school_years" do
school = create(:school)
assert_respond_to school, :years
end
test "can have multiple school_years" do
school = create(:school)
year1 = create(:year, name: "2024")
year2 = create(:year, name: "2025")
school_year1 = create(:school_year, school: school, year: year1)
school_year2 = create(:school_year, school: school, year: year2)
assert_equal 2, school.school_years.count
assert_includes school.school_years, school_year1
assert_includes school.school_years, school_year2
end
test "can access years through school_years association" do
school = create(:school)
year1 = create(:year, name: "2024")
year2 = create(:year, name: "2025")
create(:school_year, school: school, year: year1)
create(:school_year, school: school, year: year2)
assert_equal 2, school.years.count
assert_includes school.years, year1
assert_includes school.years, year2
end
# Dependent restrict_with_error tests
test "cannot be destroyed when it has associated school_years" do
school = create(:school)
year = create(:year)
create(:school_year, school: school, year: year)
assert_not school.destroy
assert_includes school.errors[:base], "Cannot delete record because dependent school years exist"
end
test "can be destroyed when it has no associated school_years" do
school = create(:school)
assert_difference("School.count", -1) do
school.destroy!
end
end
# Edge cases
test "allows duplicate names" do
create(:school, name: "Test School")
duplicate_school = build(:school, name: "Test School")
assert duplicate_school.valid?
end
test "name can contain special characters" do
school = build(:school, name: "St. Mary's School & Academy")
assert school.valid?
end
test "name can contain numbers" do
school = build(:school, name: "PS 123")
assert school.valid?
end
test "name can be very long" do
long_name = "A" * 255
school = build(:school, name: long_name)
assert school.valid?
end
# Persistence tests
test "persists name correctly" do
school = create(:school, name: "Persisted School")
reloaded_school = School.find(school.id)
assert_equal "Persisted School", reloaded_school.name
end
test "updates name correctly" do
school = create(:school, name: "Original Name")
school.update!(name: "Updated Name")
assert_equal "Updated Name", school.reload.name
end
end

View File

@@ -0,0 +1,50 @@
# frozen_string_literal: true
require "test_helper"
class SchoolYearTest < ActiveSupport::TestCase
test "factory" do
assert build(:school_year).validate!
end
test "name returns school name and year name" do
school_year = build(:school_year)
assert_equal "#{school_year.school_name} (#{school_year.year_name})", school_year.name
end
test "can be destroyed and cascades quarters" do
school_year = create(:school_year)
quarter_ids = school_year.quarters.pluck(:id)
assert_equal 4, quarter_ids.size
assert school_year.destroy
assert Quarter.where(id: quarter_ids).empty?
end
test "cannot be destroyed when classrooms exist" do
school_year = create(:school_year)
create(:classroom, school_year: school_year)
assert_not school_year.destroy
assert school_year.errors.added?(:base, "Cannot delete record because dependent classrooms exist")
end
test "cannot be destroyed when grade books exist" do
school_year = create(:school_year)
create(:classroom, school_year: school_year)
assert_not school_year.destroy
end
test "automatically creates 4 quarters when created" do
school = create(:school)
year = create(:year)
school_year = SchoolYear.create!(school: school, year: year)
assert_equal 4, school_year.quarters.count
assert_equal [1, 2, 3, 4], school_year.quarters.order(:number).pluck(:number)
assert_equal ["Quarter 1", "Quarter 2", "Quarter 3", "Quarter 4"],
school_year.quarters.order(:number).pluck(:name)
end
end

View File

@@ -0,0 +1,29 @@
# frozen_string_literal: true
require "test_helper"
class StiTest < ActiveSupport::TestCase
def test_sti_implementation
classroom = create(:classroom)
admin = create(:admin, classroom: classroom)
assert_equal "User", admin.type
student = create(:student, classroom: classroom)
assert_equal "Student", student.type
teacher = create(:teacher, classroom: classroom)
assert_equal "Teacher", teacher.type
admin.update(type: "Student")
assert_equal "Student", admin.type
assert_equal 3, User.count
assert_equal 2, Student.count
assert_equal 1, Teacher.count
assert_equal "Student", admin.reload.type
assert_equal "Student", student.reload.type
assert_equal "Teacher", teacher.reload.type
end
end

View File

@@ -0,0 +1,87 @@
# frozen_string_literal: true
require "test_helper"
class StockTest < ActiveSupport::TestCase
test "factory" do
assert build(:stock).validate!
end
test "#current_price" do
stock = create(:stock, price_cents: 1_000)
result = stock.current_price
assert_equal 10.0, result
end
test "archived defaults to false" do
stock = create(:stock)
assert_equal false, stock.archived
end
test "active and archived scopes filter correctly" do
active_stock = create(:stock, archived: false)
archived_stock = create(:stock, archived: true)
active_stocks = Stock.active
archived_stocks = Stock.archived
assert_includes active_stocks, active_stock
assert_not_includes active_stocks, archived_stock
assert_includes archived_stocks, archived_stock
assert_not_includes archived_stocks, active_stock
end
test "validates company_website format accepts valid http URLs" do
stock = build(:stock, company_website: "http://example.com")
assert stock.valid?
end
test "validates company_website format accepts valid https URLs" do
stock = build(:stock, company_website: "https://example.com")
assert stock.valid?
end
test "validates company_website format accepts blank URLs" do
stock = build(:stock, company_website: "")
assert stock.valid?
end
test "validates company_website format rejects javascript URLs" do
stock = build(:stock, company_website: "javascript:alert('XSS')")
assert_not stock.valid?
assert stock.errors.added?(
:company_website,
"must be a valid HTTP or HTTPS URL"
)
end
test "validates company_website format rejects invalid URLs" do
stock = build(:stock, company_website: "not a url")
assert_not stock.valid?
assert stock.errors.added?(
:company_website,
"must be a valid HTTP or HTTPS URL"
)
end
test "validates company_website format rejects data URLs" do
stock = build(
:stock,
company_website: "data:text/html,<script>alert('XSS')</script>"
)
assert_not stock.valid?
assert stock.errors.added?(
:company_website,
"must be a valid HTTP or HTTPS URL"
)
end
end

View File

@@ -0,0 +1,294 @@
# frozen_string_literal: true
require "test_helper"
class StudentTest < ActiveSupport::TestCase
test "inherits from User" do
student = create(:student)
assert_kind_of User, student
assert_equal "Student", student.type
end
test "belongs to classroom" do
classroom = create(:classroom)
student = create(:student, classroom: classroom)
assert_equal classroom, student.classroom
end
test "has portfolio association" do
student = create(:student)
portfolio = create(:portfolio, user: student)
assert_equal portfolio, student.portfolio
end
test "email can be blank for students" do
student = build(:student, email: "")
assert student.valid?
end
test "password generation works" do
student = create(:student)
assert_not_nil student.encrypted_password
end
test "destroy raises and does not delete the row" do
student = create(:student)
assert_raises(RuntimeError) { student.destroy }
assert student.reload.persisted?
assert_not student.discarded?
end
test "has many classroom_enrollments" do
student = create(:student)
assert student.respond_to?(:classroom_enrollments)
end
test "has many classrooms through classroom_enrollments" do
student = create(:student)
assert student.respond_to?(:classrooms)
end
test "current_enrollments returns only enrollments with nil unenrolled_at" do
student = create(:student, :without_enrollment)
classroom1 = create(:classroom)
classroom2 = create(:classroom)
current = create(
:classroom_enrollment,
student: student,
classroom: classroom1,
unenrolled_at: nil
)
historical = create(
:classroom_enrollment,
student: student,
classroom: classroom2,
enrolled_at: 2.days.ago,
unenrolled_at: 1.day.ago
)
assert_includes student.current_enrollments, current
assert_not_includes student.current_enrollments, historical
end
test "current_classrooms returns classrooms with active enrollments" do
student = create(:student, :without_enrollment)
classroom1 = create(:classroom)
classroom2 = create(:classroom)
classroom3 = create(:classroom)
create(
:classroom_enrollment,
student: student,
classroom: classroom1,
unenrolled_at: nil
)
create(
:classroom_enrollment,
student: student,
classroom: classroom2,
unenrolled_at: nil
)
create(
:classroom_enrollment,
student: student,
classroom: classroom3,
enrolled_at: 2.days.ago,
unenrolled_at: 1.day.ago
)
assert_includes student.current_classrooms, classroom1
assert_includes student.current_classrooms, classroom2
assert_not_includes student.current_classrooms, classroom3
end
test "primary_enrollment returns the primary enrollment" do
student = create(:student, :without_enrollment)
classroom1 = create(:classroom)
classroom2 = create(:classroom)
primary = create(
:classroom_enrollment,
student: student,
classroom: classroom1,
primary: true
)
create(
:classroom_enrollment,
student: student,
classroom: classroom2,
primary: false
)
assert_equal primary, student.primary_enrollment
end
test "primary_classroom returns primary enrollment classroom" do
student = create(:student, :without_enrollment)
classroom = create(:classroom)
create(
:classroom_enrollment,
student: student,
classroom: classroom,
primary: true
)
assert_equal classroom, student.primary_classroom
end
test "primary_classroom falls back to classroom_id when no primary enrollment" do
classroom = create(:classroom)
student = create(:student, classroom: classroom)
assert_equal classroom, student.primary_classroom
end
test "enroll_in! creates a new enrollment" do
student = create(:student)
classroom = create(:classroom)
assert_difference -> { student.classroom_enrollments.count }, 1 do
student.enroll_in!(classroom)
end
end
test "enroll_in! sets enrolled_at to current time by default" do
student = create(:student)
classroom = create(:classroom)
freeze_time do
enrollment = student.enroll_in!(classroom)
assert_in_delta Time.current, enrollment.enrolled_at, 1.second
end
end
test "enroll_in! accepts custom enrolled_at time" do
student = create(:student)
classroom = create(:classroom)
custom_time = 1.week.ago
enrollment = student.enroll_in!(classroom, enrolled_at: custom_time)
assert_in_delta custom_time, enrollment.enrolled_at, 1.second
end
test "enroll_in! sets primary flag when requested" do
student = create(:student, :without_enrollment)
classroom = create(:classroom)
enrollment = student.enroll_in!(classroom, primary: true)
assert enrollment.primary
end
test "enroll_in! demotes other primary enrollments when creating new primary" do
student = create(:student, :without_enrollment)
classroom1 = create(:classroom)
classroom2 = create(:classroom)
old_primary = create(
:classroom_enrollment,
student: student,
classroom: classroom1,
primary: true
)
student.enroll_in!(classroom2, primary: true)
assert_not old_primary.reload.primary
end
test "enroll_in! returns the enrollment" do
student = create(:student)
classroom = create(:classroom)
enrollment = student.enroll_in!(classroom)
assert_kind_of ClassroomEnrollment, enrollment
assert enrollment.persisted?
end
test "unenroll_from! sets unenrolled_at on enrollment" do
student = create(:student)
classroom = create(:classroom)
enrollment = create(
:classroom_enrollment,
student: student,
classroom: classroom
)
student.unenroll_from!(classroom)
assert_not_nil enrollment.reload.unenrolled_at
end
test "unenroll_from! sets unenrolled_at to current time by default" do
student = create(:student, :without_enrollment)
classroom = create(:classroom)
enrollment = create(
:classroom_enrollment,
student: student,
classroom: classroom,
enrolled_at: 1.hour.ago
)
freeze_time do
student.unenroll_from!(classroom)
assert_in_delta Time.current, enrollment.reload.unenrolled_at, 1.second
end
end
test "unenroll_from! accepts custom unenrollment time" do
student = create(:student, :without_enrollment)
classroom = create(:classroom)
enrollment = create(
:classroom_enrollment,
student: student,
classroom: classroom,
enrolled_at: 2.weeks.ago
)
custom_time = 1.week.ago
student.unenroll_from!(classroom, unenrolled_at: custom_time)
assert_in_delta custom_time, enrollment.reload.unenrolled_at, 1.second
end
test "unenroll_from! handles multiple enrollments for same classroom" do
student = create(:student)
classroom = create(:classroom)
enrollment1 = create(
:classroom_enrollment,
student: student,
classroom: classroom,
enrolled_at: 1.month.ago
)
enrollment2 = create(
:classroom_enrollment,
student: student,
classroom: classroom,
enrolled_at: 2.weeks.ago
)
student.unenroll_from!(classroom)
assert_not_nil enrollment1.reload.unenrolled_at
assert_not_nil enrollment2.reload.unenrolled_at
end
test "automatically creates enrollment when student is created with classroom_id" do
classroom = create(:classroom)
student = create(:student, classroom: classroom)
assert_equal 1, student.classroom_enrollments.count
assert_equal classroom, student.classroom_enrollments.first.classroom
assert student.classroom_enrollments.first.primary
end
test "does not create enrollment when student is created without classroom_id" do
student = Student.create!(username: "test_#{rand(10_000)}", password: "Test1234")
assert_equal 0, student.classroom_enrollments.count
end
end

View File

@@ -0,0 +1,59 @@
# frozen_string_literal: true
require "test_helper"
class TeacherClassroomTest < ActiveSupport::TestCase
test "valid teacher_classroom" do
teacher = create(:teacher)
classroom = create(:classroom)
teacher_classroom = TeacherClassroom.new(teacher: teacher, classroom: classroom)
assert teacher_classroom.valid?
end
test "requires teacher" do
classroom = create(:classroom)
teacher_classroom = TeacherClassroom.new(classroom: classroom)
assert_not teacher_classroom.valid?
assert_includes teacher_classroom.errors[:teacher], "must exist"
end
test "requires classroom" do
teacher = create(:teacher)
teacher_classroom = TeacherClassroom.new(teacher: teacher)
assert_not teacher_classroom.valid?
assert_includes teacher_classroom.errors[:classroom], "must exist"
end
test "validates uniqueness of teacher_id scoped to classroom_id" do
teacher = create(:teacher)
classroom = create(:classroom)
TeacherClassroom.create!(teacher: teacher, classroom: classroom)
duplicate = TeacherClassroom.new(teacher: teacher, classroom: classroom)
assert_not duplicate.valid?
assert_includes duplicate.errors[:teacher_id], "has already been taken"
end
test "allows same teacher in different classrooms" do
teacher = create(:teacher)
classroom1 = create(:classroom)
classroom2 = create(:classroom)
assert TeacherClassroom.create(teacher: teacher, classroom: classroom1)
assert TeacherClassroom.create(teacher: teacher, classroom: classroom2)
end
test "allows different teachers in same classroom" do
teacher1 = create(:teacher)
teacher2 = create(:teacher)
classroom = create(:classroom)
assert TeacherClassroom.create(teacher: teacher1, classroom: classroom)
assert TeacherClassroom.create(teacher: teacher2, classroom: classroom)
end
end

Some files were not shown because too many files have changed in this diff Show More