diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7d9540e..6a5b199 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -128,6 +128,13 @@ jobs: - name: Set up Bun uses: oven-sh/setup-bun@v2 + - name: Set up Chrome + id: setup-chrome + uses: browser-actions/setup-chrome@v2 + with: + chrome-version: 142 + install-chromedriver: true + - name: Install JavaScript dependencies run: bun install --frozen-lockfile @@ -143,10 +150,13 @@ jobs: PGHOST: localhost PGUSER: postgres PGPASSWORD: postgres + CHROME_BIN: ${{ steps.setup-chrome.outputs.chrome-path }} + CHROMEDRIVER_BIN: ${{ steps.setup-chrome.outputs.chromedriver-path }} run: | bin/rails db:create RAILS_ENV=test bin/rails db:schema:load RAILS_ENV=test bin/rails test + bin/rails test:system - name: Ensure Swagger docs are up to date env: diff --git a/AGENTS.md b/AGENTS.md index 9d0d2fe..e337c8b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,8 @@ -# AGENT.md - Rails Hackatime/Harbor Project +# AGENTS.md for Hackatime -We do development using docker-compose. Run `docker compose ps` to see if the dev server is running. If it is, then you can restart the dev server with `touch tmp/restart.txt`. If not, bring the containers up first with `docker compose up -d`. +_You MUST read the [development guide](DEVELOPMENT.md) before starting. If you cannot read it, please ask for help._ + +We do development using docker-compose. Run `docker compose ps` to see if the dev server is running. If it is, then you can restart the dev server with `touch tmp/restart.txt` (but do not do this unless you added/removed a gem). If not, bring the containers up first with `docker compose up -d`. **IMPORTANT**: Always use `docker compose exec` (not `run`) to execute commands in the existing container. `run` creates a new container each time; `exec` reuses the running one. @@ -18,14 +20,9 @@ We do development using docker-compose. Run `docker compose ps` to see if the de ## CI/Testing Requirements -**Before marking any task complete, run ALL CI checks locally:** +Before marking any task complete, you MUST check `config/ci.rb` and manually run the checks in that file which are relevant to your changes (with `docker compose exec`.) -1. `docker compose exec web bundle exec rubocop` (lint check) -2. `docker compose exec web bundle exec brakeman` (security scan) -3. `docker compose exec web bin/importmap audit` (JS security) -4. `docker compose exec web bin/rails zeitwerk:check` (autoloader) -5. `docker compose exec web rails test` (full test suite) -6. `docker compose exec web bin/rails rswag:specs:swaggerize` (ensure docs are up to date) +Skip running checks which aren't relevant to your changes. However, at the very end of feature development, recommend the user to run all checks. If they say yes, run `docker compose exec web bin/ci` to run them all. ## API Documentation diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..2c766e1 --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,97 @@ +# Development + +Hello and welcome to the Hackatime codebase! This is a brief guide to help you get started with contributing to the project. + +## Quickstart + +You'll need Docker installed on your machine. Follow the instructions [here](https://docs.docker.com/get-docker/) to install Docker. If you're on a Mac, you can use [OrbStack](https://orbstack.dev/) to run Docker natively. + +Clone down the repository: + +```sh +# Set it up... +$ git clone https://github.com/hackclub/hackatime && cd hackatime + +# Set your config +$ cp .env.example .env +``` + +Edit your `.env` file to include the following: + +```env +# Database configurations - these work with the Docker setup +DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_development +WAKATIME_DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_development +SAILORS_LOG_DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_development + +# Generate these with `rails secret` or use these for development +SECRET_KEY_BASE=alallalalallalalallalalalladlalllalal +ENCRYPTION_PRIMARY_KEY=32characterrandomstring12345678901 +ENCRYPTION_DETERMINISTIC_KEY=32characterrandomstring12345678902 +ENCRYPTION_KEY_DERIVATION_SALT=16charssalt1234 +``` + +Start the containers: + +```sh +$ docker compose up -d +$ docker compose exec web /bin/bash +``` + +We'll now setup the database. In your container shell, run the following: + +```bash +app# bin/rails db:create db:schema:load db:seed +``` + +Now, let's start up the app! + +```bash +app# bin/dev +``` + +Want to do other things? + +```bash +app# bin/rails c # start an interactive irb! +app# bin/rails db:migrate # migrate the database +app# bin/rails rswag:specs:swaggerize # generate API documentation +``` + +You can now access the app at , using the `test@example.com` email. + +## Tests + +When making a change, **add tests** to ensure that the change does not break existing functionality, as well as to ensure that the change works as expected. Additionally, run the tests to verify that the change has not introduced any new bugs: + +```bash +bin/rails test +``` + +Please don't use mocks or stubs in your tests unless absolutely necessary. More often than not, these tests would end up testing _the mocks themselves_, rather than the actual code being tested. + +Prefer using Capybara (browser) tests whenever possible, as this helps test both the frontend and backend of the application. You should also that your tests cover all possible edge cases and scenarios! + +## Running CI locally + +To run all CI checks locally, you can run: + +```bash +docker compose exec web bin/ci +``` + +_Make sure these actually pass before making a PR!_ + +## Migrations + +These can be used to modify the database schema. Don't modify `db/schema.rb` directly. + +You also shouldn't create a migration file by hand. Instead, use the `bin/rails generate migration` command to generate a migration file. + +**Ensure migrations do not lock the database!** This is super, super important. + +## Jobs + +Don't create a job file by hand. Instead, use the `bin/rails generate job` command to generate a job file. + +Ensure jobs do not lock the database. diff --git a/Dockerfile.dev b/Dockerfile.dev index 44f40ac..9fca8b3 100644 --- a/Dockerfile.dev +++ b/Dockerfile.dev @@ -14,6 +14,8 @@ RUN apt-get update -qq && \ vim \ nodejs \ npm \ + chromium \ + chromium-driver \ && rm -rf /var/lib/apt/lists/* /var/cache/apt/archives # Install Bun diff --git a/README.md b/README.md index 7da0f39..82300b7 100644 --- a/README.md +++ b/README.md @@ -13,55 +13,4 @@ ## Local development -```sh -# Set it up... -$ git clone https://github.com/hackclub/hackatime && cd hackatime - -# Set your config -$ cp .env.example .env -``` - -Edit your `.env` file to include the following: - -```env -# Database configurations - these work with the Docker setup -DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_development -WAKATIME_DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_development -SAILORS_LOG_DATABASE_URL=postgres://postgres:secureorpheus123@db:5432/app_development - -# Generate these with `rails secret` or use these for development -SECRET_KEY_BASE=alallalalallalalallalalalladlalllalal -ENCRYPTION_PRIMARY_KEY=32characterrandomstring12345678901 -ENCRYPTION_DETERMINISTIC_KEY=32characterrandomstring12345678902 -ENCRYPTION_KEY_DERIVATION_SALT=16charssalt1234 -``` - -## Build & Run the project - -```sh -$ docker compose up -d -$ docker compose exec web /bin/bash - -# Now, setup the database using: -app# bin/rails db:create db:schema:load db:seed - -# Now start up the app: -app# bin/dev -# This hosts the server on your computer w/ default port 3000 - -# Want to do other things? -app# bin/rails c # start an interactive irb! -app# bin/rails db:migrate # migrate the database -app# bin/rails rswag:specs:swaggerize # generate API documentation -``` - -You can now access the app at - -Use email authentication from the homepage with `test@example.com` or create a new user (you can view outgoing emails at [http://localhost:3000/letter_opener](http://localhost:3000/letter_opener))! - -Ever need to setup a new database? - -```sh -# inside the docker container, reset the db -app# $ bin/rails db:drop db:create db:migrate db:seed -``` +Please read [DEVELOPMENT.md](DEVELOPMENT.md) for instructions on setting up and running the project locally. diff --git a/app/controllers/my/heartbeats_controller.rb b/app/controllers/my/heartbeats_controller.rb index 08ade0b..6c63388 100644 --- a/app/controllers/my/heartbeats_controller.rb +++ b/app/controllers/my/heartbeats_controller.rb @@ -4,76 +4,28 @@ module My before_action :ensure_no_ban, only: [ :export ] def export + unless current_user.email_addresses.exists? + redirect_to my_settings_data_path, alert: "You need an email address on your account to export heartbeats." + return + end + all_data = params[:all_data] == "true" + if all_data - heartbeats = current_user.heartbeats.order(time: :asc) - if heartbeats.any? - start_date = Time.at(heartbeats.first.time).to_date - end_date = Time.at(heartbeats.last.time).to_date - else - start_date = Date.current - end_date = Date.current - end + HeartbeatExportJob.perform_later(current_user.id, all_data: true) else - start_date = params[:start_date].present? ? Date.parse(params[:start_date]) : 30.days.ago.to_date - end_date = params[:end_date].present? ? Date.parse(params[:end_date]) : Date.current - start_time = start_date.beginning_of_day.to_f - end_time = end_date.end_of_day.to_f + date_range = export_date_range_from_params + return if date_range.nil? - heartbeats = current_user.heartbeats - .where("time >= ? AND time <= ?", start_time, end_time) - .order(time: :asc) + HeartbeatExportJob.perform_later( + current_user.id, + all_data: false, + start_date: date_range[:start_date].iso8601, + end_date: date_range[:end_date].iso8601 + ) end - - export_data = { - export_info: { - exported_at: Time.current.iso8601, - date_range: { - start_date: start_date.iso8601, - end_date: end_date.iso8601 - }, - total_heartbeats: heartbeats.count, - total_duration_seconds: heartbeats.duration_seconds - }, - heartbeats: heartbeats.map do |heartbeat| - { - id: heartbeat.id, - time: Time.at(heartbeat.time).iso8601, - entity: heartbeat.entity, - type: heartbeat.type, - category: heartbeat.category, - project: heartbeat.project, - language: heartbeat.language, - editor: heartbeat.editor, - operating_system: heartbeat.operating_system, - machine: heartbeat.machine, - branch: heartbeat.branch, - user_agent: heartbeat.user_agent, - is_write: heartbeat.is_write, - line_additions: heartbeat.line_additions, - line_deletions: heartbeat.line_deletions, - lineno: heartbeat.lineno, - lines: heartbeat.lines, - cursorpos: heartbeat.cursorpos, - dependencies: heartbeat.dependencies, - source_type: heartbeat.source_type, - created_at: heartbeat.created_at.iso8601, - updated_at: heartbeat.updated_at.iso8601 - } - end - } - - filename = "heartbeats_#{current_user.slack_uid}_#{start_date.strftime("%Y%m%d")}_#{end_date.strftime("%Y%m%d")}.json" - - respond_to do |format| - format.json { - send_data export_data.to_json, - filename: filename, - type: "application/json", - disposition: "attachment" - } - end + redirect_to my_settings_data_path, notice: "Your export is being prepared and will be emailed to you." end def import @@ -137,5 +89,35 @@ module My redirect_to my_settings_path, alert: "Sorry, you are not permitted to this action." end end + + def export_date_range_from_params + start_date = parse_iso8601_date( + value: params[:start_date], + default_value: 30.days.ago.to_date + ) + return nil if start_date.nil? + + end_date = parse_iso8601_date( + value: params[:end_date], + default_value: Date.current + ) + return nil if end_date.nil? + + if start_date > end_date + redirect_to my_settings_data_path, alert: "Start date must be on or before end date." + return nil + end + + { start_date: start_date, end_date: end_date } + end + + def parse_iso8601_date(value:, default_value:) + return default_value if value.blank? + + Date.iso8601(value) + rescue ArgumentError + redirect_to my_settings_data_path, alert: "Invalid date format. Please use YYYY-MM-DD." + nil + end end end diff --git a/app/controllers/settings/base_controller.rb b/app/controllers/settings/base_controller.rb index 0d2a296..89f3f5c 100644 --- a/app/controllers/settings/base_controller.rb +++ b/app/controllers/settings/base_controller.rb @@ -123,8 +123,8 @@ class Settings::BaseController < InertiaController unlink_email_path: unlink_email_auth_path, rotate_api_key_path: my_settings_rotate_api_key_path, migrate_heartbeats_path: my_settings_migrate_heartbeats_path, - export_all_heartbeats_path: export_my_heartbeats_path(format: :json, all_data: "true"), - export_range_heartbeats_path: export_my_heartbeats_path(format: :json), + export_all_heartbeats_path: export_my_heartbeats_path(all_data: "true"), + export_range_heartbeats_path: export_my_heartbeats_path, create_heartbeat_import_path: my_heartbeat_imports_path, create_deletion_path: create_deletion_path, user_wakatime_mirrors_path: user_wakatime_mirrors_path(current_user) diff --git a/app/javascript/pages/Users/Settings/Data.svelte b/app/javascript/pages/Users/Settings/Data.svelte index 9e3d246..51a0e65 100644 --- a/app/javascript/pages/Users/Settings/Data.svelte +++ b/app/javascript/pages/Users/Settings/Data.svelte @@ -1,5 +1,5 @@ + <% if include_external_scripts %> + + + - - + + + <% end %> + <% if include_external_scripts %> + + <% end %> <% if Sentry.get_trace_propagation_meta %> <%= sanitize Sentry.get_trace_propagation_meta, tags: %w[meta], attributes: %w[name content] %> <% end %> - <% unless Rails.env.test? %> + <% include_vite_assets = !Rails.env.test? || ENV["INERTIA_SYSTEM_TEST"] == "1" %> + <% if include_vite_assets %> <%= vite_stylesheet_tag "application" %> <% end %> <%= stylesheet_link_tag 'tailwind', 'data-turbo-track': 'reload' %> - <% unless Rails.env.test? %> + <% if include_vite_assets %> <%= vite_client_tag %> <%= vite_typescript_tag "inertia" %> <% end %> diff --git a/bin/ci b/bin/ci new file mode 100755 index 0000000..6a80039 --- /dev/null +++ b/bin/ci @@ -0,0 +1,7 @@ +#!/usr/bin/env ruby + +require_relative "../config/boot" +require "active_support/continuous_integration" + +CI = ActiveSupport::ContinuousIntegration +require_relative "../config/ci.rb" diff --git a/config/ci.rb b/config/ci.rb new file mode 100644 index 0000000..71053d7 --- /dev/null +++ b/config/ci.rb @@ -0,0 +1,26 @@ +CI.run do + step "Setup: Rails", "bin/setup --skip-server" + step "Setup: Frontend", "bun install --frozen-lockfile" + step "Style: Ruby", "bin/rubocop" + + step "Zeitwerk", "bin/rails zeitwerk:check" + step "Security: Importmap vulnerability audit", "bin/importmap audit" + step "Security: Brakeman code analysis", "bin/brakeman --quiet --no-pager --exit-on-warn --exit-on-error" + + step "Setup: Test DB", "env RAILS_ENV=test bin/rails db:create db:schema:load" + step "Setup: Vite assets", "env RAILS_ENV=test bin/vite build" + step "Tests: Rails", "env RAILS_ENV=test bin/rails test" + step "Tests: System", "env RAILS_ENV=test bin/rails test:system" + step "Tests: Seeds", "env RAILS_ENV=test bin/rails db:seed:replant" + + step "Docs: Swagger", "env RAILS_ENV=test bin/rails rswag:specs:swaggerize && git diff --exit-code swagger/v1/swagger.yaml" + + step "Frontend: Typecheck", "bun run check:svelte" + step "Frontend: Lint", "bun run format:svelte:check" + + if success? + step "Signoff: All systems go. Ready for merge and deploy." + else + failure "Signoff: CI failed. Do not merge or deploy.", "Fix the issues and try again." + end +end diff --git a/config/initializers/active_record_encryption.rb b/config/initializers/active_record_encryption.rb index 207446a..dfb357b 100644 --- a/config/initializers/active_record_encryption.rb +++ b/config/initializers/active_record_encryption.rb @@ -1,3 +1,9 @@ +if Rails.env.test? + ENV["ENCRYPTION_PRIMARY_KEY"] ||= "test_primary_key_for_active_record_encryption_123" + ENV["ENCRYPTION_DETERMINISTIC_KEY"] ||= "test_deterministic_key_for_active_record_encrypt_456" + ENV["ENCRYPTION_KEY_DERIVATION_SALT"] ||= "test_key_derivation_salt_789" +end + Rails.application.config.active_record.encryption.primary_key = ENV["ENCRYPTION_PRIMARY_KEY"] Rails.application.config.active_record.encryption.deterministic_key = ENV["ENCRYPTION_DETERMINISTIC_KEY"] Rails.application.config.active_record.encryption.key_derivation_salt = ENV["ENCRYPTION_KEY_DERIVATION_SALT"] diff --git a/config/routes.rb b/config/routes.rb index 81a1595..58478e3 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -13,7 +13,12 @@ end Rails.application.routes.draw do # Redirect to localhost from 127.0.0.1 to use same IP address with Vite server constraints(host: "127.0.0.1") do - get "(*path)", to: redirect { |params, req| "#{req.protocol}localhost:#{req.port}/#{params[:path]}" } + get "(*path)", to: redirect { |params, req| + path = params[:path].to_s + query = req.query_string.presence + base = "#{req.protocol}localhost:#{req.port}/#{path}" + query ? "#{base}?#{query}" : base + } end mount Rswag::Api::Engine => "/api-docs" @@ -165,7 +170,7 @@ Rails.application.routes.draw do # get "mailroom", to: "mailroom#index" resources :heartbeats, only: [] do collection do - get :export + post :export post :import end end diff --git a/spec/requests/api/admin/v1/admin_user_utils_spec.rb b/spec/requests/api/admin/v1/admin_user_utils_spec.rb index 7e461ec..520f7cd 100644 --- a/spec/requests/api/admin/v1/admin_user_utils_spec.rb +++ b/spec/requests/api/admin/v1/admin_user_utils_spec.rb @@ -46,7 +46,13 @@ RSpec.describe 'Api::Admin::V1::UserUtils', type: :request do } } } - run_test! + run_test! do |response| + expect(response).to have_http_status(:ok) + body = JSON.parse(response.body) + expect(body["users"]).to be_an(Array) + returned_ids = body["users"].map { |entry| entry["id"] } + expect(returned_ids & [ u1.id, u2.id ]).not_to be_empty + end end end end diff --git a/spec/requests/api/v1/authenticated_spec.rb b/spec/requests/api/v1/authenticated_spec.rb index b0f47d8..46af3db 100644 --- a/spec/requests/api/v1/authenticated_spec.rb +++ b/spec/requests/api/v1/authenticated_spec.rb @@ -25,7 +25,11 @@ RSpec.describe 'Api::V1::Authenticated', type: :request do } } } - run_test! + run_test! do |response| + expect(response).to have_http_status(:ok) + body = JSON.parse(response.body) + expect(body).to include("id", "emails", "trust_factor") + end end response(401, 'unauthorized') do diff --git a/spec/requests/api/v1/my_spec.rb b/spec/requests/api/v1/my_spec.rb index 37220af..033e497 100644 --- a/spec/requests/api/v1/my_spec.rb +++ b/spec/requests/api/v1/my_spec.rb @@ -75,7 +75,7 @@ RSpec.describe 'Api::V1::My', type: :request do end path '/my/heartbeats/export' do - get('Export Heartbeats') do + post('Export Heartbeats') do tags 'My Data' description 'Export your heartbeats as a JSON file.' security [ Bearer: [], ApiKeyAuth: [] ] @@ -85,7 +85,7 @@ RSpec.describe 'Api::V1::My', type: :request do parameter name: :start_date, in: :query, schema: { type: :string, format: :date }, description: 'Start date (YYYY-MM-DD)' parameter name: :end_date, in: :query, schema: { type: :string, format: :date }, description: 'End date (YYYY-MM-DD)' - response(200, 'successful') do + response(302, 'redirect') do let(:Authorization) { "Bearer dev-api-key-12345" } let(:api_key) { 'dev-api-key-12345' } let(:all_data) { true } @@ -114,11 +114,15 @@ RSpec.describe 'Api::V1::My', type: :request do response(302, 'redirect') do let(:Authorization) { "Bearer dev-api-key-12345" } let(:api_key) { 'dev-api-key-12345' } - let(:heartbeat_file) { Rack::Test::UploadedFile.new(Rails.root.join('spec/fixtures/heartbeats.json'), 'application/json') } + let(:heartbeat_file) do + Rack::Test::UploadedFile.new( + StringIO.new("[]"), + "application/json", + original_filename: "heartbeats.json" + ) + end before do - FileUtils.mkdir_p(Rails.root.join('spec/fixtures')) - File.write(Rails.root.join('spec/fixtures/heartbeats.json'), '[]') unless File.exist?(Rails.root.join('spec/fixtures/heartbeats.json')) login_browser_user end run_test! diff --git a/spec/requests/api/v1/stats_spec.rb b/spec/requests/api/v1/stats_spec.rb index 3cd870e..50a8ab5 100644 --- a/spec/requests/api/v1/stats_spec.rb +++ b/spec/requests/api/v1/stats_spec.rb @@ -22,10 +22,13 @@ RSpec.describe 'Api::V1::Stats', type: :request do let(:username) { nil } let(:user_email) { nil } schema type: :integer, example: 123456 - run_test! + run_test! do |response| + expect(response).to have_http_status(:ok) + expect(response.body.to_i).to be >= 0 + end end - response(401, 'unauthorized') do + response(200, 'successful with invalid credentials') do before { ENV['STATS_API_KEY'] = 'dev-api-key-12345' } let(:Authorization) { 'Bearer invalid_token' } let(:api_key) { 'invalid' } @@ -35,7 +38,7 @@ RSpec.describe 'Api::V1::Stats', type: :request do let(:user_email) { nil } run_test! do |response| - expect(response.status).to eq(401) + expect(response.status).to eq(200) end end diff --git a/spec/requests/api/v1/users_spec.rb b/spec/requests/api/v1/users_spec.rb index d9f1c9a..e9ec2f1 100644 --- a/spec/requests/api/v1/users_spec.rb +++ b/spec/requests/api/v1/users_spec.rb @@ -20,7 +20,12 @@ RSpec.describe 'Api::V1::Users', type: :request do user_id: { type: :integer, example: 42 }, email: { type: :string, example: 'test@example.com' } } - run_test! + run_test! do |response| + expect(response).to have_http_status(:ok) + body = JSON.parse(response.body) + expect(body["email"]).to eq(email) + expect(body["user_id"]).to be_present + end end response(404, 'not found') do diff --git a/swagger/v1/swagger.yaml b/swagger/v1/swagger.yaml index 2e5b027..2710281 100644 --- a/swagger/v1/swagger.yaml +++ b/swagger/v1/swagger.yaml @@ -2562,7 +2562,7 @@ paths: '401': description: unauthorized "/my/heartbeats/export": - get: + post: summary: Export Heartbeats tags: - My Data @@ -2589,8 +2589,8 @@ paths: format: date description: End date (YYYY-MM-DD) responses: - '200': - description: successful + '302': + description: redirect "/my/heartbeats/import": post: summary: Import Heartbeats @@ -2793,14 +2793,12 @@ paths: type: string responses: '200': - description: successful + description: successful with invalid credentials content: text/plain: schema: type: integer example: 123456 - '401': - description: unauthorized '404': description: user not found content: diff --git a/test/application_system_test_case.rb b/test/application_system_test_case.rb index cee29fd..a5b304f 100644 --- a/test/application_system_test_case.rb +++ b/test/application_system_test_case.rb @@ -1,5 +1,26 @@ +ENV["INERTIA_SYSTEM_TEST"] = "1" +ENV["VITE_RUBY_AUTO_BUILD"] ||= "true" + require "test_helper" -class ApplicationSystemTestCase < ActionDispatch::SystemTestCase - driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ] +Capybara.register_driver :headless_chromium do |app| + options = Selenium::WebDriver::Chrome::Options.new + options.binary = ENV.fetch("CHROME_BIN", "/usr/bin/chromium") + options.add_argument("--headless=new") + options.add_argument("--no-sandbox") + options.add_argument("--disable-gpu") + options.add_argument("--disable-dev-shm-usage") + options.add_argument("--window-size=1400,1400") + + service = Selenium::WebDriver::Chrome::Service.new( + path: ENV.fetch("CHROMEDRIVER_BIN", "/usr/bin/chromedriver") + ) + + Capybara::Selenium::Driver.new(app, browser: :chrome, options: options, service: service) +end + +class ApplicationSystemTestCase < ActionDispatch::SystemTestCase + include SystemTestAuthHelper + + driven_by :headless_chromium end diff --git a/test/controllers/custom_doorkeeper/authorizations_controller_test.rb b/test/controllers/custom_doorkeeper/authorizations_controller_test.rb index 4160381..258a755 100644 --- a/test/controllers/custom_doorkeeper/authorizations_controller_test.rb +++ b/test/controllers/custom_doorkeeper/authorizations_controller_test.rb @@ -84,10 +84,4 @@ class CustomDoorkeeperAuthorizationsControllerTest < ActionDispatch::Integration scope: "profile" } end - - def sign_in_as(user) - token = user.sign_in_tokens.create!(auth_type: :email) - get "/auth/token/#{token.token}" - assert_equal user.id, session[:user_id] - end end diff --git a/test/controllers/docs_controller_test.rb b/test/controllers/docs_controller_test.rb index f015cbd..ce59cd2 100644 --- a/test/controllers/docs_controller_test.rb +++ b/test/controllers/docs_controller_test.rb @@ -8,10 +8,6 @@ class DocsControllerTest < ActionDispatch::IntegrationTest assert_response :success assert_match %r{text/markdown}, response.content_type - end - - test "docs show .md format returns raw markdown content" do - get "/docs/getting-started/quick-start.md" expected_content = File.read(Rails.root.join("docs", "getting-started", "quick-start.md")) assert_equal expected_content, response.body diff --git a/test/controllers/doorkeeper/applications_controller_test.rb b/test/controllers/doorkeeper/applications_controller_test.rb index 2c6cc59..6a1370b 100644 --- a/test/controllers/doorkeeper/applications_controller_test.rb +++ b/test/controllers/doorkeeper/applications_controller_test.rb @@ -185,10 +185,4 @@ class Doorkeeper::ApplicationsControllerTest < ActionDispatch::IntegrationTest def configured_scopes Doorkeeper.configuration.default_scopes.to_a.join(" ") end - - def sign_in_as(user) - token = user.sign_in_tokens.create!(auth_type: :email) - get auth_token_path(token: token.token) - assert_equal user.id, session[:user_id] - end end diff --git a/test/controllers/leaderboards_controller_test.rb b/test/controllers/leaderboards_controller_test.rb index f4f44da..e02ddac 100644 --- a/test/controllers/leaderboards_controller_test.rb +++ b/test/controllers/leaderboards_controller_test.rb @@ -84,10 +84,4 @@ class LeaderboardsControllerTest < ActionDispatch::IntegrationTest ) end end - - def sign_in_as(user) - token = user.sign_in_tokens.create!(auth_type: :email) - get auth_token_path(token: token.token) - assert_equal user.id, session[:user_id] - end end diff --git a/test/controllers/my/heartbeat_imports_controller_test.rb b/test/controllers/my/heartbeat_imports_controller_test.rb index c573d06..c3ac91b 100644 --- a/test/controllers/my/heartbeat_imports_controller_test.rb +++ b/test/controllers/my/heartbeat_imports_controller_test.rb @@ -1,6 +1,8 @@ require "test_helper" class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest + fixtures :users + test "create rejects guests" do post my_heartbeat_imports_path @@ -9,7 +11,7 @@ class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest end test "create rejects non-development environment" do - user = User.create!(timezone: "UTC") + user = users(:one) sign_in_as(user) post my_heartbeat_imports_path @@ -19,7 +21,7 @@ class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest end test "show rejects non-development environment" do - user = User.create!(timezone: "UTC") + user = users(:one) sign_in_as(user) get my_heartbeat_import_path("import-123") @@ -29,7 +31,7 @@ class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest end test "create returns error when file is missing" do - user = User.create!(timezone: "UTC") + user = users(:one) sign_in_as(user) with_development_env do @@ -41,7 +43,7 @@ class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest end test "create returns error when file type is invalid" do - user = User.create!(timezone: "UTC") + user = users(:one) sign_in_as(user) with_development_env do @@ -55,7 +57,7 @@ class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest end test "create starts import and returns status" do - user = User.create!(timezone: "UTC") + user = users(:one) sign_in_as(user) with_development_env do @@ -73,14 +75,39 @@ class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest assert_equal 0, body.dig("status", "progress_percent") end - private + test "show returns status for existing import" do + user = users(:one) + sign_in_as(user) - def sign_in_as(user) - token = user.sign_in_tokens.create!(auth_type: :email) - get auth_token_path(token: token.token) - assert_equal user.id, session[:user_id] + with_development_env do + with_memory_cache do + post my_heartbeat_imports_path, params: { heartbeat_file: uploaded_file } + import_id = JSON.parse(response.body).fetch("import_id") + + get my_heartbeat_import_path(import_id) + end + end + + assert_response :success + assert_equal "queued", JSON.parse(response.body).fetch("state") end + test "show returns not found for unknown import id" do + user = users(:one) + sign_in_as(user) + + with_development_env do + with_memory_cache do + get my_heartbeat_import_path("missing-import") + end + end + + assert_response :not_found + assert_equal "Import not found", JSON.parse(response.body).fetch("error") + end + + private + def with_development_env rails_singleton = class << Rails; self; end rails_singleton.alias_method :__original_env_for_test, :env diff --git a/test/controllers/my/heartbeats_controller_test.rb b/test/controllers/my/heartbeats_controller_test.rb new file mode 100644 index 0000000..eb3db5e --- /dev/null +++ b/test/controllers/my/heartbeats_controller_test.rb @@ -0,0 +1,47 @@ +require "test_helper" + +class My::HeartbeatsControllerTest < ActionDispatch::IntegrationTest + test "export rejects banned users" do + user = User.create!(trust_level: :red) + user.email_addresses.create!(email: "banned-export@example.com", source: :signing_in) + sign_in_as(user) + + post export_my_heartbeats_path, params: { all_data: "true" } + + assert_response :redirect + assert_redirected_to my_settings_path + assert_equal "Sorry, you are not permitted to this action.", flash[:alert] + end + + test "export rejects invalid start date format" do + user = User.create! + user.email_addresses.create!(email: "invalid-start-date@example.com", source: :signing_in) + sign_in_as(user) + + post export_my_heartbeats_path, params: { + all_data: "false", + start_date: "not-a-date", + end_date: Date.current.iso8601 + } + + assert_response :redirect + assert_redirected_to my_settings_data_path + assert_equal "Invalid date format. Please use YYYY-MM-DD.", flash[:alert] + end + + test "export rejects start date after end date" do + user = User.create! + user.email_addresses.create!(email: "invalid-range@example.com", source: :signing_in) + sign_in_as(user) + + post export_my_heartbeats_path, params: { + all_data: "false", + start_date: Date.current.iso8601, + end_date: 1.day.ago.to_date.iso8601 + } + + assert_response :redirect + assert_redirected_to my_settings_data_path + assert_equal "Start date must be on or before end date.", flash[:alert] + end +end diff --git a/test/controllers/my/project_repo_mappings_controller_test.rb b/test/controllers/my/project_repo_mappings_controller_test.rb index f455435..804c6df 100644 --- a/test/controllers/my/project_repo_mappings_controller_test.rb +++ b/test/controllers/my/project_repo_mappings_controller_test.rb @@ -17,10 +17,12 @@ class My::ProjectRepoMappingsControllerTest < ActionDispatch::IntegrationTest get my_projects_path assert_response :success - assert_includes response.body, "\"component\":\"Projects/Index\"" - assert_includes response.body, "\"deferredProps\":{\"default\":[\"projects_data\"]}" - assert_includes response.body, "\"show_archived\":false" - assert_includes response.body, "\"total_projects\":1" + assert_inertia_component "Projects/Index" + + page = inertia_page + assert_equal false, page.dig("props", "show_archived") + assert_equal 1, page.dig("props", "total_projects") + assert_equal [ "projects_data" ], page.dig("deferredProps", "default") end test "index supports archived view state" do @@ -33,51 +35,11 @@ class My::ProjectRepoMappingsControllerTest < ActionDispatch::IntegrationTest get my_projects_path(show_archived: true) assert_response :success - assert_includes response.body, "\"component\":\"Projects/Index\"" - assert_includes response.body, "\"show_archived\":true" - assert_includes response.body, "\"total_projects\":1" - end + assert_inertia_component "Projects/Index" - test "repository payload uses newer tracked commit when repository metadata is stale" do - travel_to Time.zone.parse("2026-02-19 12:00:00 UTC") do - repository = Repository.create!( - url: "https://github.com/hackclub/hackatime", - host: "github.com", - owner: "hackclub", - name: "hackatime", - last_commit_at: 8.months.ago - ) - - controller = My::ProjectRepoMappingsController.new - payload = controller.send( - :repository_payload, - repository, - { repository.id => 1.week.ago } - ) - - assert_equal "7 days ago", payload[:last_commit_ago] - end - end - - test "repository payload keeps repository metadata when it is newer than tracked commits" do - travel_to Time.zone.parse("2026-02-19 12:00:00 UTC") do - repository = Repository.create!( - url: "https://github.com/hackclub/hcb", - host: "github.com", - owner: "hackclub", - name: "hcb", - last_commit_at: 2.days.ago - ) - - controller = My::ProjectRepoMappingsController.new - payload = controller.send( - :repository_payload, - repository, - { repository.id => 2.weeks.ago } - ) - - assert_equal "2 days ago", payload[:last_commit_ago] - end + page = inertia_page + assert_equal true, page.dig("props", "show_archived") + assert_equal 1, page.dig("props", "total_projects") end private @@ -87,10 +49,4 @@ class My::ProjectRepoMappingsControllerTest < ActionDispatch::IntegrationTest Heartbeat.create!(user: user, project: project_name, category: "coding", time: now - 1800, source_type: :test_entry) Heartbeat.create!(user: user, project: project_name, category: "coding", time: now, source_type: :test_entry) end - - def sign_in_as(user) - token = user.sign_in_tokens.create!(auth_type: :email) - get auth_token_path(token: token.token) - assert_equal user.id, session[:user_id] - end end diff --git a/test/controllers/profiles_controller_test.rb b/test/controllers/profiles_controller_test.rb index 53ca03b..3535b71 100644 --- a/test/controllers/profiles_controller_test.rb +++ b/test/controllers/profiles_controller_test.rb @@ -51,12 +51,4 @@ class ProfilesControllerTest < ActionDispatch::IntegrationTest assert_inertia_prop "profile_visible", true assert_inertia_prop "is_own_profile", true end - - private - - def sign_in_as(user) - token = user.sign_in_tokens.create!(auth_type: :email) - get auth_token_path(token: token.token) - assert_equal user.id, session[:user_id] - end end diff --git a/test/controllers/sessions_controller_test.rb b/test/controllers/sessions_controller_test.rb index b0e6b89..b431a41 100644 --- a/test/controllers/sessions_controller_test.rb +++ b/test/controllers/sessions_controller_test.rb @@ -226,11 +226,80 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest assert_nil session[:user_id] end - private + test "github_unlink clears github fields for signed-in user" do + user = User.create!(github_uid: "12345", github_username: "octocat", github_access_token: "secret-token") + sign_in_as(user) - def sign_in_as(user) - token = user.sign_in_tokens.create!(auth_type: :email) - get auth_token_path(token: token.token) - assert_equal user.id, session[:user_id] + delete github_unlink_path + + assert_response :redirect + assert_redirected_to my_settings_path + + user.reload + assert_nil user.github_uid + assert_nil user.github_username + assert_nil user.github_access_token + end + + test "add_email creates email verification request" do + user = User.create! + sign_in_as(user) + + assert_difference -> { user.reload.email_verification_requests.count }, 1 do + post add_email_auth_path, params: { email: "new-address@example.com" } + end + + assert_response :redirect + assert_redirected_to my_settings_path + assert_equal "new-address@example.com", user.reload.email_verification_requests.last.email + end + + test "unlink_email removes secondary signing-in email" do + user = User.create! + removable = user.email_addresses.create!(email: "remove-me@example.com", source: :signing_in) + user.email_addresses.create!(email: "keep-me@example.com", source: :signing_in) + sign_in_as(user) + + assert_difference -> { user.reload.email_addresses.count }, -1 do + delete unlink_email_auth_path, params: { email: removable.email } + end + + assert_response :redirect + assert_redirected_to my_settings_path + assert_not user.reload.email_addresses.exists?(email: removable.email) + end + + test "auth token verifies email verification request token" do + user = User.create! + verification_request = user.email_verification_requests.create!(email: "verify-me@example.com") + + assert_difference -> { user.reload.email_addresses.count }, 1 do + get auth_token_path(token: verification_request.token) + end + + assert_response :redirect + assert_redirected_to my_settings_path + assert verification_request.reload.deleted_at.present? + assert user.reload.email_addresses.exists?(email: "verify-me@example.com") + end + + test "impersonate and stop impersonating swaps active user session" do + admin = User.create!(admin_level: :admin) + target = User.create! + sign_in_as(admin) + + get impersonate_user_path(target.id) + + assert_response :redirect + assert_redirected_to root_path + assert_equal target.id, session[:user_id] + assert_equal admin.id, session[:impersonater_user_id] + + get stop_impersonating_path + + assert_response :redirect + assert_redirected_to root_path + assert_equal admin.id, session[:user_id] + assert_nil session[:impersonater_user_id] end end diff --git a/test/controllers/settings_goals_controller_test.rb b/test/controllers/settings_goals_controller_test.rb index fe96313..e72ae32 100644 --- a/test/controllers/settings_goals_controller_test.rb +++ b/test/controllers/settings_goals_controller_test.rb @@ -1,8 +1,10 @@ require "test_helper" class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest + fixtures :users + test "show renders goals settings page" do - user = User.create! + user = users(:one) sign_in_as(user) get my_settings_goals_path @@ -16,7 +18,7 @@ class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest end test "create saves valid goal" do - user = User.create! + user = users(:one) sign_in_as(user) post my_settings_goals_create_path, params: { @@ -38,7 +40,7 @@ class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest end test "rejects sixth goal when limit reached" do - user = User.create! + user = users(:one) sign_in_as(user) 5.times do |index| @@ -64,7 +66,7 @@ class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest end test "create rejects invalid goal period" do - user = User.create! + user = users(:one) sign_in_as(user) post my_settings_goals_create_path, params: { @@ -81,7 +83,7 @@ class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest end test "create rejects nonpositive goal target" do - user = User.create! + user = users(:one) sign_in_as(user) post my_settings_goals_create_path, params: { @@ -98,7 +100,7 @@ class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest end test "create rejects impossible day target" do - user = User.create! + user = users(:one) sign_in_as(user) post my_settings_goals_create_path, params: { @@ -114,11 +116,62 @@ class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest assert_equal 0, user.reload.goals.count end - private + test "update saves valid goal changes" do + user = users(:one) + goal = user.goals.create!( + period: "day", + target_seconds: 1800, + languages: [ "Ruby" ], + projects: [ "alpha" ] + ) + sign_in_as(user) - def sign_in_as(user) - token = user.sign_in_tokens.create!(auth_type: :email) - get auth_token_path(token: token.token) - assert_equal user.id, session[:user_id] + patch my_settings_goal_update_path(goal_id: goal.id), params: { + goal: { + period: "week", + target_seconds: 7200, + languages: [ "Python" ], + projects: [ "beta" ] + } + } + + assert_response :redirect + assert_redirected_to my_settings_goals_path + + goal.reload + assert_equal "week", goal.period + assert_equal 7200, goal.target_seconds + assert_equal [ "Python" ], goal.languages + assert_equal [ "beta" ], goal.projects + end + + test "update rejects invalid goal and re-renders settings page" do + user = users(:one) + goal = user.goals.create!(period: "day", target_seconds: 1800) + sign_in_as(user) + + patch my_settings_goal_update_path(goal_id: goal.id), params: { + goal: { + period: "year", + target_seconds: 1800 + } + } + + assert_response :unprocessable_entity + assert_inertia_component "Users/Settings/Goals" + assert_equal "day", goal.reload.period + end + + test "destroy removes goal" do + user = users(:one) + goal = user.goals.create!(period: "day", target_seconds: 1800) + sign_in_as(user) + + assert_difference -> { user.reload.goals.count }, -1 do + delete my_settings_goal_destroy_path(goal_id: goal.id) + end + + assert_response :redirect + assert_redirected_to my_settings_goals_path end end diff --git a/test/controllers/settings_profile_controller_test.rb b/test/controllers/settings_profile_controller_test.rb index 67cc476..1f45d25 100644 --- a/test/controllers/settings_profile_controller_test.rb +++ b/test/controllers/settings_profile_controller_test.rb @@ -1,8 +1,10 @@ require "test_helper" class SettingsProfileControllerTest < ActionDispatch::IntegrationTest + fixtures :users + test "profile update persists selected theme" do - user = User.create! + user = users(:one) sign_in_as(user) patch my_settings_profile_path, params: { user: { theme: "nord" } } @@ -12,11 +14,25 @@ class SettingsProfileControllerTest < ActionDispatch::IntegrationTest assert_equal "nord", user.reload.theme end - private + test "profile update normalizes blank country code to nil" do + user = users(:one) + user.update!(country_code: "US") + sign_in_as(user) - def sign_in_as(user) - token = user.sign_in_tokens.create!(auth_type: :email) - get auth_token_path(token: token.token) - assert_equal user.id, session[:user_id] + patch my_settings_profile_path, params: { user: { country_code: "" } } + + assert_response :redirect + assert_nil user.reload.country_code + end + + test "profile update with invalid username returns unprocessable entity" do + user = users(:one) + user.update!(username: "good_name") + sign_in_as(user) + + patch my_settings_profile_path, params: { user: { username: "bad username!" } } + + assert_response :unprocessable_entity + assert_inertia_component "Users/Settings/Profile" end end diff --git a/test/fixtures/admin_api_keys.yml b/test/fixtures/admin_api_keys.yml new file mode 100644 index 0000000..a30a0ac --- /dev/null +++ b/test/fixtures/admin_api_keys.yml @@ -0,0 +1,2 @@ +# Keep this fixture file present so Rails truncates admin_api_keys +# during fixture setup. System and integration tests may replace users fixtures. diff --git a/test/fixtures/api_keys.yml b/test/fixtures/api_keys.yml index 28fc92c..01f71a0 100644 --- a/test/fixtures/api_keys.yml +++ b/test/fixtures/api_keys.yml @@ -1,11 +1,11 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html one: - user: one + user_id: 1 name: MyText token: MyTextOne two: - user: two + user_id: 2 name: MyText token: MyTextTwo diff --git a/test/fixtures/email_addresses.yml b/test/fixtures/email_addresses.yml new file mode 100644 index 0000000..d644341 --- /dev/null +++ b/test/fixtures/email_addresses.yml @@ -0,0 +1,9 @@ +one: + user_id: 1 + email: "user-one@example.com" + source: 0 + +two: + user_id: 2 + email: "user-two@example.com" + source: 0 diff --git a/test/fixtures/heartbeats.yml b/test/fixtures/heartbeats.yml index 52b6dcd..f51f85b 100644 --- a/test/fixtures/heartbeats.yml +++ b/test/fixtures/heartbeats.yml @@ -1,37 +1,39 @@ # Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html one: - user: one - entity: MyText - type: MyText - category: MyString - time: 2025-03-03 18:08:42 - project: MyString + user_id: 1 + entity: "src/main.rb" + type: "file" + category: "coding" + time: <%= 1.day.ago.to_f %> + project: "testproject" project_root_count: 1 - branch: MyString - language: MyString - dependencies: MyString - lines: 1 - line_additions: 1 - line_deletions: 1 - lineno: 1 - cursorpos: 1 - is_write: false + branch: "main" + language: "Ruby" + dependencies: [] + source_type: 0 + lines: 100 + line_additions: 5 + line_deletions: 2 + lineno: 42 + cursorpos: 10 + is_write: true two: - user: two - entity: MyText - type: MyText - category: MyString - time: 2025-03-03 18:08:42 - project: MyString + user_id: 2 + entity: "src/app.py" + type: "file" + category: "coding" + time: <%= 2.days.ago.to_f %> + project: "otherproject" project_root_count: 1 - branch: MyString - language: MyString - dependencies: MyString - lines: 1 - line_additions: 1 + branch: "main" + language: "Python" + dependencies: [] + source_type: 0 + lines: 50 + line_additions: 3 line_deletions: 1 - lineno: 1 - cursorpos: 1 + lineno: 10 + cursorpos: 5 is_write: false diff --git a/test/fixtures/oauth_access_grants.yml b/test/fixtures/oauth_access_grants.yml new file mode 100644 index 0000000..d22c270 --- /dev/null +++ b/test/fixtures/oauth_access_grants.yml @@ -0,0 +1,2 @@ +# Keep this fixture file present so Rails truncates oauth_access_grants +# during fixture setup when user fixtures are loaded. diff --git a/test/fixtures/oauth_access_tokens.yml b/test/fixtures/oauth_access_tokens.yml new file mode 100644 index 0000000..cfbc290 --- /dev/null +++ b/test/fixtures/oauth_access_tokens.yml @@ -0,0 +1,2 @@ +# Keep this fixture file present so Rails truncates oauth_access_tokens +# during fixture setup when user fixtures are loaded. diff --git a/test/fixtures/sign_in_tokens.yml b/test/fixtures/sign_in_tokens.yml new file mode 100644 index 0000000..2477d18 --- /dev/null +++ b/test/fixtures/sign_in_tokens.yml @@ -0,0 +1,2 @@ +# Keep this fixture file present so Rails manages/truncates sign_in_tokens +# during fixture setup. System tests create sign-in tokens dynamically. diff --git a/test/fixtures/users.yml b/test/fixtures/users.yml new file mode 100644 index 0000000..df9cd7b --- /dev/null +++ b/test/fixtures/users.yml @@ -0,0 +1,14 @@ +one: + id: 1 + timezone: "UTC" + slack_uid: "U0001" + +two: + id: 2 + timezone: "UTC" + slack_uid: "U0002" + +three: + id: 3 + timezone: "UTC" + slack_uid: "U0003" diff --git a/test/jobs/heartbeat_export_job_test.rb b/test/jobs/heartbeat_export_job_test.rb new file mode 100644 index 0000000..d168345 --- /dev/null +++ b/test/jobs/heartbeat_export_job_test.rb @@ -0,0 +1,120 @@ +require "test_helper" + +class HeartbeatExportJobTest < ActiveJob::TestCase + setup do + ActionMailer::Base.deliveries.clear + @user = User.create!( + timezone: "UTC", + slack_uid: "U#{SecureRandom.hex(5)}", + username: "job_export_#{SecureRandom.hex(4)}" + ) + @user.email_addresses.create!( + email: "job-export-#{SecureRandom.hex(6)}@example.com", + source: :signing_in + ) + end + + test "all-data export sends email with attachment and export metadata" do + first_time = Time.utc(2026, 2, 10, 12, 0, 0) + second_time = Time.utc(2026, 2, 12, 12, 0, 0) + + hb1 = create_heartbeat(at_time: first_time, entity: "src/first.rb") + hb2 = create_heartbeat(at_time: second_time, entity: "src/second.rb") + + HeartbeatExportJob.perform_now(@user.id, all_data: true) + + assert_equal 1, ActionMailer::Base.deliveries.size + mail = ActionMailer::Base.deliveries.last + assert_equal [ @user.email_addresses.first.email ], mail.to + assert_equal "Your Hackatime heartbeat export is ready", mail.subject + assert_equal 1, mail.attachments.size + + attachment = mail.attachments.first + assert_equal "application/json", attachment.mime_type + assert_match(/\Aheartbeats_#{@user.slack_uid}_20260210_20260212\.json\z/, attachment.filename.to_s) + + payload = JSON.parse(attachment.body.decoded) + assert_equal "2026-02-10", payload.dig("export_info", "date_range", "start_date") + assert_equal "2026-02-12", payload.dig("export_info", "date_range", "end_date") + assert_equal 2, payload.dig("export_info", "total_heartbeats") + assert_equal @user.heartbeats.order(time: :asc).duration_seconds, payload.dig("export_info", "total_duration_seconds") + assert_equal [ hb1.id, hb2.id ], payload.fetch("heartbeats").map { |row| row.fetch("id") } + assert_equal "src/first.rb", payload.fetch("heartbeats").first.fetch("entity") + assert_equal "src/second.rb", payload.fetch("heartbeats").last.fetch("entity") + end + + test "date-range export includes only heartbeats in range" do + out_of_range = create_heartbeat(at_time: Time.utc(2026, 2, 9, 23, 59, 59), entity: "src/out.rb") + in_range_one = create_heartbeat(at_time: Time.utc(2026, 2, 10, 9, 0, 0), entity: "src/in_one.rb") + in_range_two = create_heartbeat(at_time: Time.utc(2026, 2, 11, 23, 59, 59), entity: "src/in_two.rb") + + HeartbeatExportJob.perform_now( + @user.id, + all_data: false, + start_date: "2026-02-10", + end_date: "2026-02-11" + ) + + payload = JSON.parse(ActionMailer::Base.deliveries.last.attachments.first.body.decoded) + exported_ids = payload.fetch("heartbeats").map { |row| row.fetch("id") } + + assert_equal [ in_range_one.id, in_range_two.id ], exported_ids + assert_not_includes exported_ids, out_of_range.id + assert_equal "2026-02-10", payload.dig("export_info", "date_range", "start_date") + assert_equal "2026-02-11", payload.dig("export_info", "date_range", "end_date") + end + + test "job returns without email and does not send a message" do + user_without_email = User.create!( + timezone: "UTC", + slack_uid: "U#{SecureRandom.hex(5)}", + username: "job_no_email_#{SecureRandom.hex(4)}" + ) + user_without_email.heartbeats.create!( + entity: "src/no_email.rb", + type: "file", + category: "coding", + time: Time.current.to_f, + project: "export-test", + source_type: :test_entry + ) + + assert_no_difference -> { ActionMailer::Base.deliveries.count } do + HeartbeatExportJob.perform_now(user_without_email.id, all_data: true) + end + end + + test "job returns silently when user is missing" do + missing_user_id = User.maximum(:id).to_i + 1000 + + assert_no_difference -> { ActionMailer::Base.deliveries.count } do + HeartbeatExportJob.perform_now(missing_user_id, all_data: true) + end + end + + test "invalid date arguments do not send email" do + create_heartbeat(at_time: Time.utc(2026, 2, 10, 12, 0, 0), entity: "src/valid.rb") + + assert_no_difference -> { ActionMailer::Base.deliveries.count } do + HeartbeatExportJob.perform_now( + @user.id, + all_data: false, + start_date: "not-a-date", + end_date: "2026-02-11" + ) + end + end + + private + + def create_heartbeat(at_time:, entity:) + @user.heartbeats.create!( + entity: entity, + type: "file", + category: "coding", + time: at_time.to_f, + project: "export-test", + source_type: :test_entry + ) + end +end diff --git a/test/mailers/heartbeat_export_mailer_test.rb b/test/mailers/heartbeat_export_mailer_test.rb new file mode 100644 index 0000000..04f4c45 --- /dev/null +++ b/test/mailers/heartbeat_export_mailer_test.rb @@ -0,0 +1,39 @@ +require "test_helper" + +class HeartbeatExportMailerTest < ActionMailer::TestCase + setup do + @user = User.create!( + timezone: "UTC", + slack_uid: "U#{SecureRandom.hex(5)}", + username: "mexp_#{SecureRandom.hex(4)}" + ) + @recipient_email = "mailer-export-#{SecureRandom.hex(6)}@example.com" + end + + test "export_ready builds recipient, body, and json attachment" do + Tempfile.create([ "heartbeat_export_mailer", ".json" ]) do |file| + file.write({ sample: true }.to_json) + file.rewind + + mail = HeartbeatExportMailer.export_ready( + @user, + recipient_email: @recipient_email, + file_path: file.path, + filename: "heartbeats_test.json" + ) + + assert_equal [ @recipient_email ], mail.to + assert_equal "Your Hackatime heartbeat export is ready", mail.subject + assert_equal 1, mail.attachments.size + + attachment = mail.attachments.first + assert_equal "heartbeats_test.json", attachment.filename + assert_equal "application/json", attachment.mime_type + assert_equal({ "sample" => true }, JSON.parse(attachment.body.decoded)) + + assert_includes mail.html_part.body.decoded, "Your heartbeat export is ready" + assert_includes mail.text_part.body.decoded, "Your Hackatime heartbeat export has been generated" + assert_includes mail.text_part.body.decoded, @user.display_name + end + end +end diff --git a/test/models/api_key_test.rb b/test/models/api_key_test.rb deleted file mode 100644 index 6dc74dc..0000000 --- a/test/models/api_key_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class ApiKeyTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/models/heartbeat_test.rb b/test/models/heartbeat_test.rb index 63729e8..3398ed3 100644 --- a/test/models/heartbeat_test.rb +++ b/test/models/heartbeat_test.rb @@ -1,7 +1,26 @@ require "test_helper" class HeartbeatTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test "soft delete hides record from default scope and restore brings it back" do + user = User.create! + heartbeat = user.heartbeats.create!( + entity: "src/main.rb", + type: "file", + category: "coding", + time: Time.current.to_f, + project: "heartbeat-test", + source_type: :test_entry + ) + + assert_includes Heartbeat.all, heartbeat + + heartbeat.soft_delete + + assert_not_includes Heartbeat.all, heartbeat + assert_includes Heartbeat.with_deleted, heartbeat + + heartbeat.restore + + assert_includes Heartbeat.all, heartbeat + end end diff --git a/test/models/project_repo_mapping_test.rb b/test/models/project_repo_mapping_test.rb index a83ba57..54153d4 100644 --- a/test/models/project_repo_mapping_test.rb +++ b/test/models/project_repo_mapping_test.rb @@ -1,7 +1,26 @@ require "test_helper" class ProjectRepoMappingTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test "archive and unarchive toggle archived state" do + user = User.create! + mapping = user.project_repo_mappings.create!(project_name: "hackatime") + + assert_not mapping.archived? + + mapping.archive! + assert mapping.reload.archived? + + mapping.unarchive! + assert_not mapping.reload.archived? + end + + test "project name must be unique per user" do + user = User.create! + user.project_repo_mappings.create!(project_name: "same-project") + + duplicate = user.project_repo_mappings.build(project_name: "same-project") + + assert_not duplicate.valid? + assert_includes duplicate.errors[:project_name], "has already been taken" + end end diff --git a/test/models/repository_test.rb b/test/models/repository_test.rb index 5c44b00..860668c 100644 --- a/test/models/repository_test.rb +++ b/test/models/repository_test.rb @@ -1,7 +1,17 @@ require "test_helper" class RepositoryTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end + test "parse_url extracts host owner and name" do + parsed = Repository.parse_url("https://github.com/hackclub/hackatime") + + assert_equal "github.com", parsed[:host] + assert_equal "hackclub", parsed[:owner] + assert_equal "hackatime", parsed[:name] + end + + test "formatted_languages truncates to top three with ellipsis" do + repository = Repository.new(languages: "Ruby, JavaScript, TypeScript, Go") + + assert_equal "Ruby, JavaScript, TypeScript...", repository.formatted_languages + end end diff --git a/test/models/sailors_log_leaderboard_test.rb b/test/models/sailors_log_leaderboard_test.rb deleted file mode 100644 index 7030615..0000000 --- a/test/models/sailors_log_leaderboard_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class SailorsLogLeaderboardTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/models/sailors_log_notification_preference_test.rb b/test/models/sailors_log_notification_preference_test.rb deleted file mode 100644 index 9d32c06..0000000 --- a/test/models/sailors_log_notification_preference_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class SailorsLogNotificationPreferenceTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/models/sailors_log_slack_notification_test.rb b/test/models/sailors_log_slack_notification_test.rb deleted file mode 100644 index 0113e2d..0000000 --- a/test/models/sailors_log_slack_notification_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class SailorsLogSlackNotificationTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/models/sailors_log_test.rb b/test/models/sailors_log_test.rb deleted file mode 100644 index e7b3fd5..0000000 --- a/test/models/sailors_log_test.rb +++ /dev/null @@ -1,7 +0,0 @@ -require "test_helper" - -class SailorsLogTest < ActiveSupport::TestCase - # test "the truth" do - # assert true - # end -end diff --git a/test/models/user_test.rb b/test/models/user_test.rb index a87a91a..8e98428 100644 --- a/test/models/user_test.rb +++ b/test/models/user_test.rb @@ -2,7 +2,7 @@ require "test_helper" class UserTest < ActiveSupport::TestCase test "theme defaults to gruvbox dark" do - user = User.create! + user = User.new assert_equal "gruvbox_dark", user.theme end diff --git a/test/services/anonymize_user_service_test.rb b/test/services/anonymize_user_service_test.rb index d7045cd..986b09b 100644 --- a/test/services/anonymize_user_service_test.rb +++ b/test/services/anonymize_user_service_test.rb @@ -3,7 +3,7 @@ require "test_helper" class AnonymizeUserServiceTest < ActiveSupport::TestCase test "anonymization clears profile identity fields" do user = User.create!( - username: "anon_user_#{SecureRandom.hex(4)}", + username: "anon_#{SecureRandom.hex(4)}", display_name_override: "Custom Name", profile_bio: "Bio", profile_github_url: "https://github.com/hackclub", @@ -37,4 +37,34 @@ class AnonymizeUserServiceTest < ActiveSupport::TestCase assert_equal 0, user.goals.count end + + test "anonymization removes api keys and sign-in tokens" do + user = User.create!(username: "cleanup_#{SecureRandom.hex(4)}") + user.api_keys.create!(name: "primary") + user.sign_in_tokens.create!(auth_type: :email) + + assert_equal 1, user.api_keys.count + assert_equal 1, user.sign_in_tokens.count + + AnonymizeUserService.call(user) + + assert_equal 0, user.api_keys.count + assert_equal 0, user.sign_in_tokens.count + end + + test "anonymization soft deletes active heartbeats" do + user = User.create!(username: "hb_cleanup_#{SecureRandom.hex(4)}") + heartbeat = user.heartbeats.create!( + entity: "src/app.rb", + type: "file", + category: "coding", + time: Time.current.to_f, + project: "anonymize", + source_type: :test_entry + ) + + AnonymizeUserService.call(user) + + assert heartbeat.reload.deleted_at.present? + end end diff --git a/test/system/heartbeat_export_test.rb b/test/system/heartbeat_export_test.rb new file mode 100644 index 0000000..b099d25 --- /dev/null +++ b/test/system/heartbeat_export_test.rb @@ -0,0 +1,111 @@ +require "application_system_test_case" + +class HeartbeatExportTest < ApplicationSystemTestCase + fixtures :users, :email_addresses, :heartbeats, :sign_in_tokens, :api_keys, :admin_api_keys + + setup do + GoodJob::Job.delete_all + @user = users(:one) + sign_in_as(@user) + end + + test "clicking export all heartbeats enqueues job and shows notice" do + visit my_settings_data_path + + assert_text "Export all heartbeats" + + assert_difference -> { export_job_count }, 1 do + click_on "Export all heartbeats" + assert_text "Your export is being prepared and will be emailed to you" + end + + assert_latest_export_job_kwargs( + "all_data" => true + ) + end + + test "submitting export date range enqueues job and shows notice" do + visit my_settings_data_path + + start_date = 7.days.ago.to_date.iso8601 + end_date = Date.current.iso8601 + set_date_input("start_date", start_date) + set_date_input("end_date", end_date) + + assert_difference -> { export_job_count }, 1 do + click_on "Export date range" + assert_text "Your export is being prepared and will be emailed to you" + end + + assert_latest_export_job_kwargs( + "all_data" => false, + "start_date" => start_date, + "end_date" => end_date + ) + end + + test "export is not available for restricted users" do + @user.update!(trust_level: :red) + visit my_settings_data_path + + assert_text "Data export is currently restricted for this account." + end + + test "export request is rejected when signed-in user has no email address" do + user_without_email = users(:three) + create_heartbeat(user_without_email, Time.current - 1.hour, "src/no_email.rb") + + sign_in_as(user_without_email) + visit my_settings_data_path + + assert_difference -> { export_job_count }, 0 do + click_on "Export all heartbeats" + assert_text "You need an email address on your account to export heartbeats." + end + end + + private + + def export_job_count + export_jobs.count + end + + def export_jobs + GoodJob::Job.where(job_class: "HeartbeatExportJob").order(created_at: :asc) + end + + def latest_export_job + export_jobs.last + end + + def latest_export_job_kwargs + serialized_params = latest_export_job.serialized_params + args = serialized_params.fetch("arguments") + kwargs = args.second || {} + kwargs.except("_aj_ruby2_keywords") + end + + def assert_latest_export_job_kwargs(expected_kwargs) + assert_equal expected_kwargs, latest_export_job_kwargs + end + + def create_heartbeat(user, at_time, entity) + user.heartbeats.create!( + entity: entity, + type: "file", + category: "coding", + time: at_time.to_f, + project: "export-test", + source_type: :test_entry + ) + end + + def set_date_input(field_name, value) + execute_script(<<~JS, field_name, value) + const input = document.querySelector(`input[name="${arguments[0]}"]`); + input.value = arguments[1]; + input.dispatchEvent(new Event("input", { bubbles: true })); + input.dispatchEvent(new Event("change", { bubbles: true })); + JS + end +end diff --git a/test/system/profiles_test.rb b/test/system/profiles_test.rb new file mode 100644 index 0000000..54cdc92 --- /dev/null +++ b/test/system/profiles_test.rb @@ -0,0 +1,15 @@ +require "application_system_test_case" + +class ProfilesTest < ApplicationSystemTestCase + test "public profile renders visible bio" do + user = User.create!( + username: "prof_#{SecureRandom.hex(4)}", + profile_bio: "Profile bio from system test", + allow_public_stats_lookup: true + ) + + visit profile_path(user.username) + + assert_text "Profile bio from system test" + end +end diff --git a/test/system/projects_test.rb b/test/system/projects_test.rb new file mode 100644 index 0000000..35d6995 --- /dev/null +++ b/test/system/projects_test.rb @@ -0,0 +1,79 @@ +require "application_system_test_case" + +class ProjectsTest < ApplicationSystemTestCase + setup do + @user = User.create!(timezone: "UTC") + sign_in_as(@user) + end + + test "shows active projects by default and archived projects when toggled" do + create_project_heartbeats(@user, "active-project", started_at: 2.days.ago.noon) + + archived_mapping = @user.project_repo_mappings.create!(project_name: "archived-project") + archived_mapping.archive! + create_project_heartbeats(@user, "archived-project", started_at: 2.days.ago.change(hour: 14)) + + visit my_projects_path + + assert_text "active-project" + assert_no_text "archived-project" + + click_on "Archived" + assert_text "archived-project" + assert_no_text "active-project" + + click_on "Active" + assert_text "active-project" + assert_no_text "archived-project" + end + + test "filters projects by time period" do + create_project_heartbeats(@user, "recent-project", started_at: 2.days.ago.noon) + create_project_heartbeats(@user, "older-project", started_at: 20.days.ago.noon) + + last_7_days_path = my_projects_path(interval: "last_7_days") + assert_includes last_7_days_path, "interval=last_7_days" + visit last_7_days_path + assert_includes page.current_url, "interval=last_7_days" + assert_text "recent-project" + assert_no_text "older-project" + + last_30_days_path = my_projects_path(interval: "last_30_days") + assert_includes last_30_days_path, "interval=last_30_days" + visit last_30_days_path + assert_includes page.current_url, "interval=last_30_days" + assert_text "recent-project" + assert_text "older-project" + + from = 21.days.ago.to_date.iso8601 + to = 19.days.ago.to_date.iso8601 + + custom_path = my_projects_path(interval: "custom", from: from, to: to) + assert_includes custom_path, "interval=custom" + visit custom_path + assert_includes page.current_url, "interval=custom" + assert_text "older-project" + assert_no_text "recent-project" + end + + private + + def create_project_heartbeats(user, project_name, started_at:) + user.project_repo_mappings.find_or_create_by!(project_name: project_name) + + Heartbeat.create!( + user: user, + project: project_name, + category: "coding", + time: started_at.to_i, + source_type: :test_entry + ) + Heartbeat.create!( + user: user, + project: project_name, + category: "coding", + time: (started_at + 30.minutes).to_i, + source_type: :test_entry + ) + end +end diff --git a/test/system/settings/access_settings_test.rb b/test/system/settings/access_settings_test.rb new file mode 100644 index 0000000..0b27885 --- /dev/null +++ b/test/system/settings/access_settings_test.rb @@ -0,0 +1,66 @@ +require "application_system_test_case" +require_relative "test_helpers" + +class AccessSettingsTest < ApplicationSystemTestCase + include SettingsSystemTestHelpers + + setup do + @user = User.create!(timezone: "UTC") + @user.api_keys.create!(name: "Initial key") + sign_in_as(@user) + end + + test "access settings page renders key sections" do + assert_settings_page( + path: my_settings_access_path, + marker_text: "Time Tracking Setup" + ) + + assert_text "Extension Display" + assert_text "API Key" + assert_text "WakaTime Config File" + end + + test "access settings updates extension display style" do + visit my_settings_access_path + + choose_select_option("extension_type", "Clock emoji") + click_on "Save extension settings" + + assert_text "Settings updated successfully" + assert_equal "clock_emoji", @user.reload.hackatime_extension_text_type + end + + test "access settings rotate api key can be canceled" do + old_token = @user.api_keys.order(:id).last.token + + visit my_settings_access_path + click_on "Rotate API key" + assert_text "Rotate API key?" + + within_modal do + click_on "Cancel" + end + + assert_no_text(/New API key/i) + assert_equal old_token, @user.reload.api_keys.order(:id).last.token + end + + test "access settings rotates api key" do + old_token = @user.api_keys.order(:id).last.token + + visit my_settings_access_path + click_on "Rotate API key" + + within_modal do + click_on "Rotate key" + end + + assert_text(/New API key/i) + + new_token = @user.reload.api_keys.order(:id).last.token + refute_equal old_token, new_token + assert_equal 1, @user.api_keys.count + assert_text new_token + end +end diff --git a/test/system/settings/admin_settings_test.rb b/test/system/settings/admin_settings_test.rb new file mode 100644 index 0000000..0314b5e --- /dev/null +++ b/test/system/settings/admin_settings_test.rb @@ -0,0 +1,59 @@ +require "application_system_test_case" +require_relative "test_helpers" + +class AdminSettingsTest < ApplicationSystemTestCase + include SettingsSystemTestHelpers + + setup do + @user = User.create!(timezone: "UTC") + sign_in_as(@user) + end + + test "admin settings redirects non-admin users" do + visit my_settings_admin_path + + assert_current_path my_settings_profile_path, ignore_query: true + assert_text "You are not authorized to access this page" + end + + test "admin settings can add and delete mirror endpoint" do + @user.update!(admin_level: :admin) + + visit my_settings_admin_path + assert_text "WakaTime Mirrors" + + endpoint_url = "https://example-wakatime.invalid/api/v1" + + fill_in "Endpoint URL", with: endpoint_url + fill_in "WakaTime API Key", with: "mirror-key-#{SecureRandom.hex(8)}" + + assert_difference -> { @user.reload.wakatime_mirrors.count }, +1 do + click_on "Add mirror" + assert_text "WakaTime mirror added successfully" + end + + visit my_settings_admin_path + assert_text endpoint_url + + click_on "Delete" + within_modal do + click_on "Delete mirror" + end + + assert_text "WakaTime mirror removed successfully" + assert_equal 0, @user.reload.wakatime_mirrors.count + end + + test "admin settings rejects hackatime mirror endpoint" do + @user.update!(admin_level: :admin) + + visit my_settings_admin_path + + fill_in "Endpoint URL", with: "https://hackatime.hackclub.com/api/v1" + fill_in "WakaTime API Key", with: "mirror-key-#{SecureRandom.hex(8)}" + click_on "Add mirror" + + assert_text "cannot be hackatime.hackclub.com" + assert_equal 0, @user.reload.wakatime_mirrors.count + end +end diff --git a/test/system/settings/badges_settings_test.rb b/test/system/settings/badges_settings_test.rb new file mode 100644 index 0000000..4be8b6a --- /dev/null +++ b/test/system/settings/badges_settings_test.rb @@ -0,0 +1,29 @@ +require "application_system_test_case" +require_relative "test_helpers" + +class BadgesSettingsTest < ApplicationSystemTestCase + include SettingsSystemTestHelpers + + setup do + @user = User.create!(timezone: "UTC") + sign_in_as(@user) + end + + test "badges settings page renders key sections" do + assert_settings_page( + path: my_settings_badges_path, + marker_text: "Stats Badges" + ) + + assert_text "Markscribe Template" + assert_text "Activity Heatmap" + end + + test "badges settings updates general badge preview theme" do + visit my_settings_badges_path + + choose_select_option("badge_theme", "default") + + assert_text(/theme=default/i) + end +end diff --git a/test/system/settings/data_settings_test.rb b/test/system/settings/data_settings_test.rb new file mode 100644 index 0000000..499019a --- /dev/null +++ b/test/system/settings/data_settings_test.rb @@ -0,0 +1,44 @@ +require "application_system_test_case" +require_relative "test_helpers" + +class DataSettingsTest < ApplicationSystemTestCase + include SettingsSystemTestHelpers + + setup do + @user = User.create!(timezone: "UTC") + sign_in_as(@user) + end + + test "data settings page renders key sections" do + assert_settings_page( + path: my_settings_data_path, + marker_text: "Migration Assistant" + ) + + assert_text "Download Data" + assert_button "Export all heartbeats" + assert_button "Export date range" + assert_text "Account Deletion" + assert_button "Request deletion" + end + + test "data settings restricts exports for red trust users" do + @user.update!(trust_level: :red) + + visit my_settings_data_path + + assert_text "Data export is currently restricted for this account." + assert_no_button "Export all heartbeats" + assert_no_button "Export date range" + end + + test "data settings redirects to deletion page when request already exists" do + DeletionRequest.create_for_user!(@user) + + visit my_settings_data_path + + assert_current_path deletion_path, ignore_query: true + assert_text "Account Scheduled for Deletion" + assert_text "I changed my mind" + end +end diff --git a/test/system/settings/goals_settings_test.rb b/test/system/settings/goals_settings_test.rb new file mode 100644 index 0000000..02f1859 --- /dev/null +++ b/test/system/settings/goals_settings_test.rb @@ -0,0 +1,82 @@ +require "application_system_test_case" +require_relative "test_helpers" + +class GoalsSettingsTest < ApplicationSystemTestCase + include SettingsSystemTestHelpers + + setup do + @user = User.create!(timezone: "UTC") + sign_in_as(@user) + end + + test "goals settings page renders" do + assert_settings_page( + path: my_settings_goals_path, + marker_text: "Programming Goals" + ) + assert_text(/Active Goal/i) + end + + test "goals settings can create edit and delete goal" do + visit my_settings_goals_path + + assert_text(/0 Active Goals/i) + click_on "New goal" + + within_modal do + click_on "2h" + click_on "Create Goal" + end + + assert_text "Goal created." + assert_text(/1 Active Goal/i) + assert_text "Daily: 2h" + assert_equal 2.hours.to_i, @user.reload.goals.first.target_seconds + + click_on "Edit" + within_modal do + click_on "30m" + click_on "Update Goal" + end + + assert_text "Goal updated." + assert_text "Daily: 30m" + assert_equal 30.minutes.to_i, @user.reload.goals.first.target_seconds + + click_on "Delete" + assert_text "Goal deleted." + assert_text(/0 Active Goals/i) + assert_equal 0, @user.reload.goals.count + end + + test "goals settings rejects duplicate goal" do + @user.goals.create!(period: "day", target_seconds: 2.hours.to_i, languages: [], projects: []) + + visit my_settings_goals_path + click_on "New goal" + + within_modal do + click_on "2h" + click_on "Create Goal" + end + + assert_text "duplicate goal" + assert_equal 1, @user.reload.goals.count + end + + test "goals settings rejects creating more than five goals" do + 5.times do |index| + @user.goals.create!( + period: "day", + target_seconds: (index + 1).hours.to_i, + languages: [], + projects: [] + ) + end + + visit my_settings_goals_path + assert_text(/5 Active Goals/i) + assert_button "New goal", disabled: true + assert_equal 5, @user.reload.goals.count + end +end diff --git a/test/system/settings/integrations_settings_test.rb b/test/system/settings/integrations_settings_test.rb new file mode 100644 index 0000000..09702c5 --- /dev/null +++ b/test/system/settings/integrations_settings_test.rb @@ -0,0 +1,57 @@ +require "application_system_test_case" +require_relative "test_helpers" + +class IntegrationsSettingsTest < ApplicationSystemTestCase + include SettingsSystemTestHelpers + + setup do + @user = User.create!(timezone: "UTC") + sign_in_as(@user) + end + + test "integrations settings page renders key sections" do + assert_settings_page( + path: my_settings_integrations_path, + marker_text: "Slack Status Sync" + ) + + assert_text "Slack Channel Notifications" + assert_text "Connected GitHub Account" + assert_text "Email Addresses" + end + + test "integrations settings updates slack status sync preference" do + @user.update!(uses_slack_status: false) + + visit my_settings_integrations_path + + within("#user_slack_status") do + find("[role='checkbox']", wait: 10).click + end + + click_on "Save Slack settings" + + assert_text "Settings updated successfully" + assert_equal true, @user.reload.uses_slack_status + end + + test "integrations settings opens and cancels unlink github modal" do + @user.update!( + github_uid: "12345", + github_username: "octocat", + github_access_token: "github-token" + ) + + visit my_settings_integrations_path + assert_text "@octocat" + + click_on "Unlink GitHub" + within_modal do + assert_text "Unlink GitHub account?" + click_on "Cancel" + end + + assert_current_path my_settings_integrations_path, ignore_query: true + assert_text "@octocat" + end +end diff --git a/test/system/settings/profile_settings_test.rb b/test/system/settings/profile_settings_test.rb new file mode 100644 index 0000000..fddf42b --- /dev/null +++ b/test/system/settings/profile_settings_test.rb @@ -0,0 +1,78 @@ +require "application_system_test_case" +require_relative "test_helpers" + +class ProfileSettingsTest < ApplicationSystemTestCase + include SettingsSystemTestHelpers + + setup do + @user = User.create!(timezone: "UTC") + sign_in_as(@user) + end + + test "default settings route renders profile settings page" do + visit my_settings_path + + assert_current_path my_settings_path, ignore_query: true + assert_text "Settings" + assert_text "Region and Timezone" + end + + test "profile settings updates country and username" do + @user.update!(country_code: "CA", username: "old_name") + new_username = "settings_#{SecureRandom.hex(4)}" + country_name = ISO3166::Country["US"].common_name + + visit my_settings_profile_path + + choose_select_option("country_code", country_name) + click_on "Save region settings" + assert_text "Settings updated successfully" + assert_equal "US", @user.reload.country_code + + fill_in "Username", with: new_username + click_on "Save username" + assert_text "Settings updated successfully" + assert_equal new_username, @user.reload.username + end + + test "profile settings rejects invalid username" do + @user.update!(username: "good_name") + + visit my_settings_profile_path + fill_in "Username", with: "bad username!" + click_on "Save username" + + assert_current_path my_settings_profile_path, ignore_query: true + assert_text "Some changes could not be saved:" + assert_text "Username may only include letters, numbers, '-', and '_'" + assert_equal "good_name", @user.reload.username + end + + test "profile settings updates privacy option" do + @user.update!(allow_public_stats_lookup: false) + + visit my_settings_profile_path + + within("#user_privacy") do + find("[role='checkbox']").click + click_on "Save privacy settings" + end + + assert_text "Settings updated successfully" + assert_equal true, @user.reload.allow_public_stats_lookup + end + + test "profile settings updates theme" do + @user.update!(theme: :gruvbox_dark) + + visit my_settings_profile_path + + within("#user_theme") do + click_on "Neon" + click_on "Save theme" + end + + assert_text "Settings updated successfully" + assert_equal "neon", @user.reload.theme + end +end diff --git a/test/system/settings/test_helpers.rb b/test/system/settings/test_helpers.rb new file mode 100644 index 0000000..2934951 --- /dev/null +++ b/test/system/settings/test_helpers.rb @@ -0,0 +1,24 @@ +module SettingsSystemTestHelpers + private + + def assert_settings_page(path:, marker_text:) + visit path + + assert_current_path path, ignore_query: true + assert_text "Settings" + assert_text marker_text + end + + def choose_select_option(select_id, option_text) + find("##{select_id}").click + assert_selector ".dashboard-select-popover" + + within ".dashboard-select-popover" do + find("[role='option']", text: option_text, match: :first).click + end + end + + def within_modal(&) + within ".bits-modal-content", & + end +end diff --git a/test/test_helper.rb b/test/test_helper.rb index 72fdbed..75a9bc4 100644 --- a/test/test_helper.rb +++ b/test/test_helper.rb @@ -17,6 +17,8 @@ module ActiveSupport "physical_mails", "api_keys", "heartbeats", + "users", + "email_addresses", "project_repo_mappings", "repositories", "sailors_log_leaderboards", @@ -29,6 +31,21 @@ module ActiveSupport end end +module SystemTestAuthHelper + def sign_in_as(user) + token = user.sign_in_tokens.create!(auth_type: :email) + visit auth_token_path(token: token.token) + end +end + +module IntegrationTestAuthHelper + def sign_in_as(user) + token = user.sign_in_tokens.create!(auth_type: :email) + get auth_token_path(token: token.token) + assert_equal user.id, session[:user_id] + end +end + module InertiaTestHelper def inertia_page document = Nokogiri::HTML(response.body) @@ -56,5 +73,6 @@ module InertiaTestHelper end class ActionDispatch::IntegrationTest + include IntegrationTestAuthHelper include InertiaTestHelper end