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
This commit is contained in:
Mahad Kalam 2026-02-21 11:28:21 +00:00 committed by GitHub
parent ef50839744
commit 44777ad644
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
67 changed files with 1674 additions and 371 deletions

View file

@ -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:

View file

@ -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

97
DEVELOPMENT.md Normal file
View file

@ -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 <http://localhost:3000/>, 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.

View file

@ -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

View file

@ -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 <http://localhost:3000/>
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.

View file

@ -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
HeartbeatExportJob.perform_later(current_user.id, all_data: true)
else
start_date = Date.current
end_date = Date.current
end
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

View file

@ -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)

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { usePoll } from "@inertiajs/svelte";
import { usePoll, Form } from "@inertiajs/svelte";
import { onMount } from "svelte";
import { tweened } from "svelte/motion";
import { cubicOut } from "svelte/easing";
@ -320,16 +320,29 @@
</div>
</div>
<div class="mt-4 space-y-3">
<Button href={paths.export_all_heartbeats_path} class="rounded-md">
Export all heartbeats
</Button>
<p class="mt-2 text-sm text-muted">
Exports are generated in the background and emailed to you.
</p>
<form
method="get"
<div class="mt-4 space-y-3">
<Form method="post" action={paths.export_all_heartbeats_path}>
{#snippet children({ processing })}
<Button
type="submit"
class="rounded-md cursor-default"
disabled={processing}
>
{processing ? "Exporting..." : "Export all heartbeats"}
</Button>
{/snippet}
</Form>
<Form
method="post"
action={paths.export_range_heartbeats_path}
class="grid grid-cols-1 gap-3 rounded-md border border-surface-200 bg-darker p-4 sm:grid-cols-3"
>
{#snippet children({ processing })}
<input
type="date"
name="start_date"
@ -342,10 +355,16 @@
required
class="rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
/>
<Button type="submit" variant="surface" class="rounded-md">
Export date range
<Button
type="submit"
variant="surface"
class="rounded-md"
disabled={processing}
>
{processing ? "Exporting..." : "Export date range"}
</Button>
</form>
{/snippet}
</Form>
</div>
{#if ui.show_dev_import}

View file

@ -0,0 +1,95 @@
class HeartbeatExportJob < ApplicationJob
queue_as :default
def perform(user_id, all_data:, start_date: nil, end_date: nil)
user = User.find_by(id: user_id)
return if user.nil?
recipient_email = user.email_addresses.order(:id).pick(:email)
unless recipient_email.present?
Rails.logger.warn("Skipping heartbeat export for user #{user.id}: no email address found")
return
end
if all_data
heartbeats = user.heartbeats.order(time: :asc)
first_time, last_time = user.heartbeats.pick(Arel.sql("MIN(time), MAX(time)"))
if first_time && last_time
start_date = Time.at(first_time).to_date
end_date = Time.at(last_time).to_date
else
start_date = Date.current
end_date = Date.current
end
else
start_date = Date.iso8601(start_date)
end_date = Date.iso8601(end_date)
start_time = start_date.beginning_of_day.to_f
end_time = end_date.end_of_day.to_f
heartbeats = user.heartbeats
.where("time >= ? AND time <= ?", start_time, end_time)
.order(time: :asc)
end
export_data = build_export_data(heartbeats, start_date, end_date)
user_identifier = user.slack_uid.presence || "user_#{user.id}"
filename = "heartbeats_#{user_identifier}_#{start_date.strftime("%Y%m%d")}_#{end_date.strftime("%Y%m%d")}.json"
Tempfile.create([ "heartbeat_export", ".json" ]) do |file|
file.write(export_data.to_json)
file.rewind
HeartbeatExportMailer.export_ready(
user,
recipient_email: recipient_email,
file_path: file.path,
filename: filename
).deliver_now
end
rescue ArgumentError => e
Rails.logger.error("Heartbeat export failed for user #{user_id}: #{e.message}")
end
private
def build_export_data(heartbeats, start_date, end_date)
{
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
}
end
end

View file

@ -0,0 +1,14 @@
class HeartbeatExportMailer < ApplicationMailer
def export_ready(user, recipient_email:, file_path:, filename:)
@user = user
attachments[filename] = {
mime_type: "application/json",
content: File.binread(file_path)
}
mail(
to: recipient_email,
subject: "Your Hackatime heartbeat export is ready"
)
end
end

View file

@ -0,0 +1,4 @@
<h1>Your heartbeat export is ready</h1>
<p>Hi <%= h(@user.display_name) %>,</p>
<p>Your Hackatime heartbeat export has been generated and is attached to this email.</p>
<p>If you didn't request this export, you can safely ignore this email.</p>

View file

@ -0,0 +1,7 @@
Your heartbeat export is ready
Hi <%= h(@user.display_name) %>,
Your Hackatime heartbeat export has been generated and is attached to this email.
If you didn't request this export, you can safely ignore this email.

View file

@ -38,14 +38,18 @@
<meta name="user-is-viewer" content="<%= current_user.admin_level == 'viewer' %>">
<% end %>
<% include_external_scripts = !Rails.env.test? %>
<%= yield :head %>
<% if include_external_scripts %>
<!-- Lets users record their screen from your site -->
<meta name="jam:team" content="a5978e52-2479-4dd3-9883-593aa7a4f121">
<script type="module" src="https://js.jam.dev/recorder.js"></script>
<!-- Captures user events and developer logs -->
<script type="module" src="https://js.jam.dev/capture.js"></script>
<% end %>
<script type="application/ld+json">
{
@ -146,16 +150,19 @@
<%= favicon_link_tag asset_path('favicon.png'), type: 'image/png' %>
<% if include_external_scripts %>
<script defer data-domain="hackatime.hackclub.com" src="https://plausible.io/js/script.file-downloads.hash.js"></script>
<% 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 %>

7
bin/ci Executable file
View file

@ -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"

26
config/ci.rb Normal file
View file

@ -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

View file

@ -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"]

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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!

View file

@ -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

View file

@ -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

View file

@ -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:

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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,13 +75,38 @@ 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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

2
test/fixtures/admin_api_keys.yml vendored Normal file
View file

@ -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.

View file

@ -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

9
test/fixtures/email_addresses.yml vendored Normal file
View file

@ -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

View file

@ -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

2
test/fixtures/oauth_access_grants.yml vendored Normal file
View file

@ -0,0 +1,2 @@
# Keep this fixture file present so Rails truncates oauth_access_grants
# during fixture setup when user fixtures are loaded.

2
test/fixtures/oauth_access_tokens.yml vendored Normal file
View file

@ -0,0 +1,2 @@
# Keep this fixture file present so Rails truncates oauth_access_tokens
# during fixture setup when user fixtures are loaded.

2
test/fixtures/sign_in_tokens.yml vendored Normal file
View file

@ -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.

14
test/fixtures/users.yml vendored Normal file
View file

@ -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"

View file

@ -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

View file

@ -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

View file

@ -1,7 +0,0 @@
require "test_helper"
class ApiKeyTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -1,7 +0,0 @@
require "test_helper"
class SailorsLogLeaderboardTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -1,7 +0,0 @@
require "test_helper"
class SailorsLogNotificationPreferenceTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -1,7 +0,0 @@
require "test_helper"
class SailorsLogSlackNotificationTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -1,7 +0,0 @@
require "test_helper"
class SailorsLogTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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