mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 18:45:21 +00:00
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:
parent
ef50839744
commit
44777ad644
67 changed files with 1674 additions and 371 deletions
10
.github/workflows/ci.yml
vendored
10
.github/workflows/ci.yml
vendored
|
|
@ -128,6 +128,13 @@ jobs:
|
||||||
- name: Set up Bun
|
- name: Set up Bun
|
||||||
uses: oven-sh/setup-bun@v2
|
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
|
- name: Install JavaScript dependencies
|
||||||
run: bun install --frozen-lockfile
|
run: bun install --frozen-lockfile
|
||||||
|
|
||||||
|
|
@ -143,10 +150,13 @@ jobs:
|
||||||
PGHOST: localhost
|
PGHOST: localhost
|
||||||
PGUSER: postgres
|
PGUSER: postgres
|
||||||
PGPASSWORD: postgres
|
PGPASSWORD: postgres
|
||||||
|
CHROME_BIN: ${{ steps.setup-chrome.outputs.chrome-path }}
|
||||||
|
CHROMEDRIVER_BIN: ${{ steps.setup-chrome.outputs.chromedriver-path }}
|
||||||
run: |
|
run: |
|
||||||
bin/rails db:create RAILS_ENV=test
|
bin/rails db:create RAILS_ENV=test
|
||||||
bin/rails db:schema:load RAILS_ENV=test
|
bin/rails db:schema:load RAILS_ENV=test
|
||||||
bin/rails test
|
bin/rails test
|
||||||
|
bin/rails test:system
|
||||||
|
|
||||||
- name: Ensure Swagger docs are up to date
|
- name: Ensure Swagger docs are up to date
|
||||||
env:
|
env:
|
||||||
|
|
|
||||||
15
AGENTS.md
15
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.
|
**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
|
## 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)
|
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.
|
||||||
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)
|
|
||||||
|
|
||||||
## API Documentation
|
## API Documentation
|
||||||
|
|
||||||
|
|
|
||||||
97
DEVELOPMENT.md
Normal file
97
DEVELOPMENT.md
Normal 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.
|
||||||
|
|
@ -14,6 +14,8 @@ RUN apt-get update -qq && \
|
||||||
vim \
|
vim \
|
||||||
nodejs \
|
nodejs \
|
||||||
npm \
|
npm \
|
||||||
|
chromium \
|
||||||
|
chromium-driver \
|
||||||
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives
|
&& rm -rf /var/lib/apt/lists/* /var/cache/apt/archives
|
||||||
|
|
||||||
# Install Bun
|
# Install Bun
|
||||||
|
|
|
||||||
53
README.md
53
README.md
|
|
@ -13,55 +13,4 @@
|
||||||
|
|
||||||
## Local development
|
## Local development
|
||||||
|
|
||||||
```sh
|
Please read [DEVELOPMENT.md](DEVELOPMENT.md) for instructions on setting up and running the project locally.
|
||||||
# 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
|
|
||||||
```
|
|
||||||
|
|
|
||||||
|
|
@ -4,76 +4,28 @@ module My
|
||||||
before_action :ensure_no_ban, only: [ :export ]
|
before_action :ensure_no_ban, only: [ :export ]
|
||||||
|
|
||||||
def 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"
|
all_data = params[:all_data] == "true"
|
||||||
|
|
||||||
if all_data
|
if all_data
|
||||||
heartbeats = current_user.heartbeats.order(time: :asc)
|
HeartbeatExportJob.perform_later(current_user.id, all_data: true)
|
||||||
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
|
|
||||||
else
|
else
|
||||||
start_date = params[:start_date].present? ? Date.parse(params[:start_date]) : 30.days.ago.to_date
|
date_range = export_date_range_from_params
|
||||||
end_date = params[:end_date].present? ? Date.parse(params[:end_date]) : Date.current
|
return if date_range.nil?
|
||||||
start_time = start_date.beginning_of_day.to_f
|
|
||||||
end_time = end_date.end_of_day.to_f
|
|
||||||
|
|
||||||
heartbeats = current_user.heartbeats
|
HeartbeatExportJob.perform_later(
|
||||||
.where("time >= ? AND time <= ?", start_time, end_time)
|
current_user.id,
|
||||||
.order(time: :asc)
|
all_data: false,
|
||||||
|
start_date: date_range[:start_date].iso8601,
|
||||||
|
end_date: date_range[:end_date].iso8601
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
redirect_to my_settings_data_path, notice: "Your export is being prepared and will be emailed to you."
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def import
|
def import
|
||||||
|
|
@ -137,5 +89,35 @@ module My
|
||||||
redirect_to my_settings_path, alert: "Sorry, you are not permitted to this action."
|
redirect_to my_settings_path, alert: "Sorry, you are not permitted to this action."
|
||||||
end
|
end
|
||||||
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -123,8 +123,8 @@ class Settings::BaseController < InertiaController
|
||||||
unlink_email_path: unlink_email_auth_path,
|
unlink_email_path: unlink_email_auth_path,
|
||||||
rotate_api_key_path: my_settings_rotate_api_key_path,
|
rotate_api_key_path: my_settings_rotate_api_key_path,
|
||||||
migrate_heartbeats_path: my_settings_migrate_heartbeats_path,
|
migrate_heartbeats_path: my_settings_migrate_heartbeats_path,
|
||||||
export_all_heartbeats_path: export_my_heartbeats_path(format: :json, all_data: "true"),
|
export_all_heartbeats_path: export_my_heartbeats_path(all_data: "true"),
|
||||||
export_range_heartbeats_path: export_my_heartbeats_path(format: :json),
|
export_range_heartbeats_path: export_my_heartbeats_path,
|
||||||
create_heartbeat_import_path: my_heartbeat_imports_path,
|
create_heartbeat_import_path: my_heartbeat_imports_path,
|
||||||
create_deletion_path: create_deletion_path,
|
create_deletion_path: create_deletion_path,
|
||||||
user_wakatime_mirrors_path: user_wakatime_mirrors_path(current_user)
|
user_wakatime_mirrors_path: user_wakatime_mirrors_path(current_user)
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { usePoll } from "@inertiajs/svelte";
|
import { usePoll, Form } from "@inertiajs/svelte";
|
||||||
import { onMount } from "svelte";
|
import { onMount } from "svelte";
|
||||||
import { tweened } from "svelte/motion";
|
import { tweened } from "svelte/motion";
|
||||||
import { cubicOut } from "svelte/easing";
|
import { cubicOut } from "svelte/easing";
|
||||||
|
|
@ -320,32 +320,51 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mt-4 space-y-3">
|
<p class="mt-2 text-sm text-muted">
|
||||||
<Button href={paths.export_all_heartbeats_path} class="rounded-md">
|
Exports are generated in the background and emailed to you.
|
||||||
Export all heartbeats
|
</p>
|
||||||
</Button>
|
|
||||||
|
|
||||||
<form
|
<div class="mt-4 space-y-3">
|
||||||
method="get"
|
<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}
|
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"
|
class="grid grid-cols-1 gap-3 rounded-md border border-surface-200 bg-darker p-4 sm:grid-cols-3"
|
||||||
>
|
>
|
||||||
<input
|
{#snippet children({ processing })}
|
||||||
type="date"
|
<input
|
||||||
name="start_date"
|
type="date"
|
||||||
required
|
name="start_date"
|
||||||
class="rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
|
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"
|
||||||
<input
|
/>
|
||||||
type="date"
|
<input
|
||||||
name="end_date"
|
type="date"
|
||||||
required
|
name="end_date"
|
||||||
class="rounded-md border border-surface-200 bg-surface px-3 py-2 text-sm text-surface-content focus:border-primary focus:outline-none"
|
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
|
||||||
</Button>
|
type="submit"
|
||||||
</form>
|
variant="surface"
|
||||||
|
class="rounded-md"
|
||||||
|
disabled={processing}
|
||||||
|
>
|
||||||
|
{processing ? "Exporting..." : "Export date range"}
|
||||||
|
</Button>
|
||||||
|
{/snippet}
|
||||||
|
</Form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if ui.show_dev_import}
|
{#if ui.show_dev_import}
|
||||||
|
|
|
||||||
95
app/jobs/heartbeat_export_job.rb
Normal file
95
app/jobs/heartbeat_export_job.rb
Normal 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
|
||||||
14
app/mailers/heartbeat_export_mailer.rb
Normal file
14
app/mailers/heartbeat_export_mailer.rb
Normal 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
|
||||||
4
app/views/heartbeat_export_mailer/export_ready.html.erb
Normal file
4
app/views/heartbeat_export_mailer/export_ready.html.erb
Normal 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>
|
||||||
7
app/views/heartbeat_export_mailer/export_ready.text.erb
Normal file
7
app/views/heartbeat_export_mailer/export_ready.text.erb
Normal 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.
|
||||||
|
|
@ -38,14 +38,18 @@
|
||||||
<meta name="user-is-viewer" content="<%= current_user.admin_level == 'viewer' %>">
|
<meta name="user-is-viewer" content="<%= current_user.admin_level == 'viewer' %>">
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
|
<% include_external_scripts = !Rails.env.test? %>
|
||||||
|
|
||||||
<%= yield :head %>
|
<%= yield :head %>
|
||||||
|
|
||||||
<!-- Lets users record their screen from your site -->
|
<% if include_external_scripts %>
|
||||||
<meta name="jam:team" content="a5978e52-2479-4dd3-9883-593aa7a4f121">
|
<!-- Lets users record their screen from your site -->
|
||||||
<script type="module" src="https://js.jam.dev/recorder.js"></script>
|
<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 -->
|
<!-- Captures user events and developer logs -->
|
||||||
<script type="module" src="https://js.jam.dev/capture.js"></script>
|
<script type="module" src="https://js.jam.dev/capture.js"></script>
|
||||||
|
<% end %>
|
||||||
|
|
||||||
<script type="application/ld+json">
|
<script type="application/ld+json">
|
||||||
{
|
{
|
||||||
|
|
@ -146,16 +150,19 @@
|
||||||
|
|
||||||
<%= favicon_link_tag asset_path('favicon.png'), type: 'image/png' %>
|
<%= favicon_link_tag asset_path('favicon.png'), type: 'image/png' %>
|
||||||
|
|
||||||
<script defer data-domain="hackatime.hackclub.com" src="https://plausible.io/js/script.file-downloads.hash.js"></script>
|
<% 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 %>
|
<% if Sentry.get_trace_propagation_meta %>
|
||||||
<%= sanitize Sentry.get_trace_propagation_meta, tags: %w[meta], attributes: %w[name content] %>
|
<%= sanitize Sentry.get_trace_propagation_meta, tags: %w[meta], attributes: %w[name content] %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<% unless Rails.env.test? %>
|
<% include_vite_assets = !Rails.env.test? || ENV["INERTIA_SYSTEM_TEST"] == "1" %>
|
||||||
|
<% if include_vite_assets %>
|
||||||
<%= vite_stylesheet_tag "application" %>
|
<%= vite_stylesheet_tag "application" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
<%= stylesheet_link_tag 'tailwind', 'data-turbo-track': 'reload' %>
|
<%= stylesheet_link_tag 'tailwind', 'data-turbo-track': 'reload' %>
|
||||||
<% unless Rails.env.test? %>
|
<% if include_vite_assets %>
|
||||||
<%= vite_client_tag %>
|
<%= vite_client_tag %>
|
||||||
<%= vite_typescript_tag "inertia" %>
|
<%= vite_typescript_tag "inertia" %>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
|
||||||
7
bin/ci
Executable file
7
bin/ci
Executable 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
26
config/ci.rb
Normal 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
|
||||||
|
|
@ -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.primary_key = ENV["ENCRYPTION_PRIMARY_KEY"]
|
||||||
Rails.application.config.active_record.encryption.deterministic_key = ENV["ENCRYPTION_DETERMINISTIC_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"]
|
Rails.application.config.active_record.encryption.key_derivation_salt = ENV["ENCRYPTION_KEY_DERIVATION_SALT"]
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,12 @@ end
|
||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
# Redirect to localhost from 127.0.0.1 to use same IP address with Vite server
|
# Redirect to localhost from 127.0.0.1 to use same IP address with Vite server
|
||||||
constraints(host: "127.0.0.1") do
|
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
|
end
|
||||||
|
|
||||||
mount Rswag::Api::Engine => "/api-docs"
|
mount Rswag::Api::Engine => "/api-docs"
|
||||||
|
|
@ -165,7 +170,7 @@ Rails.application.routes.draw do
|
||||||
# get "mailroom", to: "mailroom#index"
|
# get "mailroom", to: "mailroom#index"
|
||||||
resources :heartbeats, only: [] do
|
resources :heartbeats, only: [] do
|
||||||
collection do
|
collection do
|
||||||
get :export
|
post :export
|
||||||
post :import
|
post :import
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -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
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -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
|
end
|
||||||
|
|
||||||
response(401, 'unauthorized') do
|
response(401, 'unauthorized') do
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ RSpec.describe 'Api::V1::My', type: :request do
|
||||||
end
|
end
|
||||||
|
|
||||||
path '/my/heartbeats/export' do
|
path '/my/heartbeats/export' do
|
||||||
get('Export Heartbeats') do
|
post('Export Heartbeats') do
|
||||||
tags 'My Data'
|
tags 'My Data'
|
||||||
description 'Export your heartbeats as a JSON file.'
|
description 'Export your heartbeats as a JSON file.'
|
||||||
security [ Bearer: [], ApiKeyAuth: [] ]
|
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: :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)'
|
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(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
let(:api_key) { 'dev-api-key-12345' }
|
let(:api_key) { 'dev-api-key-12345' }
|
||||||
let(:all_data) { true }
|
let(:all_data) { true }
|
||||||
|
|
@ -114,11 +114,15 @@ RSpec.describe 'Api::V1::My', type: :request do
|
||||||
response(302, 'redirect') do
|
response(302, 'redirect') do
|
||||||
let(:Authorization) { "Bearer dev-api-key-12345" }
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
let(:api_key) { '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
|
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
|
login_browser_user
|
||||||
end
|
end
|
||||||
run_test!
|
run_test!
|
||||||
|
|
|
||||||
|
|
@ -22,10 +22,13 @@ RSpec.describe 'Api::V1::Stats', type: :request do
|
||||||
let(:username) { nil }
|
let(:username) { nil }
|
||||||
let(:user_email) { nil }
|
let(:user_email) { nil }
|
||||||
schema type: :integer, example: 123456
|
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
|
end
|
||||||
|
|
||||||
response(401, 'unauthorized') do
|
response(200, 'successful with invalid credentials') do
|
||||||
before { ENV['STATS_API_KEY'] = 'dev-api-key-12345' }
|
before { ENV['STATS_API_KEY'] = 'dev-api-key-12345' }
|
||||||
let(:Authorization) { 'Bearer invalid_token' }
|
let(:Authorization) { 'Bearer invalid_token' }
|
||||||
let(:api_key) { 'invalid' }
|
let(:api_key) { 'invalid' }
|
||||||
|
|
@ -35,7 +38,7 @@ RSpec.describe 'Api::V1::Stats', type: :request do
|
||||||
let(:user_email) { nil }
|
let(:user_email) { nil }
|
||||||
|
|
||||||
run_test! do |response|
|
run_test! do |response|
|
||||||
expect(response.status).to eq(401)
|
expect(response.status).to eq(200)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,12 @@ RSpec.describe 'Api::V1::Users', type: :request do
|
||||||
user_id: { type: :integer, example: 42 },
|
user_id: { type: :integer, example: 42 },
|
||||||
email: { type: :string, example: 'test@example.com' }
|
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
|
end
|
||||||
|
|
||||||
response(404, 'not found') do
|
response(404, 'not found') do
|
||||||
|
|
|
||||||
|
|
@ -2562,7 +2562,7 @@ paths:
|
||||||
'401':
|
'401':
|
||||||
description: unauthorized
|
description: unauthorized
|
||||||
"/my/heartbeats/export":
|
"/my/heartbeats/export":
|
||||||
get:
|
post:
|
||||||
summary: Export Heartbeats
|
summary: Export Heartbeats
|
||||||
tags:
|
tags:
|
||||||
- My Data
|
- My Data
|
||||||
|
|
@ -2589,8 +2589,8 @@ paths:
|
||||||
format: date
|
format: date
|
||||||
description: End date (YYYY-MM-DD)
|
description: End date (YYYY-MM-DD)
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'302':
|
||||||
description: successful
|
description: redirect
|
||||||
"/my/heartbeats/import":
|
"/my/heartbeats/import":
|
||||||
post:
|
post:
|
||||||
summary: Import Heartbeats
|
summary: Import Heartbeats
|
||||||
|
|
@ -2793,14 +2793,12 @@ paths:
|
||||||
type: string
|
type: string
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: successful
|
description: successful with invalid credentials
|
||||||
content:
|
content:
|
||||||
text/plain:
|
text/plain:
|
||||||
schema:
|
schema:
|
||||||
type: integer
|
type: integer
|
||||||
example: 123456
|
example: 123456
|
||||||
'401':
|
|
||||||
description: unauthorized
|
|
||||||
'404':
|
'404':
|
||||||
description: user not found
|
description: user not found
|
||||||
content:
|
content:
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,26 @@
|
||||||
|
ENV["INERTIA_SYSTEM_TEST"] = "1"
|
||||||
|
ENV["VITE_RUBY_AUTO_BUILD"] ||= "true"
|
||||||
|
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class ApplicationSystemTestCase < ActionDispatch::SystemTestCase
|
Capybara.register_driver :headless_chromium do |app|
|
||||||
driven_by :selenium, using: :headless_chrome, screen_size: [ 1400, 1400 ]
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -84,10 +84,4 @@ class CustomDoorkeeperAuthorizationsControllerTest < ActionDispatch::Integration
|
||||||
scope: "profile"
|
scope: "profile"
|
||||||
}
|
}
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -8,10 +8,6 @@ class DocsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_match %r{text/markdown}, response.content_type
|
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"))
|
expected_content = File.read(Rails.root.join("docs", "getting-started", "quick-start.md"))
|
||||||
assert_equal expected_content, response.body
|
assert_equal expected_content, response.body
|
||||||
|
|
|
||||||
|
|
@ -185,10 +185,4 @@ class Doorkeeper::ApplicationsControllerTest < ActionDispatch::IntegrationTest
|
||||||
def configured_scopes
|
def configured_scopes
|
||||||
Doorkeeper.configuration.default_scopes.to_a.join(" ")
|
Doorkeeper.configuration.default_scopes.to_a.join(" ")
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -84,10 +84,4 @@ class LeaderboardsControllerTest < ActionDispatch::IntegrationTest
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest
|
class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
fixtures :users
|
||||||
|
|
||||||
test "create rejects guests" do
|
test "create rejects guests" do
|
||||||
post my_heartbeat_imports_path
|
post my_heartbeat_imports_path
|
||||||
|
|
||||||
|
|
@ -9,7 +11,7 @@ class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create rejects non-development environment" do
|
test "create rejects non-development environment" do
|
||||||
user = User.create!(timezone: "UTC")
|
user = users(:one)
|
||||||
sign_in_as(user)
|
sign_in_as(user)
|
||||||
|
|
||||||
post my_heartbeat_imports_path
|
post my_heartbeat_imports_path
|
||||||
|
|
@ -19,7 +21,7 @@ class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest
|
||||||
end
|
end
|
||||||
|
|
||||||
test "show rejects non-development environment" do
|
test "show rejects non-development environment" do
|
||||||
user = User.create!(timezone: "UTC")
|
user = users(:one)
|
||||||
sign_in_as(user)
|
sign_in_as(user)
|
||||||
|
|
||||||
get my_heartbeat_import_path("import-123")
|
get my_heartbeat_import_path("import-123")
|
||||||
|
|
@ -29,7 +31,7 @@ class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create returns error when file is missing" do
|
test "create returns error when file is missing" do
|
||||||
user = User.create!(timezone: "UTC")
|
user = users(:one)
|
||||||
sign_in_as(user)
|
sign_in_as(user)
|
||||||
|
|
||||||
with_development_env do
|
with_development_env do
|
||||||
|
|
@ -41,7 +43,7 @@ class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create returns error when file type is invalid" do
|
test "create returns error when file type is invalid" do
|
||||||
user = User.create!(timezone: "UTC")
|
user = users(:one)
|
||||||
sign_in_as(user)
|
sign_in_as(user)
|
||||||
|
|
||||||
with_development_env do
|
with_development_env do
|
||||||
|
|
@ -55,7 +57,7 @@ class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create starts import and returns status" do
|
test "create starts import and returns status" do
|
||||||
user = User.create!(timezone: "UTC")
|
user = users(:one)
|
||||||
sign_in_as(user)
|
sign_in_as(user)
|
||||||
|
|
||||||
with_development_env do
|
with_development_env do
|
||||||
|
|
@ -73,14 +75,39 @@ class My::HeartbeatImportsControllerTest < ActionDispatch::IntegrationTest
|
||||||
assert_equal 0, body.dig("status", "progress_percent")
|
assert_equal 0, body.dig("status", "progress_percent")
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
test "show returns status for existing import" do
|
||||||
|
user = users(:one)
|
||||||
|
sign_in_as(user)
|
||||||
|
|
||||||
def sign_in_as(user)
|
with_development_env do
|
||||||
token = user.sign_in_tokens.create!(auth_type: :email)
|
with_memory_cache do
|
||||||
get auth_token_path(token: token.token)
|
post my_heartbeat_imports_path, params: { heartbeat_file: uploaded_file }
|
||||||
assert_equal user.id, session[:user_id]
|
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
|
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
|
def with_development_env
|
||||||
rails_singleton = class << Rails; self; end
|
rails_singleton = class << Rails; self; end
|
||||||
rails_singleton.alias_method :__original_env_for_test, :env
|
rails_singleton.alias_method :__original_env_for_test, :env
|
||||||
|
|
|
||||||
47
test/controllers/my/heartbeats_controller_test.rb
Normal file
47
test/controllers/my/heartbeats_controller_test.rb
Normal 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
|
||||||
|
|
@ -17,10 +17,12 @@ class My::ProjectRepoMappingsControllerTest < ActionDispatch::IntegrationTest
|
||||||
get my_projects_path
|
get my_projects_path
|
||||||
|
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_includes response.body, "\"component\":\"Projects/Index\""
|
assert_inertia_component "Projects/Index"
|
||||||
assert_includes response.body, "\"deferredProps\":{\"default\":[\"projects_data\"]}"
|
|
||||||
assert_includes response.body, "\"show_archived\":false"
|
page = inertia_page
|
||||||
assert_includes response.body, "\"total_projects\":1"
|
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
|
end
|
||||||
|
|
||||||
test "index supports archived view state" do
|
test "index supports archived view state" do
|
||||||
|
|
@ -33,51 +35,11 @@ class My::ProjectRepoMappingsControllerTest < ActionDispatch::IntegrationTest
|
||||||
get my_projects_path(show_archived: true)
|
get my_projects_path(show_archived: true)
|
||||||
|
|
||||||
assert_response :success
|
assert_response :success
|
||||||
assert_includes response.body, "\"component\":\"Projects/Index\""
|
assert_inertia_component "Projects/Index"
|
||||||
assert_includes response.body, "\"show_archived\":true"
|
|
||||||
assert_includes response.body, "\"total_projects\":1"
|
|
||||||
end
|
|
||||||
|
|
||||||
test "repository payload uses newer tracked commit when repository metadata is stale" do
|
page = inertia_page
|
||||||
travel_to Time.zone.parse("2026-02-19 12:00:00 UTC") do
|
assert_equal true, page.dig("props", "show_archived")
|
||||||
repository = Repository.create!(
|
assert_equal 1, page.dig("props", "total_projects")
|
||||||
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
|
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
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 - 1800, source_type: :test_entry)
|
||||||
Heartbeat.create!(user: user, project: project_name, category: "coding", time: now, source_type: :test_entry)
|
Heartbeat.create!(user: user, project: project_name, category: "coding", time: now, source_type: :test_entry)
|
||||||
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -51,12 +51,4 @@ class ProfilesControllerTest < ActionDispatch::IntegrationTest
|
||||||
assert_inertia_prop "profile_visible", true
|
assert_inertia_prop "profile_visible", true
|
||||||
assert_inertia_prop "is_own_profile", true
|
assert_inertia_prop "is_own_profile", true
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -226,11 +226,80 @@ class SessionsControllerTest < ActionDispatch::IntegrationTest
|
||||||
assert_nil session[:user_id]
|
assert_nil session[:user_id]
|
||||||
end
|
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)
|
delete github_unlink_path
|
||||||
token = user.sign_in_tokens.create!(auth_type: :email)
|
|
||||||
get auth_token_path(token: token.token)
|
assert_response :redirect
|
||||||
assert_equal user.id, session[:user_id]
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest
|
class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
fixtures :users
|
||||||
|
|
||||||
test "show renders goals settings page" do
|
test "show renders goals settings page" do
|
||||||
user = User.create!
|
user = users(:one)
|
||||||
sign_in_as(user)
|
sign_in_as(user)
|
||||||
|
|
||||||
get my_settings_goals_path
|
get my_settings_goals_path
|
||||||
|
|
@ -16,7 +18,7 @@ class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create saves valid goal" do
|
test "create saves valid goal" do
|
||||||
user = User.create!
|
user = users(:one)
|
||||||
sign_in_as(user)
|
sign_in_as(user)
|
||||||
|
|
||||||
post my_settings_goals_create_path, params: {
|
post my_settings_goals_create_path, params: {
|
||||||
|
|
@ -38,7 +40,7 @@ class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest
|
||||||
end
|
end
|
||||||
|
|
||||||
test "rejects sixth goal when limit reached" do
|
test "rejects sixth goal when limit reached" do
|
||||||
user = User.create!
|
user = users(:one)
|
||||||
sign_in_as(user)
|
sign_in_as(user)
|
||||||
|
|
||||||
5.times do |index|
|
5.times do |index|
|
||||||
|
|
@ -64,7 +66,7 @@ class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create rejects invalid goal period" do
|
test "create rejects invalid goal period" do
|
||||||
user = User.create!
|
user = users(:one)
|
||||||
sign_in_as(user)
|
sign_in_as(user)
|
||||||
|
|
||||||
post my_settings_goals_create_path, params: {
|
post my_settings_goals_create_path, params: {
|
||||||
|
|
@ -81,7 +83,7 @@ class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create rejects nonpositive goal target" do
|
test "create rejects nonpositive goal target" do
|
||||||
user = User.create!
|
user = users(:one)
|
||||||
sign_in_as(user)
|
sign_in_as(user)
|
||||||
|
|
||||||
post my_settings_goals_create_path, params: {
|
post my_settings_goals_create_path, params: {
|
||||||
|
|
@ -98,7 +100,7 @@ class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest
|
||||||
end
|
end
|
||||||
|
|
||||||
test "create rejects impossible day target" do
|
test "create rejects impossible day target" do
|
||||||
user = User.create!
|
user = users(:one)
|
||||||
sign_in_as(user)
|
sign_in_as(user)
|
||||||
|
|
||||||
post my_settings_goals_create_path, params: {
|
post my_settings_goals_create_path, params: {
|
||||||
|
|
@ -114,11 +116,62 @@ class SettingsGoalsControllerTest < ActionDispatch::IntegrationTest
|
||||||
assert_equal 0, user.reload.goals.count
|
assert_equal 0, user.reload.goals.count
|
||||||
end
|
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)
|
patch my_settings_goal_update_path(goal_id: goal.id), params: {
|
||||||
token = user.sign_in_tokens.create!(auth_type: :email)
|
goal: {
|
||||||
get auth_token_path(token: token.token)
|
period: "week",
|
||||||
assert_equal user.id, session[:user_id]
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class SettingsProfileControllerTest < ActionDispatch::IntegrationTest
|
class SettingsProfileControllerTest < ActionDispatch::IntegrationTest
|
||||||
|
fixtures :users
|
||||||
|
|
||||||
test "profile update persists selected theme" do
|
test "profile update persists selected theme" do
|
||||||
user = User.create!
|
user = users(:one)
|
||||||
sign_in_as(user)
|
sign_in_as(user)
|
||||||
|
|
||||||
patch my_settings_profile_path, params: { user: { theme: "nord" } }
|
patch my_settings_profile_path, params: { user: { theme: "nord" } }
|
||||||
|
|
@ -12,11 +14,25 @@ class SettingsProfileControllerTest < ActionDispatch::IntegrationTest
|
||||||
assert_equal "nord", user.reload.theme
|
assert_equal "nord", user.reload.theme
|
||||||
end
|
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)
|
patch my_settings_profile_path, params: { user: { country_code: "" } }
|
||||||
token = user.sign_in_tokens.create!(auth_type: :email)
|
|
||||||
get auth_token_path(token: token.token)
|
assert_response :redirect
|
||||||
assert_equal user.id, session[:user_id]
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
2
test/fixtures/admin_api_keys.yml
vendored
Normal file
2
test/fixtures/admin_api_keys.yml
vendored
Normal 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.
|
||||||
4
test/fixtures/api_keys.yml
vendored
4
test/fixtures/api_keys.yml
vendored
|
|
@ -1,11 +1,11 @@
|
||||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
one:
|
one:
|
||||||
user: one
|
user_id: 1
|
||||||
name: MyText
|
name: MyText
|
||||||
token: MyTextOne
|
token: MyTextOne
|
||||||
|
|
||||||
two:
|
two:
|
||||||
user: two
|
user_id: 2
|
||||||
name: MyText
|
name: MyText
|
||||||
token: MyTextTwo
|
token: MyTextTwo
|
||||||
|
|
|
||||||
9
test/fixtures/email_addresses.yml
vendored
Normal file
9
test/fixtures/email_addresses.yml
vendored
Normal 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
|
||||||
58
test/fixtures/heartbeats.yml
vendored
58
test/fixtures/heartbeats.yml
vendored
|
|
@ -1,37 +1,39 @@
|
||||||
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
|
||||||
|
|
||||||
one:
|
one:
|
||||||
user: one
|
user_id: 1
|
||||||
entity: MyText
|
entity: "src/main.rb"
|
||||||
type: MyText
|
type: "file"
|
||||||
category: MyString
|
category: "coding"
|
||||||
time: 2025-03-03 18:08:42
|
time: <%= 1.day.ago.to_f %>
|
||||||
project: MyString
|
project: "testproject"
|
||||||
project_root_count: 1
|
project_root_count: 1
|
||||||
branch: MyString
|
branch: "main"
|
||||||
language: MyString
|
language: "Ruby"
|
||||||
dependencies: MyString
|
dependencies: []
|
||||||
lines: 1
|
source_type: 0
|
||||||
line_additions: 1
|
lines: 100
|
||||||
line_deletions: 1
|
line_additions: 5
|
||||||
lineno: 1
|
line_deletions: 2
|
||||||
cursorpos: 1
|
lineno: 42
|
||||||
is_write: false
|
cursorpos: 10
|
||||||
|
is_write: true
|
||||||
|
|
||||||
two:
|
two:
|
||||||
user: two
|
user_id: 2
|
||||||
entity: MyText
|
entity: "src/app.py"
|
||||||
type: MyText
|
type: "file"
|
||||||
category: MyString
|
category: "coding"
|
||||||
time: 2025-03-03 18:08:42
|
time: <%= 2.days.ago.to_f %>
|
||||||
project: MyString
|
project: "otherproject"
|
||||||
project_root_count: 1
|
project_root_count: 1
|
||||||
branch: MyString
|
branch: "main"
|
||||||
language: MyString
|
language: "Python"
|
||||||
dependencies: MyString
|
dependencies: []
|
||||||
lines: 1
|
source_type: 0
|
||||||
line_additions: 1
|
lines: 50
|
||||||
|
line_additions: 3
|
||||||
line_deletions: 1
|
line_deletions: 1
|
||||||
lineno: 1
|
lineno: 10
|
||||||
cursorpos: 1
|
cursorpos: 5
|
||||||
is_write: false
|
is_write: false
|
||||||
|
|
|
||||||
2
test/fixtures/oauth_access_grants.yml
vendored
Normal file
2
test/fixtures/oauth_access_grants.yml
vendored
Normal 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
2
test/fixtures/oauth_access_tokens.yml
vendored
Normal 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
2
test/fixtures/sign_in_tokens.yml
vendored
Normal 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
14
test/fixtures/users.yml
vendored
Normal 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"
|
||||||
120
test/jobs/heartbeat_export_job_test.rb
Normal file
120
test/jobs/heartbeat_export_job_test.rb
Normal 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
|
||||||
39
test/mailers/heartbeat_export_mailer_test.rb
Normal file
39
test/mailers/heartbeat_export_mailer_test.rb
Normal 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
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class ApiKeyTest < ActiveSupport::TestCase
|
|
||||||
# test "the truth" do
|
|
||||||
# assert true
|
|
||||||
# end
|
|
||||||
end
|
|
||||||
|
|
@ -1,7 +1,26 @@
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class HeartbeatTest < ActiveSupport::TestCase
|
class HeartbeatTest < ActiveSupport::TestCase
|
||||||
# test "the truth" do
|
test "soft delete hides record from default scope and restore brings it back" do
|
||||||
# assert true
|
user = User.create!
|
||||||
# end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,26 @@
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class ProjectRepoMappingTest < ActiveSupport::TestCase
|
class ProjectRepoMappingTest < ActiveSupport::TestCase
|
||||||
# test "the truth" do
|
test "archive and unarchive toggle archived state" do
|
||||||
# assert true
|
user = User.create!
|
||||||
# end
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,17 @@
|
||||||
require "test_helper"
|
require "test_helper"
|
||||||
|
|
||||||
class RepositoryTest < ActiveSupport::TestCase
|
class RepositoryTest < ActiveSupport::TestCase
|
||||||
# test "the truth" do
|
test "parse_url extracts host owner and name" do
|
||||||
# assert true
|
parsed = Repository.parse_url("https://github.com/hackclub/hackatime")
|
||||||
# end
|
|
||||||
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class SailorsLogLeaderboardTest < ActiveSupport::TestCase
|
|
||||||
# test "the truth" do
|
|
||||||
# assert true
|
|
||||||
# end
|
|
||||||
end
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class SailorsLogNotificationPreferenceTest < ActiveSupport::TestCase
|
|
||||||
# test "the truth" do
|
|
||||||
# assert true
|
|
||||||
# end
|
|
||||||
end
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class SailorsLogSlackNotificationTest < ActiveSupport::TestCase
|
|
||||||
# test "the truth" do
|
|
||||||
# assert true
|
|
||||||
# end
|
|
||||||
end
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
require "test_helper"
|
|
||||||
|
|
||||||
class SailorsLogTest < ActiveSupport::TestCase
|
|
||||||
# test "the truth" do
|
|
||||||
# assert true
|
|
||||||
# end
|
|
||||||
end
|
|
||||||
|
|
@ -2,7 +2,7 @@ require "test_helper"
|
||||||
|
|
||||||
class UserTest < ActiveSupport::TestCase
|
class UserTest < ActiveSupport::TestCase
|
||||||
test "theme defaults to gruvbox dark" do
|
test "theme defaults to gruvbox dark" do
|
||||||
user = User.create!
|
user = User.new
|
||||||
|
|
||||||
assert_equal "gruvbox_dark", user.theme
|
assert_equal "gruvbox_dark", user.theme
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ require "test_helper"
|
||||||
class AnonymizeUserServiceTest < ActiveSupport::TestCase
|
class AnonymizeUserServiceTest < ActiveSupport::TestCase
|
||||||
test "anonymization clears profile identity fields" do
|
test "anonymization clears profile identity fields" do
|
||||||
user = User.create!(
|
user = User.create!(
|
||||||
username: "anon_user_#{SecureRandom.hex(4)}",
|
username: "anon_#{SecureRandom.hex(4)}",
|
||||||
display_name_override: "Custom Name",
|
display_name_override: "Custom Name",
|
||||||
profile_bio: "Bio",
|
profile_bio: "Bio",
|
||||||
profile_github_url: "https://github.com/hackclub",
|
profile_github_url: "https://github.com/hackclub",
|
||||||
|
|
@ -37,4 +37,34 @@ class AnonymizeUserServiceTest < ActiveSupport::TestCase
|
||||||
|
|
||||||
assert_equal 0, user.goals.count
|
assert_equal 0, user.goals.count
|
||||||
end
|
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
|
end
|
||||||
|
|
|
||||||
111
test/system/heartbeat_export_test.rb
Normal file
111
test/system/heartbeat_export_test.rb
Normal 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
|
||||||
15
test/system/profiles_test.rb
Normal file
15
test/system/profiles_test.rb
Normal 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
|
||||||
79
test/system/projects_test.rb
Normal file
79
test/system/projects_test.rb
Normal 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
|
||||||
66
test/system/settings/access_settings_test.rb
Normal file
66
test/system/settings/access_settings_test.rb
Normal 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
|
||||||
59
test/system/settings/admin_settings_test.rb
Normal file
59
test/system/settings/admin_settings_test.rb
Normal 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
|
||||||
29
test/system/settings/badges_settings_test.rb
Normal file
29
test/system/settings/badges_settings_test.rb
Normal 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
|
||||||
44
test/system/settings/data_settings_test.rb
Normal file
44
test/system/settings/data_settings_test.rb
Normal 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
|
||||||
82
test/system/settings/goals_settings_test.rb
Normal file
82
test/system/settings/goals_settings_test.rb
Normal 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
|
||||||
57
test/system/settings/integrations_settings_test.rb
Normal file
57
test/system/settings/integrations_settings_test.rb
Normal 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
|
||||||
78
test/system/settings/profile_settings_test.rb
Normal file
78
test/system/settings/profile_settings_test.rb
Normal 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
|
||||||
24
test/system/settings/test_helpers.rb
Normal file
24
test/system/settings/test_helpers.rb
Normal 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
|
||||||
|
|
@ -17,6 +17,8 @@ module ActiveSupport
|
||||||
"physical_mails",
|
"physical_mails",
|
||||||
"api_keys",
|
"api_keys",
|
||||||
"heartbeats",
|
"heartbeats",
|
||||||
|
"users",
|
||||||
|
"email_addresses",
|
||||||
"project_repo_mappings",
|
"project_repo_mappings",
|
||||||
"repositories",
|
"repositories",
|
||||||
"sailors_log_leaderboards",
|
"sailors_log_leaderboards",
|
||||||
|
|
@ -29,6 +31,21 @@ module ActiveSupport
|
||||||
end
|
end
|
||||||
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
|
module InertiaTestHelper
|
||||||
def inertia_page
|
def inertia_page
|
||||||
document = Nokogiri::HTML(response.body)
|
document = Nokogiri::HTML(response.body)
|
||||||
|
|
@ -56,5 +73,6 @@ module InertiaTestHelper
|
||||||
end
|
end
|
||||||
|
|
||||||
class ActionDispatch::IntegrationTest
|
class ActionDispatch::IntegrationTest
|
||||||
|
include IntegrationTestAuthHelper
|
||||||
include InertiaTestHelper
|
include InertiaTestHelper
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue