From 44777ad644d70eb463495da2efd6dc9188c3b446 Mon Sep 17 00:00:00 2001
From: Mahad Kalam <55807755+skyfallwastaken@users.noreply.github.com>
Date: Sat, 21 Feb 2026 11:28:21 +0000
Subject: [PATCH] Data export fix + async exports + more tests (#989)
* Fix data export + Capybara
* Continue?
* A ton of system tests :D + test cleanup
* More system tests
* Add placeholder keys for tests?
* Get rid of the double-query!
* Speed up CI Chrome setup by avoiding snap installs
* Pin CI Chrome version to reduce system test flakiness
* Stabilize integrations settings system test interaction
---
.github/workflows/ci.yml | 10 ++
AGENTS.md | 15 +--
DEVELOPMENT.md | 97 ++++++++++++++
Dockerfile.dev | 2 +
README.md | 53 +-------
app/controllers/my/heartbeats_controller.rb | 110 +++++++---------
app/controllers/settings/base_controller.rb | 4 +-
.../pages/Users/Settings/Data.svelte | 65 ++++++----
app/jobs/heartbeat_export_job.rb | 95 ++++++++++++++
app/mailers/heartbeat_export_mailer.rb | 14 ++
.../export_ready.html.erb | 4 +
.../export_ready.text.erb | 7 +
app/views/layouts/inertia.html.erb | 23 ++--
bin/ci | 7 +
config/ci.rb | 26 ++++
.../initializers/active_record_encryption.rb | 6 +
config/routes.rb | 9 +-
.../api/admin/v1/admin_user_utils_spec.rb | 8 +-
spec/requests/api/v1/authenticated_spec.rb | 6 +-
spec/requests/api/v1/my_spec.rb | 14 +-
spec/requests/api/v1/stats_spec.rb | 9 +-
spec/requests/api/v1/users_spec.rb | 7 +-
swagger/v1/swagger.yaml | 10 +-
test/application_system_test_case.rb | 25 +++-
.../authorizations_controller_test.rb | 6 -
test/controllers/docs_controller_test.rb | 4 -
.../applications_controller_test.rb | 6 -
.../leaderboards_controller_test.rb | 6 -
.../my/heartbeat_imports_controller_test.rb | 47 +++++--
.../my/heartbeats_controller_test.rb | 47 +++++++
.../project_repo_mappings_controller_test.rb | 64 ++--------
test/controllers/profiles_controller_test.rb | 8 --
test/controllers/sessions_controller_test.rb | 79 +++++++++++-
.../settings_goals_controller_test.rb | 75 +++++++++--
.../settings_profile_controller_test.rb | 28 +++-
test/fixtures/admin_api_keys.yml | 2 +
test/fixtures/api_keys.yml | 4 +-
test/fixtures/email_addresses.yml | 9 ++
test/fixtures/heartbeats.yml | 58 +++++----
test/fixtures/oauth_access_grants.yml | 2 +
test/fixtures/oauth_access_tokens.yml | 2 +
test/fixtures/sign_in_tokens.yml | 2 +
test/fixtures/users.yml | 14 ++
test/jobs/heartbeat_export_job_test.rb | 120 ++++++++++++++++++
test/mailers/heartbeat_export_mailer_test.rb | 39 ++++++
test/models/api_key_test.rb | 7 -
test/models/heartbeat_test.rb | 25 +++-
test/models/project_repo_mapping_test.rb | 25 +++-
test/models/repository_test.rb | 16 ++-
test/models/sailors_log_leaderboard_test.rb | 7 -
...ailors_log_notification_preference_test.rb | 7 -
.../sailors_log_slack_notification_test.rb | 7 -
test/models/sailors_log_test.rb | 7 -
test/models/user_test.rb | 2 +-
test/services/anonymize_user_service_test.rb | 32 ++++-
test/system/heartbeat_export_test.rb | 111 ++++++++++++++++
test/system/profiles_test.rb | 15 +++
test/system/projects_test.rb | 79 ++++++++++++
test/system/settings/access_settings_test.rb | 66 ++++++++++
test/system/settings/admin_settings_test.rb | 59 +++++++++
test/system/settings/badges_settings_test.rb | 29 +++++
test/system/settings/data_settings_test.rb | 44 +++++++
test/system/settings/goals_settings_test.rb | 82 ++++++++++++
.../settings/integrations_settings_test.rb | 57 +++++++++
test/system/settings/profile_settings_test.rb | 78 ++++++++++++
test/system/settings/test_helpers.rb | 24 ++++
test/test_helper.rb | 18 +++
67 files changed, 1674 insertions(+), 371 deletions(-)
create mode 100644 DEVELOPMENT.md
create mode 100644 app/jobs/heartbeat_export_job.rb
create mode 100644 app/mailers/heartbeat_export_mailer.rb
create mode 100644 app/views/heartbeat_export_mailer/export_ready.html.erb
create mode 100644 app/views/heartbeat_export_mailer/export_ready.text.erb
create mode 100755 bin/ci
create mode 100644 config/ci.rb
create mode 100644 test/controllers/my/heartbeats_controller_test.rb
create mode 100644 test/fixtures/admin_api_keys.yml
create mode 100644 test/fixtures/email_addresses.yml
create mode 100644 test/fixtures/oauth_access_grants.yml
create mode 100644 test/fixtures/oauth_access_tokens.yml
create mode 100644 test/fixtures/sign_in_tokens.yml
create mode 100644 test/fixtures/users.yml
create mode 100644 test/jobs/heartbeat_export_job_test.rb
create mode 100644 test/mailers/heartbeat_export_mailer_test.rb
delete mode 100644 test/models/api_key_test.rb
delete mode 100644 test/models/sailors_log_leaderboard_test.rb
delete mode 100644 test/models/sailors_log_notification_preference_test.rb
delete mode 100644 test/models/sailors_log_slack_notification_test.rb
delete mode 100644 test/models/sailors_log_test.rb
create mode 100644 test/system/heartbeat_export_test.rb
create mode 100644 test/system/profiles_test.rb
create mode 100644 test/system/projects_test.rb
create mode 100644 test/system/settings/access_settings_test.rb
create mode 100644 test/system/settings/admin_settings_test.rb
create mode 100644 test/system/settings/badges_settings_test.rb
create mode 100644 test/system/settings/data_settings_test.rb
create mode 100644 test/system/settings/goals_settings_test.rb
create mode 100644 test/system/settings/integrations_settings_test.rb
create mode 100644 test/system/settings/profile_settings_test.rb
create mode 100644 test/system/settings/test_helpers.rb
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