mirror of
https://github.com/System-End/hackatime.git
synced 2026-04-19 16:38:23 +00:00
feat: added actual api docs (rswag) + ci enforcement (#846)
* feat: add API documentation and CI checks - Add Rswag for automated API documentation generation - Add Swagger specs for all endpoints - Add CI step to enforce that swagger.yaml stays in sync with code - Add static test keys in seeds.rb for easier testing - Update AGENTS.md and README.md to support this * Merge branch 'main' of https://github.com/deployor/hackatime * Merge branch 'main' into main * Deprecations! Yay! :) * It was wan addicent i swear linter! Dont hurt me * Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Copilot..... we love you! Also this project is open and so are api docs meant to be if another AI reads ts! * Merge branch 'main' of https://github.com/deployor/hackatime * Merge branch 'main' into main * Merge branch 'main' into main * Update app/controllers/api/admin/v1/admin_controller.rb If you say so Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Update spec/requests/api/v1/my_spec.rb I guessss? Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * Failed my own CI wow.... EMBARRASSINGGGG * Merge branch 'main' into main * Merge branch 'main' into main * clarify wording on internal/revoke * Merge branch 'main' into main * update swagger docs
This commit is contained in:
parent
deaa299924
commit
8d0215ff0f
36 changed files with 7507 additions and 29 deletions
11
.github/workflows/ci.yml
vendored
11
.github/workflows/ci.yml
vendored
|
|
@ -122,6 +122,17 @@ jobs:
|
||||||
psql -h localhost -U postgres -c "CREATE DATABASE test_warehouse;"
|
psql -h localhost -U postgres -c "CREATE DATABASE test_warehouse;"
|
||||||
bin/rails test
|
bin/rails test
|
||||||
|
|
||||||
|
- name: Ensure Swagger docs are up to date
|
||||||
|
env:
|
||||||
|
RAILS_ENV: test
|
||||||
|
TEST_DATABASE_URL: postgres://postgres:postgres@localhost:5432/test_db
|
||||||
|
PGHOST: localhost
|
||||||
|
PGUSER: postgres
|
||||||
|
PGPASSWORD: postgres
|
||||||
|
run: |
|
||||||
|
bin/rails rswag:specs:swaggerize
|
||||||
|
git diff --exit-code swagger/v1/swagger.yaml
|
||||||
|
|
||||||
- name: Keep screenshots from failed system tests
|
- name: Keep screenshots from failed system tests
|
||||||
uses: actions/upload-artifact@v6
|
uses: actions/upload-artifact@v6
|
||||||
if: failure()
|
if: failure()
|
||||||
|
|
|
||||||
1
.rspec
Normal file
1
.rspec
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
--require spec_helper
|
||||||
|
|
@ -12,6 +12,7 @@ We do development using docker-compose. Run `docker-compose ps` to see if the de
|
||||||
- **Security**: `docker compose run web bundle exec brakeman` (security audit)
|
- **Security**: `docker compose run web bundle exec brakeman` (security audit)
|
||||||
- **JS Security**: `docker compose run web bin/importmap audit` (JS dependency scan)
|
- **JS Security**: `docker compose run web bin/importmap audit` (JS dependency scan)
|
||||||
- **Zeitwerk**: `docker compose run web bin/rails zeitwerk:check` (autoloader check)
|
- **Zeitwerk**: `docker compose run web bin/rails zeitwerk:check` (autoloader check)
|
||||||
|
- **Swagger**: `docker compose run web bin/rails rswag:specs:swaggerize` (generate API docs)
|
||||||
|
|
||||||
## CI/Testing Requirements
|
## CI/Testing Requirements
|
||||||
|
|
||||||
|
|
@ -22,6 +23,13 @@ We do development using docker-compose. Run `docker-compose ps` to see if the de
|
||||||
3. `docker compose run web bin/importmap audit` (JS security)
|
3. `docker compose run web bin/importmap audit` (JS security)
|
||||||
4. `docker compose run web bin/rails zeitwerk:check` (autoloader)
|
4. `docker compose run web bin/rails zeitwerk:check` (autoloader)
|
||||||
5. `docker compose run web rails test` (full test suite)
|
5. `docker compose run web rails test` (full test suite)
|
||||||
|
6. `docker compose run web bin/rails rswag:specs:swaggerize` (ensure docs are up to date)
|
||||||
|
|
||||||
|
## API Documentation
|
||||||
|
|
||||||
|
- **Specs**: All new API endpoints MUST include Rswag specs in `spec/requests/api/...`.
|
||||||
|
- **Generation**: After changing specs, run `bundle exec rake rswag:specs:swaggerize` to update `swagger/v1/swagger.yaml`.
|
||||||
|
- **Validation**: CI will fail if `swagger.yaml` is out of sync with the specs (meaning you forgot to run the generation command).
|
||||||
|
|
||||||
## Docker Development
|
## Docker Development
|
||||||
|
|
||||||
|
|
|
||||||
6
Gemfile
6
Gemfile
|
|
@ -118,8 +118,14 @@ group :development, :test do
|
||||||
|
|
||||||
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
|
# Omakase Ruby styling [https://github.com/rails/rubocop-rails-omakase/]
|
||||||
gem "rubocop-rails-omakase", require: false
|
gem "rubocop-rails-omakase", require: false
|
||||||
|
|
||||||
|
gem "rspec-rails"
|
||||||
|
gem "rswag-specs"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
gem "rswag-api"
|
||||||
|
gem "rswag-ui"
|
||||||
|
|
||||||
group :development do
|
group :development do
|
||||||
# Use console on exceptions pages [https://github.com/rails/web-console]
|
# Use console on exceptions pages [https://github.com/rails/web-console]
|
||||||
gem "web-console"
|
gem "web-console"
|
||||||
|
|
|
||||||
36
Gemfile.lock
36
Gemfile.lock
|
|
@ -151,6 +151,7 @@ GEM
|
||||||
irb (~> 1.10)
|
irb (~> 1.10)
|
||||||
reline (>= 0.3.8)
|
reline (>= 0.3.8)
|
||||||
device_detector (1.1.3)
|
device_detector (1.1.3)
|
||||||
|
diff-lcs (1.6.2)
|
||||||
domain_name (0.6.20240107)
|
domain_name (0.6.20240107)
|
||||||
doorkeeper (5.8.2)
|
doorkeeper (5.8.2)
|
||||||
railties (>= 5)
|
railties (>= 5)
|
||||||
|
|
@ -253,6 +254,9 @@ GEM
|
||||||
actionview (>= 7.0.0)
|
actionview (>= 7.0.0)
|
||||||
activesupport (>= 7.0.0)
|
activesupport (>= 7.0.0)
|
||||||
json (2.18.0)
|
json (2.18.0)
|
||||||
|
json-schema (6.1.0)
|
||||||
|
addressable (~> 2.8)
|
||||||
|
bigdecimal (>= 3.1, < 5)
|
||||||
kamal (2.10.1)
|
kamal (2.10.1)
|
||||||
activesupport (>= 7.0)
|
activesupport (>= 7.0)
|
||||||
base64 (~> 0.2)
|
base64 (~> 0.2)
|
||||||
|
|
@ -442,6 +446,34 @@ GEM
|
||||||
request_store (1.7.0)
|
request_store (1.7.0)
|
||||||
rack (>= 1.4)
|
rack (>= 1.4)
|
||||||
rexml (3.4.4)
|
rexml (3.4.4)
|
||||||
|
rspec-core (3.13.6)
|
||||||
|
rspec-support (~> 3.13.0)
|
||||||
|
rspec-expectations (3.13.5)
|
||||||
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
|
rspec-support (~> 3.13.0)
|
||||||
|
rspec-mocks (3.13.7)
|
||||||
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
|
rspec-support (~> 3.13.0)
|
||||||
|
rspec-rails (8.0.2)
|
||||||
|
actionpack (>= 7.2)
|
||||||
|
activesupport (>= 7.2)
|
||||||
|
railties (>= 7.2)
|
||||||
|
rspec-core (~> 3.13)
|
||||||
|
rspec-expectations (~> 3.13)
|
||||||
|
rspec-mocks (~> 3.13)
|
||||||
|
rspec-support (~> 3.13)
|
||||||
|
rspec-support (3.13.6)
|
||||||
|
rswag-api (2.17.0)
|
||||||
|
activesupport (>= 5.2, < 8.2)
|
||||||
|
railties (>= 5.2, < 8.2)
|
||||||
|
rswag-specs (2.17.0)
|
||||||
|
activesupport (>= 5.2, < 8.2)
|
||||||
|
json-schema (>= 2.2, < 7.0)
|
||||||
|
railties (>= 5.2, < 8.2)
|
||||||
|
rspec-core (>= 2.14)
|
||||||
|
rswag-ui (2.17.0)
|
||||||
|
actionpack (>= 5.2, < 8.2)
|
||||||
|
railties (>= 5.2, < 8.2)
|
||||||
rubocop (1.82.1)
|
rubocop (1.82.1)
|
||||||
json (~> 2.3)
|
json (~> 2.3)
|
||||||
language_server-protocol (~> 3.17.0.2)
|
language_server-protocol (~> 3.17.0.2)
|
||||||
|
|
@ -629,6 +661,10 @@ DEPENDENCIES
|
||||||
rack-mini-profiler
|
rack-mini-profiler
|
||||||
rails (~> 8.1.2)
|
rails (~> 8.1.2)
|
||||||
redcarpet
|
redcarpet
|
||||||
|
rspec-rails
|
||||||
|
rswag-api
|
||||||
|
rswag-specs
|
||||||
|
rswag-ui
|
||||||
rubocop-rails-omakase
|
rubocop-rails-omakase
|
||||||
ruby_identicon
|
ruby_identicon
|
||||||
selenium-webdriver
|
selenium-webdriver
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,7 @@ app# bin/dev
|
||||||
# Want to do other things?
|
# Want to do other things?
|
||||||
app# bin/rails c # start an interactive irb!
|
app# bin/rails c # start an interactive irb!
|
||||||
app# bin/rails db:migrate # migrate the database
|
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/>
|
You can now access the app at <http://localhost:3000/>
|
||||||
|
|
|
||||||
|
|
@ -517,9 +517,9 @@ module Api
|
||||||
|
|
||||||
values = query
|
values = query
|
||||||
.where.not(column_name => nil)
|
.where.not(column_name => nil)
|
||||||
.order(column_name => :asc)
|
.order(Arel.sql("value ASC"))
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.pluck(:value)
|
.pluck(Arel.sql("value"))
|
||||||
.reject(&:empty?)
|
.reject(&:empty?)
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
|
|
@ -826,7 +826,7 @@ module Api
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_user_by_id
|
def find_user_by_id
|
||||||
user_id = params[:id]
|
user_id = params[:id] || params[:user_id]
|
||||||
|
|
||||||
if user_id.blank?
|
if user_id.blank?
|
||||||
render json: { error: "who?" }, status: :unprocessable_entity
|
render json: { error: "who?" }, status: :unprocessable_entity
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ module Api
|
||||||
|
|
||||||
render json: {
|
render json: {
|
||||||
magic_link: auth_token_url(sign_in_token.token),
|
magic_link: auth_token_url(sign_in_token.token),
|
||||||
existing:
|
existing: existing
|
||||||
}
|
}
|
||||||
rescue => e
|
rescue => e
|
||||||
Sentry.capture_exception(e, extra: { slack_uid: slack_uid, email: email, params: params.to_unsafe_h })
|
Sentry.capture_exception(e, extra: { slack_uid: slack_uid, email: email, params: params.to_unsafe_h })
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
module Api
|
module Api
|
||||||
module Internal
|
module Internal
|
||||||
class RevocationsController < ApplicationController
|
class RevocationsController < Api::Internal::ApplicationController
|
||||||
def create
|
def create
|
||||||
token = params[:token]
|
token = params[:token]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ module Api
|
||||||
|
|
||||||
def index
|
def index
|
||||||
# Parse interval or date range
|
# Parse interval or date range
|
||||||
date_range = determine_date_range(params[:interval], params[:range], params[:from], params[:to])
|
date_range = determine_date_range(params[:interval], params[:range], params[:from] || params[:start], params[:to] || params[:end])
|
||||||
return render json: { error: "Invalid date range" }, status: :bad_request unless date_range
|
return render json: { error: "Invalid date range" }, status: :bad_request unless date_range
|
||||||
|
|
||||||
# Create parameters for WakatimeService
|
# Create parameters for WakatimeService
|
||||||
|
|
|
||||||
|
|
@ -11,11 +11,10 @@ class Api::V1::StatsController < ApplicationController
|
||||||
|
|
||||||
query = Heartbeat.where(time: start_date..end_date)
|
query = Heartbeat.where(time: start_date..end_date)
|
||||||
if params[:username].present?
|
if params[:username].present?
|
||||||
user_id = params[:username]
|
user = User.find_by(username: params[:username]) || User.find_by(slack_uid: params[:username])
|
||||||
|
return render json: { error: "User not found" }, status: :not_found unless user
|
||||||
|
|
||||||
return render json: { error: "User not found" }, status: :not_found unless user_id.present?
|
query = query.where(user_id: user.id)
|
||||||
|
|
||||||
query = query.where(user_id: user_id)
|
|
||||||
end
|
end
|
||||||
|
|
||||||
if params[:user_email].present?
|
if params[:user_email].present?
|
||||||
|
|
@ -26,7 +25,7 @@ class Api::V1::StatsController < ApplicationController
|
||||||
query = query.where(user_id: user_id)
|
query = query.where(user_id: user_id)
|
||||||
end
|
end
|
||||||
|
|
||||||
render plain: query.duration_seconds
|
render plain: query.duration_seconds.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
def user_stats
|
def user_stats
|
||||||
|
|
@ -34,10 +33,6 @@ class Api::V1::StatsController < ApplicationController
|
||||||
|
|
||||||
return render json: { error: "User not found" }, status: :not_found unless @user.present?
|
return render json: { error: "User not found" }, status: :not_found unless @user.present?
|
||||||
|
|
||||||
if !@user.allow_public_stats_lookup && (!current_user || current_user != @user)
|
|
||||||
return render json: { error: "user has disabled public stats" }, status: :forbidden
|
|
||||||
end
|
|
||||||
|
|
||||||
start_date = params[:start_date].to_datetime if params[:start_date].present?
|
start_date = params[:start_date].to_datetime if params[:start_date].present?
|
||||||
start_date ||= 10.years.ago
|
start_date ||= 10.years.ago
|
||||||
end_date = params[:end_date].to_datetime if params[:end_date].present?
|
end_date = params[:end_date].to_datetime if params[:end_date].present?
|
||||||
|
|
@ -81,6 +76,10 @@ class Api::V1::StatsController < ApplicationController
|
||||||
.with_valid_timestamps
|
.with_valid_timestamps
|
||||||
.where(time: start_date..end_date)
|
.where(time: start_date..end_date)
|
||||||
|
|
||||||
|
if !@user.allow_public_stats_lookup && (!current_user || current_user != @user)
|
||||||
|
return render json: { error: "user has disabled public stats" }, status: :forbidden
|
||||||
|
end
|
||||||
|
|
||||||
if params[:filter_by_project].present?
|
if params[:filter_by_project].present?
|
||||||
filter_by_project = params[:filter_by_project].split(",")
|
filter_by_project = params[:filter_by_project].split(",")
|
||||||
query = query.where(project: filter_by_project)
|
query = query.where(project: filter_by_project)
|
||||||
|
|
@ -115,6 +114,8 @@ class Api::V1::StatsController < ApplicationController
|
||||||
summary[:unique_total_seconds] = unique_seconds
|
summary[:unique_total_seconds] = unique_seconds
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
trust_level = @user.trust_level
|
trust_level = @user.trust_level
|
||||||
trust_level = "blue" if trust_level == "yellow"
|
trust_level = "blue" if trust_level == "yellow"
|
||||||
trust_value = User.trust_levels[trust_level]
|
trust_value = User.trust_levels[trust_level]
|
||||||
|
|
@ -196,7 +197,7 @@ class Api::V1::StatsController < ApplicationController
|
||||||
params[:projects].split(",").map(&:strip)
|
params[:projects].split(",").map(&:strip)
|
||||||
else
|
else
|
||||||
since = params[:since]&.to_datetime || 30.days.ago.beginning_of_day
|
since = params[:since]&.to_datetime || 30.days.ago.beginning_of_day
|
||||||
until_date = params[:until]&.to_datetime || Time.current
|
until_date = (params[:until] || params[:until_date])&.to_datetime || Time.current
|
||||||
|
|
||||||
@user.heartbeats
|
@user.heartbeats
|
||||||
.where(time: since..until_date)
|
.where(time: since..until_date)
|
||||||
|
|
@ -255,7 +256,8 @@ class Api::V1::StatsController < ApplicationController
|
||||||
token = request.headers["Authorization"]&.split(" ")&.last
|
token = request.headers["Authorization"]&.split(" ")&.last
|
||||||
token ||= params[:api_key]
|
token ||= params[:api_key]
|
||||||
|
|
||||||
render plain: "Unauthorized", status: :unauthorized unless token == ENV["STATS_API_KEY"]
|
# Rails.logger.info "Auth Debug: Token=#{token.inspect}, Expected=#{ENV['STATS_API_KEY'].inspect}"
|
||||||
|
render json: { error: "Unauthorized" }, status: :unauthorized unless token == ENV["STATS_API_KEY"]
|
||||||
end
|
end
|
||||||
|
|
||||||
def find_by_email(email)
|
def find_by_email(email)
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ module Api
|
||||||
|
|
||||||
scope = Heartbeat.where(
|
scope = Heartbeat.where(
|
||||||
user_id: user.id,
|
user_id: user.id,
|
||||||
time: params[:start_time]..params[:end_time]
|
time: Time.parse(params[:start_time]).to_f..Time.parse(params[:end_time]).to_f
|
||||||
)
|
)
|
||||||
|
|
||||||
if params[:project].present?
|
if params[:project].present?
|
||||||
|
|
|
||||||
3
config/initializers/rswag_api.rb
Normal file
3
config/initializers/rswag_api.rb
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Rswag::Api.configure do |c|
|
||||||
|
c.openapi_root = Rails.root.to_s + "/swagger"
|
||||||
|
end
|
||||||
3
config/initializers/rswag_ui.rb
Normal file
3
config/initializers/rswag_ui.rb
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
Rswag::Ui.configure do |c|
|
||||||
|
c.openapi_endpoint "/api-docs/v1/swagger.yaml", "API V1 Docs"
|
||||||
|
end
|
||||||
|
|
@ -11,6 +11,8 @@ class AdminLevelConstraint
|
||||||
end
|
end
|
||||||
|
|
||||||
Rails.application.routes.draw do
|
Rails.application.routes.draw do
|
||||||
|
mount Rswag::Api::Engine => "/api-docs"
|
||||||
|
mount Rswag::Ui::Engine => "/api-docs"
|
||||||
use_doorkeeper
|
use_doorkeeper
|
||||||
|
|
||||||
root "static_pages#index"
|
root "static_pages#index"
|
||||||
|
|
|
||||||
57
db/seeds.rb
57
db/seeds.rb
|
|
@ -2,23 +2,14 @@
|
||||||
# development, test). The code here should be idempotent so that it can be executed at any point in every environment.
|
# development, test). The code here should be idempotent so that it can be executed at any point in every environment.
|
||||||
# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
|
# The data can then be loaded with the bin/rails db:seed command (or created alongside the database with db:setup).
|
||||||
|
|
||||||
Doorkeeper::Application.find_or_create_by(
|
|
||||||
name: "Hackatime Desktop",
|
|
||||||
owner: User.find_by(id: 1),
|
|
||||||
redirect_uri: "hackatime://auth/callback",
|
|
||||||
uid: "BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ",
|
|
||||||
scopes: [ "profile" ],
|
|
||||||
confidential: false,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Only seed test data in development environment
|
# Only seed test data in development environment
|
||||||
if Rails.env.development?
|
test_user = nil
|
||||||
|
if Rails.env.development? || Rails.env.test?
|
||||||
# Creating test user
|
# Creating test user
|
||||||
test_user = User.find_or_create_by(slack_uid: 'TEST123456') do |user|
|
test_user = User.find_or_create_by(slack_uid: 'TEST123456') do |user|
|
||||||
user.username = 'testuser'
|
user.username = 'testuser'
|
||||||
user.slack_username = 'testuser'
|
user.slack_username = 'testuser'
|
||||||
|
|
||||||
# Before you had user.is_admin = true, does not work, changed it to that, looks like it works but idk how to use the admin pages so pls check this, i just guess coded this, the cmd to seed the db works without errors
|
|
||||||
user.set_admin_level(:superadmin)
|
user.set_admin_level(:superadmin)
|
||||||
# Ensure timezone is set to avoid nil timezone issues
|
# Ensure timezone is set to avoid nil timezone issues
|
||||||
user.timezone = 'America/New_York'
|
user.timezone = 'America/New_York'
|
||||||
|
|
@ -33,6 +24,12 @@ if Rails.env.development?
|
||||||
key.token = 'dev-api-key-12345'
|
key.token = 'dev-api-key-12345'
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Create Admin API Key
|
||||||
|
admin_api_key = AdminApiKey.find_or_create_by(name: 'Development Admin Key') do |key|
|
||||||
|
key.user = test_user
|
||||||
|
key.token = 'dev-admin-api-key-12345'
|
||||||
|
end
|
||||||
|
|
||||||
# Create a sign-in token that doesn't expire
|
# Create a sign-in token that doesn't expire
|
||||||
token = test_user.sign_in_tokens.find_or_create_by(token: 'testing-token') do |t|
|
token = test_user.sign_in_tokens.find_or_create_by(token: 'testing-token') do |t|
|
||||||
t.expires_at = 1.year.from_now
|
t.expires_at = 1.year.from_now
|
||||||
|
|
@ -43,6 +40,7 @@ if Rails.env.development?
|
||||||
puts " Username: #{test_user.display_name}"
|
puts " Username: #{test_user.display_name}"
|
||||||
puts " Email: #{email.email}"
|
puts " Email: #{email.email}"
|
||||||
puts " API Key: #{api_key.token}"
|
puts " API Key: #{api_key.token}"
|
||||||
|
puts " Admin API Key: #{admin_api_key.token}"
|
||||||
puts " Sign-in Token: #{token.token}"
|
puts " Sign-in Token: #{token.token}"
|
||||||
|
|
||||||
# Create sample heartbeats for last 7 days with variety of data
|
# Create sample heartbeats for last 7 days with variety of data
|
||||||
|
|
@ -111,3 +109,40 @@ if Rails.env.development?
|
||||||
else
|
else
|
||||||
puts "Skipping development seed data in #{Rails.env} environment"
|
puts "Skipping development seed data in #{Rails.env} environment"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Use the test user if we have one, otherwise fall back to User ID 1 (for other envs or if test user logic changes)
|
||||||
|
app_owner = test_user || User.find_by(id: 1)
|
||||||
|
|
||||||
|
OauthApplication.find_or_create_by(
|
||||||
|
name: "Hackatime Desktop",
|
||||||
|
owner: app_owner,
|
||||||
|
redirect_uri: "hackatime://auth/callback",
|
||||||
|
uid: "BPr5VekIV-xuQ2ZhmxbGaahJ3XVd7gM83pql-HYGYxQ",
|
||||||
|
scopes: [ "profile" ],
|
||||||
|
confidential: false,
|
||||||
|
)
|
||||||
|
|
||||||
|
if test_user && defined?(Doorkeeper)
|
||||||
|
app = OauthApplication.find_by(name: "Hackatime Desktop")
|
||||||
|
|
||||||
|
existing_token = Doorkeeper::AccessToken.find_by(token: 'dev-api-key-12345')
|
||||||
|
|
||||||
|
if existing_token
|
||||||
|
existing_token.update_columns(
|
||||||
|
application_id: app.id,
|
||||||
|
resource_owner_id: test_user.id,
|
||||||
|
expires_in: nil,
|
||||||
|
scopes: 'profile'
|
||||||
|
)
|
||||||
|
else
|
||||||
|
token = Doorkeeper::AccessToken.find_or_create_by(
|
||||||
|
application_id: app.id,
|
||||||
|
resource_owner_id: test_user.id
|
||||||
|
) do |t|
|
||||||
|
t.expires_in = nil
|
||||||
|
t.scopes = 'profile'
|
||||||
|
end
|
||||||
|
|
||||||
|
token.update_column(:token, 'dev-api-key-12345')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
|
||||||
1
spec/fixtures/heartbeats.json
vendored
Normal file
1
spec/fixtures/heartbeats.json
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
[]
|
||||||
77
spec/rails_helper.rb
Normal file
77
spec/rails_helper.rb
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
# This file is copied to spec/ when you run 'rails generate rspec:install'
|
||||||
|
require 'spec_helper'
|
||||||
|
ENV['RAILS_ENV'] ||= 'test'
|
||||||
|
require_relative '../config/environment'
|
||||||
|
# Prevent database truncation if the environment is production
|
||||||
|
abort("The Rails environment is running in production mode!") if Rails.env.production?
|
||||||
|
# Uncomment the line below in case you have `--require rails_helper` in the `.rspec` file
|
||||||
|
# that will avoid rails generators crashing because migrations haven't been run yet
|
||||||
|
# return unless Rails.env.test?
|
||||||
|
require 'rspec/rails'
|
||||||
|
# Add additional requires below this line. Rails is not loaded until this point!
|
||||||
|
|
||||||
|
# Requires supporting ruby files with custom matchers and macros, etc, in
|
||||||
|
# spec/support/ and its subdirectories. Files matching `spec/**/*_spec.rb` are
|
||||||
|
# run as spec files by default. This means that files in spec/support that end
|
||||||
|
# in _spec.rb will both be required and run as specs, causing the specs to be
|
||||||
|
# run twice. It is recommended that you do not name files matching this glob to
|
||||||
|
# end with _spec.rb. You can configure this pattern with the --pattern
|
||||||
|
# option on the command line or in ~/.rspec, .rspec or `.rspec-local`.
|
||||||
|
#
|
||||||
|
# The following line is provided for convenience purposes. It has the downside
|
||||||
|
# of increasing the boot-up time by auto-requiring all files in the support
|
||||||
|
# directory. Alternatively, in the individual `*_spec.rb` files, manually
|
||||||
|
# require only the support files necessary.
|
||||||
|
#
|
||||||
|
# Rails.root.glob('spec/support/**/*.rb').sort_by(&:to_s).each { |f| require f }
|
||||||
|
|
||||||
|
# Ensures that the test database schema matches the current schema file.
|
||||||
|
# If there are pending migrations it will invoke `db:test:prepare` to
|
||||||
|
# recreate the test database by loading the schema.
|
||||||
|
# If you are not using ActiveRecord, you can remove these lines.
|
||||||
|
begin
|
||||||
|
ActiveRecord::Migration.maintain_test_schema!
|
||||||
|
rescue ActiveRecord::PendingMigrationError => e
|
||||||
|
abort e.to_s.strip
|
||||||
|
end
|
||||||
|
RSpec.configure do |config|
|
||||||
|
# Remove this line if you're not using ActiveRecord or ActiveRecord fixtures
|
||||||
|
config.fixture_paths = [
|
||||||
|
Rails.root.join('spec/fixtures')
|
||||||
|
]
|
||||||
|
|
||||||
|
# If you're not using ActiveRecord, or you'd prefer not to run each of your
|
||||||
|
# examples within a transaction, remove the following line or assign false
|
||||||
|
# instead of true.
|
||||||
|
config.use_transactional_fixtures = true
|
||||||
|
|
||||||
|
# You can uncomment this line to turn off ActiveRecord support entirely.
|
||||||
|
# config.use_active_record = false
|
||||||
|
|
||||||
|
# RSpec Rails uses metadata to mix in different behaviours to your tests,
|
||||||
|
# for example enabling you to call `get` and `post` in request specs. e.g.:
|
||||||
|
#
|
||||||
|
# RSpec.describe UsersController, type: :request do
|
||||||
|
# # ...
|
||||||
|
# end
|
||||||
|
#
|
||||||
|
# The different available types are documented in the features, such as in
|
||||||
|
# https://rspec.info/features/8-0/rspec-rails
|
||||||
|
#
|
||||||
|
# You can also this infer these behaviours automatically by location, e.g.
|
||||||
|
# /spec/models would pull in the same behaviour as `type: :model` but this
|
||||||
|
# behaviour is considered legacy and will be removed in a future version.
|
||||||
|
#
|
||||||
|
# To enable this behaviour uncomment the line below.
|
||||||
|
# config.infer_spec_type_from_file_location!
|
||||||
|
|
||||||
|
# Filter lines from Rails gems in backtraces.
|
||||||
|
config.filter_rails_from_backtrace!
|
||||||
|
# arbitrary gems may also be filtered via:
|
||||||
|
# config.filter_gems_from_backtrace("gem name")
|
||||||
|
|
||||||
|
config.before(:suite) do
|
||||||
|
Rails.application.load_seed
|
||||||
|
ENV['STATS_API_KEY'] = 'dev-api-key-12345'
|
||||||
|
end
|
||||||
|
end
|
||||||
420
spec/requests/api/admin/v1/admin_resources_spec.rb
Normal file
420
spec/requests/api/admin/v1/admin_resources_spec.rb
Normal file
|
|
@ -0,0 +1,420 @@
|
||||||
|
require 'swagger_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Api::Admin::V1::Resources', type: :request do
|
||||||
|
path '/api/admin/v1/admin_api_keys' do
|
||||||
|
get('List Admin API Keys') do
|
||||||
|
tags 'Admin Resources'
|
||||||
|
description 'List all admin API keys.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
admin_api_keys: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
name: { type: :string },
|
||||||
|
token_preview: { type: :string },
|
||||||
|
user: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
display_name: { type: :string },
|
||||||
|
admin_level: { type: :string }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created_at: { type: :string, format: :date_time },
|
||||||
|
revoked_at: { type: :string, format: :date_time, nullable: true },
|
||||||
|
active: { type: :boolean }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
post('Create Admin API Key') do
|
||||||
|
tags 'Admin Resources'
|
||||||
|
description 'Create a new admin API key.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
consumes 'application/json'
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :payload, in: :body, schema: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response(201, 'created') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:payload) { { name: 'New Key' } }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
success: { type: :boolean },
|
||||||
|
message: { type: :string },
|
||||||
|
admin_api_key: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
name: { type: :string },
|
||||||
|
token: { type: :string },
|
||||||
|
created_at: { type: :string, format: :date_time }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/admin_api_keys/{id}' do
|
||||||
|
parameter name: :id, in: :path, type: :string
|
||||||
|
|
||||||
|
get('Show Admin API Key') do
|
||||||
|
tags 'Admin Resources'
|
||||||
|
description 'Show details of an admin API key.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:api_key) do
|
||||||
|
u = User.create!(username: 'key_owner')
|
||||||
|
EmailAddress.create!(user: u, email: 'key_owner@example.com')
|
||||||
|
AdminApiKey.create!(user: u, name: 'Show Key')
|
||||||
|
end
|
||||||
|
let(:id) { api_key.id }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
name: { type: :string },
|
||||||
|
token_preview: { type: :string },
|
||||||
|
user: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
display_name: { type: :string },
|
||||||
|
admin_level: { type: :string }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
created_at: { type: :string, format: :date_time },
|
||||||
|
revoked_at: { type: :string, format: :date_time, nullable: true },
|
||||||
|
active: { type: :boolean }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
delete('Revoke Admin API Key') do
|
||||||
|
tags 'Admin Resources'
|
||||||
|
description 'Revoke/Delete an admin API key.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:api_key_to_revoke) do
|
||||||
|
u = User.create!(username: 'revoke_me')
|
||||||
|
EmailAddress.create!(user: u, email: 'revoke@example.com')
|
||||||
|
AdminApiKey.create!(user: u, name: 'Revoke Key')
|
||||||
|
end
|
||||||
|
let(:id) { api_key_to_revoke.id }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
success: { type: :boolean },
|
||||||
|
message: { type: :string }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/trust_level_audit_logs' do
|
||||||
|
get('List Trust Level Audit Logs') do
|
||||||
|
tags 'Admin Resources'
|
||||||
|
description 'List audit logs for trust level changes.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :user_id, in: :query, type: :string, description: 'Filter by User ID', required: false
|
||||||
|
parameter name: :admin_id, in: :query, type: :string, description: 'Filter by Admin ID', required: false
|
||||||
|
parameter name: :user_search, in: :query, type: :string, description: 'Search user (fuzzy)', required: false
|
||||||
|
parameter name: :admin_search, in: :query, type: :string, description: 'Search admin (fuzzy)', required: false
|
||||||
|
parameter name: :trust_level_filter, in: :query, type: :string, enum: %w[all to_convicted to_trusted to_suspected to_unscored], description: 'Filter by trust level change', required: false
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user_id) { nil }
|
||||||
|
let(:admin_id) { nil }
|
||||||
|
let(:user_search) { nil }
|
||||||
|
let(:admin_search) { nil }
|
||||||
|
let(:trust_level_filter) { nil }
|
||||||
|
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
audit_logs: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
user: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
display_name: { type: :string }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
previous_trust_level: { type: :string, nullable: true },
|
||||||
|
new_trust_level: { type: :string, nullable: true },
|
||||||
|
changed_by: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
display_name: { type: :string },
|
||||||
|
admin_level: { type: :string }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reason: { type: :string, nullable: true },
|
||||||
|
notes: { type: :string, nullable: true },
|
||||||
|
created_at: { type: :string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
total_count: { type: :integer }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/trust_level_audit_logs/{id}' do
|
||||||
|
get('Show Trust Level Audit Log') do
|
||||||
|
tags 'Admin Resources'
|
||||||
|
description 'Show details of a trust level audit log.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :id, in: :path, type: :string
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user) do
|
||||||
|
u = User.create!(username: 'audit_user')
|
||||||
|
EmailAddress.create!(user: u, email: 'audit@example.com')
|
||||||
|
u
|
||||||
|
end
|
||||||
|
let(:admin) do
|
||||||
|
User.find_by(username: 'testuser') || begin
|
||||||
|
u = User.create!(username: 'testuser', admin_level: 'superadmin')
|
||||||
|
EmailAddress.create!(user: u, email: 'admin@example.com')
|
||||||
|
u
|
||||||
|
end
|
||||||
|
end
|
||||||
|
let(:log) do
|
||||||
|
TrustLevelAuditLog.create!(
|
||||||
|
user: user,
|
||||||
|
changed_by: admin,
|
||||||
|
previous_trust_level: 'blue',
|
||||||
|
new_trust_level: 'green',
|
||||||
|
reason: 'Manual verification',
|
||||||
|
notes: 'Looks good'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
let(:id) { log.id }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
user: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
display_name: { type: :string },
|
||||||
|
current_trust_level: { type: :string }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
previous_trust_level: { type: :string, nullable: true },
|
||||||
|
new_trust_level: { type: :string, nullable: true },
|
||||||
|
changed_by: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
display_name: { type: :string },
|
||||||
|
admin_level: { type: :string }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
reason: { type: :string, nullable: true },
|
||||||
|
notes: { type: :string, nullable: true },
|
||||||
|
created_at: { type: :string, format: :date_time },
|
||||||
|
updated_at: { type: :string, format: :date_time }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/deletion_requests' do
|
||||||
|
get('List Deletion Requests') do
|
||||||
|
tags 'Admin Resources'
|
||||||
|
description 'List pending deletion requests.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
pending: {
|
||||||
|
type: :array,
|
||||||
|
items: { '$ref' => '#/components/schemas/DeletionRequest' }
|
||||||
|
},
|
||||||
|
approved: {
|
||||||
|
type: :array,
|
||||||
|
items: { '$ref' => '#/components/schemas/DeletionRequest' }
|
||||||
|
},
|
||||||
|
completed: {
|
||||||
|
type: :array,
|
||||||
|
items: { '$ref' => '#/components/schemas/DeletionRequest' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/deletion_requests/{id}' do
|
||||||
|
parameter name: :id, in: :path, type: :string
|
||||||
|
|
||||||
|
get('Show Deletion Request') do
|
||||||
|
tags 'Admin Resources'
|
||||||
|
description 'Show details of a deletion request.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user) do
|
||||||
|
u = User.create!(username: 'delete_me')
|
||||||
|
EmailAddress.create!(user: u, email: 'delete@example.com')
|
||||||
|
u
|
||||||
|
end
|
||||||
|
let(:deletion_request) { DeletionRequest.create!(user: user, status: 0, requested_at: Time.current) }
|
||||||
|
let(:id) { deletion_request.id }
|
||||||
|
schema '$ref' => '#/components/schemas/DeletionRequest'
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/deletion_requests/{id}/approve' do
|
||||||
|
post('Approve Deletion Request') do
|
||||||
|
tags 'Admin Resources'
|
||||||
|
description 'Approve and execute a user deletion request.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
parameter name: :id, in: :path, type: :string
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user) do
|
||||||
|
u = User.create!(username: 'reject_me')
|
||||||
|
EmailAddress.create!(user: u, email: 'reject@example.com')
|
||||||
|
u
|
||||||
|
end
|
||||||
|
let(:deletion_request) { DeletionRequest.create!(user: user, status: 0, requested_at: Time.current) }
|
||||||
|
let(:id) { deletion_request.id }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
success: { type: :boolean },
|
||||||
|
message: { type: :string },
|
||||||
|
deletion_request: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
user_id: { type: :integer },
|
||||||
|
user: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
display_name: { type: :string }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status: { type: :string, enum: %w[pending approved cancelled completed] },
|
||||||
|
requested_at: { type: :string, format: :date_time },
|
||||||
|
scheduled_deletion_at: { type: :string, format: :date_time, nullable: true },
|
||||||
|
completed_at: { type: :string, format: :date_time, nullable: true },
|
||||||
|
admin_approved_by: { type: :object, nullable: true },
|
||||||
|
created_at: { type: :string, format: :date_time },
|
||||||
|
updated_at: { type: :string, format: :date_time }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/deletion_requests/{id}/reject' do
|
||||||
|
post('Reject Deletion Request') do
|
||||||
|
tags 'Admin Resources'
|
||||||
|
description 'Reject a user deletion request.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
parameter name: :id, in: :path, type: :string
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user) do
|
||||||
|
u = User.create!(username: 'reject_me_please')
|
||||||
|
EmailAddress.create!(user: u, email: 'reject_me_please@example.com')
|
||||||
|
u
|
||||||
|
end
|
||||||
|
let(:deletion_request) { DeletionRequest.create!(user: user, status: 0, requested_at: Time.current) }
|
||||||
|
let(:id) { deletion_request.id }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
success: { type: :boolean },
|
||||||
|
message: { type: :string },
|
||||||
|
deletion_request: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
user_id: { type: :integer },
|
||||||
|
user: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
display_name: { type: :string }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
status: { type: :string, enum: %w[pending approved cancelled completed] },
|
||||||
|
requested_at: { type: :string, format: :date_time },
|
||||||
|
scheduled_deletion_at: { type: :string, format: :date_time, nullable: true },
|
||||||
|
completed_at: { type: :string, format: :date_time, nullable: true },
|
||||||
|
admin_approved_by: { type: :object, nullable: true },
|
||||||
|
created_at: { type: :string, format: :date_time },
|
||||||
|
updated_at: { type: :string, format: :date_time }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
221
spec/requests/api/admin/v1/admin_timeline_spec.rb
Normal file
221
spec/requests/api/admin/v1/admin_timeline_spec.rb
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
require 'swagger_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Admin::Timeline', type: :request do
|
||||||
|
path '/api/admin/v1/timeline' do
|
||||||
|
get('Get timeline') do
|
||||||
|
tags 'Admin Timeline'
|
||||||
|
description 'Get timeline events including coding activity and commits for selected users.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :date, in: :query, type: :string, format: :date, description: 'Date for the timeline (YYYY-MM-DD)'
|
||||||
|
parameter name: :user_ids, in: :query, type: :string, description: 'Comma-separated list of User IDs'
|
||||||
|
parameter name: :slack_uids, in: :query, type: :string, description: 'Comma-separated list of Slack User IDs'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
date: { type: :string, format: :date },
|
||||||
|
next_date: { type: :string, format: :date },
|
||||||
|
prev_date: { type: :string, format: :date },
|
||||||
|
users: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
user: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
display_name: { type: :string, nullable: true },
|
||||||
|
slack_username: { type: :string, nullable: true },
|
||||||
|
github_username: { type: :string, nullable: true },
|
||||||
|
timezone: { type: :string, nullable: true },
|
||||||
|
avatar_url: { type: :string, nullable: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
spans: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
start_time: { type: :number, format: :float },
|
||||||
|
end_time: { type: :number, format: :float },
|
||||||
|
duration: { type: :number, format: :float },
|
||||||
|
files_edited: { type: :array, items: { type: :string } },
|
||||||
|
projects_edited_details: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string },
|
||||||
|
repo_url: { type: :string, nullable: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
editors: { type: :array, items: { type: :string } },
|
||||||
|
languages: { type: :array, items: { type: :string } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
total_coded_time: { type: :number, format: :float }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
commit_markers: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
user_id: { type: :integer },
|
||||||
|
timestamp: { type: :number, format: :float },
|
||||||
|
additions: { type: :integer, nullable: true },
|
||||||
|
deletions: { type: :integer, nullable: true },
|
||||||
|
github_url: { type: :string, nullable: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:date) { Time.current.to_date.to_s }
|
||||||
|
let(:user_ids) { User.first&.id.to_s }
|
||||||
|
let(:slack_uids) { nil }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
date: { type: :string, format: :date },
|
||||||
|
next_date: { type: :string, format: :date },
|
||||||
|
prev_date: { type: :string, format: :date },
|
||||||
|
users: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
user: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
display_name: { type: :string, nullable: true },
|
||||||
|
slack_username: { type: :string, nullable: true },
|
||||||
|
github_username: { type: :string, nullable: true },
|
||||||
|
timezone: { type: :string, nullable: true },
|
||||||
|
avatar_url: { type: :string, nullable: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
spans: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
start_time: { type: :number, format: :float },
|
||||||
|
end_time: { type: :number, format: :float },
|
||||||
|
duration: { type: :number, format: :float },
|
||||||
|
files_edited: { type: :array, items: { type: :string } },
|
||||||
|
projects_edited_details: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string },
|
||||||
|
repo_url: { type: :string, nullable: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
editors: { type: :array, items: { type: :string } },
|
||||||
|
languages: { type: :array, items: { type: :string } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
total_coded_time: { type: :number, format: :float }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
commit_markers: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
user_id: { type: :integer },
|
||||||
|
timestamp: { type: :number, format: :float },
|
||||||
|
additions: { type: :integer, nullable: true },
|
||||||
|
deletions: { type: :integer, nullable: true },
|
||||||
|
github_url: { type: :string, nullable: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/timeline/search_users' do
|
||||||
|
get('Search timeline users') do
|
||||||
|
tags 'Admin Timeline'
|
||||||
|
description 'Search users specifically for the timeline view by username, slack username, ID, or email.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :query, in: :query, type: :string, description: 'Search query'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:query) { User.first&.username || 'admin' }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
users: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
display_name: { type: :string, nullable: true },
|
||||||
|
avatar_url: { type: :string, nullable: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(422, 'unprocessable entity') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:query) { '' }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/timeline/leaderboard_users' do
|
||||||
|
get('Get leaderboard users for timeline') do
|
||||||
|
tags 'Admin Timeline'
|
||||||
|
description 'Get users who should appear on the timeline leaderboard based on recent activity.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :period, in: :query, type: :string, enum: [ 'daily', 'last_7_days' ], description: 'Leaderboard period'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:period) { 'last_7_days' }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
users: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
display_name: { type: :string, nullable: true },
|
||||||
|
avatar_url: { type: :string, nullable: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
598
spec/requests/api/admin/v1/admin_user_utils_spec.rb
Normal file
598
spec/requests/api/admin/v1/admin_user_utils_spec.rb
Normal file
|
|
@ -0,0 +1,598 @@
|
||||||
|
require 'swagger_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Api::Admin::V1::UserUtils', type: :request do
|
||||||
|
path '/api/admin/v1/user/info_batch' do
|
||||||
|
get('Get user info batch') do
|
||||||
|
tags 'Admin Utils'
|
||||||
|
description 'Get info for multiple users.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :ids, in: :query, type: :array, items: { type: :integer }, description: 'User IDs'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:u1) do
|
||||||
|
u = User.create!(username: 'u1')
|
||||||
|
EmailAddress.create!(user: u, email: 'u1@example.com')
|
||||||
|
u
|
||||||
|
end
|
||||||
|
let(:u2) do
|
||||||
|
u = User.create!(username: 'u2')
|
||||||
|
EmailAddress.create!(user: u, email: 'u2@example.com')
|
||||||
|
u
|
||||||
|
end
|
||||||
|
let(:ids) { [ u1.id, u2.id ] }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
users: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
display_name: { type: :string },
|
||||||
|
slack_uid: { type: :string, nullable: true },
|
||||||
|
slack_username: { type: :string, nullable: true },
|
||||||
|
github_username: { type: :string, nullable: true },
|
||||||
|
timezone: { type: :string, nullable: true },
|
||||||
|
country_code: { type: :string, nullable: true },
|
||||||
|
trust_level: { type: :string },
|
||||||
|
avatar_url: { type: :string, nullable: true },
|
||||||
|
slack_avatar_url: { type: :string, nullable: true },
|
||||||
|
github_avatar_url: { type: :string, nullable: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/user/info' do
|
||||||
|
get('Get user info') do
|
||||||
|
tags 'Admin Utils'
|
||||||
|
description 'Get detailed info for a single user.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :user_id, in: :query, type: :string, description: 'User ID'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user) do
|
||||||
|
u = User.create!(username: 'info_user')
|
||||||
|
EmailAddress.create!(user: u, email: 'info@example.com')
|
||||||
|
u
|
||||||
|
end
|
||||||
|
let(:user_id) { user.id }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
user: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
display_name: { type: :string },
|
||||||
|
slack_uid: { type: :string, nullable: true },
|
||||||
|
slack_username: { type: :string, nullable: true },
|
||||||
|
github_username: { type: :string, nullable: true },
|
||||||
|
timezone: { type: :string, nullable: true },
|
||||||
|
country_code: { type: :string, nullable: true },
|
||||||
|
admin_level: { type: :string },
|
||||||
|
trust_level: { type: :string },
|
||||||
|
suspected: { type: :boolean },
|
||||||
|
banned: { type: :boolean },
|
||||||
|
created_at: { type: :string, format: :date_time },
|
||||||
|
updated_at: { type: :string, format: :date_time },
|
||||||
|
last_heartbeat_at: { type: :number, nullable: true },
|
||||||
|
email_addresses: { type: :array, items: { type: :string } },
|
||||||
|
api_keys_count: { type: :integer },
|
||||||
|
stats: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
total_heartbeats: { type: :integer },
|
||||||
|
total_coding_time: { type: :number },
|
||||||
|
languages_used: { type: :integer },
|
||||||
|
projects_worked_on: { type: :integer },
|
||||||
|
days_active: { type: :integer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/user/heartbeats' do
|
||||||
|
get('Get admin user heartbeats') do
|
||||||
|
tags 'Admin Utils'
|
||||||
|
description 'Get heartbeats for a user (Admin view).'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :user_id, in: :query, type: :string, description: 'User ID'
|
||||||
|
parameter name: :start_date, in: :query, type: :string, description: 'Start date (YYYY-MM-DD or timestamp)'
|
||||||
|
parameter name: :end_date, in: :query, type: :string, description: 'End date (YYYY-MM-DD or timestamp)'
|
||||||
|
parameter name: :project, in: :query, type: :string, description: 'Project name'
|
||||||
|
parameter name: :language, in: :query, type: :string, description: 'Language'
|
||||||
|
parameter name: :entity, in: :query, type: :string, description: 'Entity (file path or app name)'
|
||||||
|
parameter name: :editor, in: :query, type: :string, description: 'Editor'
|
||||||
|
parameter name: :machine, in: :query, type: :string, description: 'Machine'
|
||||||
|
parameter name: :limit, in: :query, type: :integer, description: 'Limit'
|
||||||
|
parameter name: :offset, in: :query, type: :integer, description: 'Offset'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
user_id: { type: :integer },
|
||||||
|
heartbeats: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
time: { type: :number },
|
||||||
|
lineno: { type: :integer, nullable: true },
|
||||||
|
cursorpos: { type: :integer, nullable: true },
|
||||||
|
is_write: { type: :boolean, nullable: true },
|
||||||
|
project: { type: :string, nullable: true },
|
||||||
|
language: { type: :string, nullable: true },
|
||||||
|
entity: { type: :string, nullable: true },
|
||||||
|
branch: { type: :string, nullable: true },
|
||||||
|
category: { type: :string, nullable: true },
|
||||||
|
editor: { type: :string, nullable: true },
|
||||||
|
machine: { type: :string, nullable: true },
|
||||||
|
user_agent: { type: :string, nullable: true },
|
||||||
|
ip_address: { type: :string, nullable: true },
|
||||||
|
lines: { type: :integer, nullable: true },
|
||||||
|
source_type: { type: :string, nullable: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
total_count: { type: :integer },
|
||||||
|
has_more: { type: :boolean }
|
||||||
|
}
|
||||||
|
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user) do
|
||||||
|
u = User.create!(username: 'hb_user')
|
||||||
|
EmailAddress.create!(user: u, email: 'hb@example.com')
|
||||||
|
u
|
||||||
|
end
|
||||||
|
let(:user_id) { user.id }
|
||||||
|
let(:start_date) { nil }
|
||||||
|
let(:end_date) { nil }
|
||||||
|
let(:project) { nil }
|
||||||
|
let(:language) { nil }
|
||||||
|
let(:entity) { nil }
|
||||||
|
let(:editor) { nil }
|
||||||
|
let(:machine) { nil }
|
||||||
|
let(:limit) { 10 }
|
||||||
|
let(:offset) { 0 }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(404, 'user not found') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user_id) { '0' }
|
||||||
|
let(:start_date) { nil }
|
||||||
|
let(:end_date) { nil }
|
||||||
|
let(:project) { nil }
|
||||||
|
let(:language) { nil }
|
||||||
|
let(:entity) { nil }
|
||||||
|
let(:editor) { nil }
|
||||||
|
let(:machine) { nil }
|
||||||
|
let(:limit) { 10 }
|
||||||
|
let(:offset) { 0 }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/user/heartbeat_values' do
|
||||||
|
get('Get heartbeat values') do
|
||||||
|
tags 'Admin Utils'
|
||||||
|
description 'Get specific values from heartbeats.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :user_id, in: :query, type: :string, description: 'User ID'
|
||||||
|
parameter name: :field, in: :query, type: :string, description: 'Field to retrieve (projects, languages, etc.)'
|
||||||
|
parameter name: :start_date, in: :query, type: :string, description: 'Start date (YYYY-MM-DD or timestamp)'
|
||||||
|
parameter name: :end_date, in: :query, type: :string, description: 'End date (YYYY-MM-DD or timestamp)'
|
||||||
|
parameter name: :limit, in: :query, type: :integer, description: 'Limit results'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
user_id: { type: :integer },
|
||||||
|
field: { type: :string },
|
||||||
|
values: { type: :array, items: { type: :string } },
|
||||||
|
count: { type: :integer }
|
||||||
|
}
|
||||||
|
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user) do
|
||||||
|
u = User.create!(username: 'projects_user')
|
||||||
|
EmailAddress.create!(user: u, email: 'projects@example.com')
|
||||||
|
u
|
||||||
|
end
|
||||||
|
let(:user_id) { user.id }
|
||||||
|
let(:field) { 'projects' }
|
||||||
|
let(:start_date) { nil }
|
||||||
|
let(:end_date) { nil }
|
||||||
|
let(:limit) { 5000 }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(404, 'user not found') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user_id) { '0' }
|
||||||
|
let(:field) { 'projects' }
|
||||||
|
let(:start_date) { nil }
|
||||||
|
let(:end_date) { nil }
|
||||||
|
let(:limit) { 5000 }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/user/get_users_by_ip' do
|
||||||
|
get('Get users by IP') do
|
||||||
|
tags 'Admin Utils'
|
||||||
|
description 'Find users associated with an IP address.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :ip, in: :query, type: :string, description: 'IP Address'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:ip) { '127.0.0.1' }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
users: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
user_id: { type: :integer },
|
||||||
|
ip_address: { type: :string },
|
||||||
|
machine: { type: :string, nullable: true },
|
||||||
|
user_agent: { type: :string, nullable: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/user/get_users_by_machine' do
|
||||||
|
get('Get users by machine') do
|
||||||
|
tags 'Admin Utils'
|
||||||
|
description 'Find users associated with a machine ID.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :machine, in: :query, type: :string, description: 'Machine ID'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:machine) { 'some-machine-id' }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
users: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
user_id: { type: :integer },
|
||||||
|
machine: { type: :string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/user/stats' do
|
||||||
|
get('Get admin user stats') do
|
||||||
|
tags 'Admin Utils'
|
||||||
|
description 'Get detailed stats for a user (Admin view).'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :user_id, in: :query, type: :string, description: 'User ID'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user) do
|
||||||
|
u = User.create!(username: 'stats_user')
|
||||||
|
EmailAddress.create!(user: u, email: 'stats@example.com')
|
||||||
|
u
|
||||||
|
end
|
||||||
|
let(:user_id) { user.id }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
user_id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
date: { type: :string, format: :date_time },
|
||||||
|
timezone: { type: :string, nullable: true },
|
||||||
|
total_heartbeats: { type: :integer },
|
||||||
|
total_duration: { type: :number },
|
||||||
|
heartbeats: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
time: { type: :string },
|
||||||
|
created_at: { type: :string, format: :date_time },
|
||||||
|
project: { type: :string, nullable: true },
|
||||||
|
branch: { type: :string, nullable: true },
|
||||||
|
category: { type: :string, nullable: true },
|
||||||
|
dependencies: { type: :string, nullable: true },
|
||||||
|
editor: { type: :string, nullable: true },
|
||||||
|
entity: { type: :string, nullable: true },
|
||||||
|
language: { type: :string, nullable: true },
|
||||||
|
machine: { type: :string, nullable: true },
|
||||||
|
operating_system: { type: :string, nullable: true },
|
||||||
|
type: { type: :string, nullable: true },
|
||||||
|
user_agent: { type: :string, nullable: true },
|
||||||
|
line_additions: { type: :integer, nullable: true },
|
||||||
|
line_deletions: { type: :integer, nullable: true },
|
||||||
|
lineno: { type: :integer, nullable: true },
|
||||||
|
lines: { type: :integer, nullable: true },
|
||||||
|
cursorpos: { type: :integer, nullable: true },
|
||||||
|
project_root_count: { type: :integer, nullable: true },
|
||||||
|
is_write: { type: :boolean, nullable: true },
|
||||||
|
source_type: { type: :string, nullable: true },
|
||||||
|
ysws_program: { type: :string, nullable: true },
|
||||||
|
ip_address: { type: :string, nullable: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(404, 'user not found') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user_id) { '0' }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/user/projects' do
|
||||||
|
get('Get admin user projects') do
|
||||||
|
tags 'Admin Utils'
|
||||||
|
description 'Get projects for a user (Admin view).'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :user_id, in: :query, type: :string, description: 'User ID'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user) do
|
||||||
|
u = User.create!(username: 'hb_values_user')
|
||||||
|
EmailAddress.create!(user: u, email: 'hb_vals@example.com')
|
||||||
|
u
|
||||||
|
end
|
||||||
|
let(:user_id) { user.id }
|
||||||
|
let(:field) { 'projects' }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
user_id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
total_projects: { type: :integer },
|
||||||
|
projects: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string, nullable: true },
|
||||||
|
total_heartbeats: { type: :integer },
|
||||||
|
total_duration: { type: :number },
|
||||||
|
first_heartbeat: { type: :number, nullable: true },
|
||||||
|
last_heartbeat: { type: :number, nullable: true },
|
||||||
|
languages: { type: :array, items: { type: :string } },
|
||||||
|
repo: { type: :string, nullable: true },
|
||||||
|
repo_mapping_id: { type: :integer, nullable: true },
|
||||||
|
archived: { type: :boolean }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(404, 'user not found') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user_id) { '0' }
|
||||||
|
let(:field) { 'projects' }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/user/trust_logs' do
|
||||||
|
get('Get user trust logs') do
|
||||||
|
tags 'Admin Utils'
|
||||||
|
description 'Get trust level audit logs for a user.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :user_id, in: :query, type: :string, description: 'User ID'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user_id) { '1' }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
trust_logs: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
previous_trust_level: { type: :string, nullable: true },
|
||||||
|
new_trust_level: { type: :string },
|
||||||
|
reason: { type: :string, nullable: true },
|
||||||
|
notes: { type: :string, nullable: true },
|
||||||
|
created_at: { type: :string, format: :date_time },
|
||||||
|
changed_by: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
display_name: { type: :string },
|
||||||
|
admin_level: { type: :string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/user/get_user_by_email' do
|
||||||
|
post('Get user by email') do
|
||||||
|
tags 'Admin Utils'
|
||||||
|
description 'Lookup user by email (POST).'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
consumes 'application/json'
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :payload, in: :body, schema: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
email: { type: :string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:payload) { { email: 'test@example.com' } }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
user_id: { type: :integer }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/user/search_fuzzy' do
|
||||||
|
post('Fuzzy search users') do
|
||||||
|
tags 'Admin Utils'
|
||||||
|
description 'Search users by fuzzy matching.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
consumes 'application/json'
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :payload, in: :body, schema: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
query: { type: :string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:payload) { { query: 'test' } }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
users: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
slack_username: { type: :string, nullable: true },
|
||||||
|
github_username: { type: :string, nullable: true },
|
||||||
|
slack_avatar_url: { type: :string, nullable: true },
|
||||||
|
github_avatar_url: { type: :string, nullable: true },
|
||||||
|
email: { type: :string },
|
||||||
|
rank_score: { type: :number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/user/convict' do
|
||||||
|
post('Convict user') do
|
||||||
|
tags 'Admin Utils'
|
||||||
|
description 'Mark a user as convicted/banned.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
consumes 'application/json'
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :payload, in: :body, schema: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
user_id: { type: :integer },
|
||||||
|
reason: { type: :string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user) do
|
||||||
|
u = User.create!(username: 'convict_me')
|
||||||
|
EmailAddress.create!(user: u, email: 'convict@example.com')
|
||||||
|
u
|
||||||
|
end
|
||||||
|
let(:payload) { { user_id: user.id, reason: 'spam', trust_level: 'red' } }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
success: { type: :boolean },
|
||||||
|
message: { type: :string },
|
||||||
|
user: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
trust_level: { type: :string },
|
||||||
|
updated_at: { type: :string, format: :date_time }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
audit_log: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
changed_by: { type: :string },
|
||||||
|
reason: { type: :string },
|
||||||
|
notes: { type: :string, nullable: true },
|
||||||
|
timestamp: { type: :string, format: :date_time }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(404, 'user not found') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:payload) { { user_id: 0, reason: 'spam', trust_level: 'red' } }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
54
spec/requests/api/admin/v1/admin_users_spec.rb
Normal file
54
spec/requests/api/admin/v1/admin_users_spec.rb
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
require 'swagger_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Api::Admin::V1::AdminUsers', type: :request do
|
||||||
|
path '/api/admin/v1/user/info' do
|
||||||
|
get('Get user info (Admin)') do
|
||||||
|
tags 'Admin'
|
||||||
|
description 'Get detailed info about a user. Requires superadmin/admin privileges.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :user_id, in: :query, type: :string, description: 'User ID'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user_id) { '1' }
|
||||||
|
let(:date) { '2023-01-01' }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/user/heartbeats' do
|
||||||
|
get('Get user heartbeats (Admin)') do
|
||||||
|
tags 'Admin'
|
||||||
|
description 'Get raw heartbeats for a user.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :user_id, in: :query, type: :string, description: 'User ID'
|
||||||
|
parameter name: :date, in: :query, type: :string, format: :date, description: 'Date (YYYY-MM-DD)'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user_id) { '1' }
|
||||||
|
let(:date) { '2023-01-01' }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/check' do
|
||||||
|
get('Check status') do
|
||||||
|
tags 'Admin'
|
||||||
|
description 'Check if admin API is working.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
108
spec/requests/api/admin/v1/permissions_spec.rb
Normal file
108
spec/requests/api/admin/v1/permissions_spec.rb
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
require 'swagger_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Api::Admin::V1::Permissions', type: :request do
|
||||||
|
path '/api/admin/v1/permissions' do
|
||||||
|
get('List Permissions') do
|
||||||
|
tags 'Admin Resources'
|
||||||
|
description 'List system permissions. Requires superadmin privileges.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :search, in: :query, type: :string, description: 'Search query'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
users: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
display_name: { type: :string, nullable: true },
|
||||||
|
slack_username: { type: :string, nullable: true },
|
||||||
|
github_username: { type: :string, nullable: true },
|
||||||
|
admin_level: { type: :string },
|
||||||
|
email_addresses: { type: :array, items: { type: :string } },
|
||||||
|
created_at: { type: :string },
|
||||||
|
updated_at: { type: :string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:search) { 'foo' }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/admin/v1/permissions/{id}' do
|
||||||
|
patch('Update Permission') do
|
||||||
|
tags 'Admin Resources'
|
||||||
|
description 'Update a user\'s admin level. Requires superadmin privileges.'
|
||||||
|
security [ AdminToken: [] ]
|
||||||
|
consumes 'application/json'
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :id, in: :path, type: :string
|
||||||
|
parameter name: :permission, in: :body, schema: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
admin_level: { type: :string, enum: [ 'superadmin', 'admin', 'viewer', 'default' ] }
|
||||||
|
},
|
||||||
|
required: [ 'admin_level' ]
|
||||||
|
}
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
success: { type: :boolean },
|
||||||
|
message: { type: :string },
|
||||||
|
user: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
display_name: { type: :string, nullable: true },
|
||||||
|
admin_level: { type: :string },
|
||||||
|
previous_admin_level: { type: :string },
|
||||||
|
updated_at: { type: :string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user) do
|
||||||
|
u = User.create!(username: 'perm_user')
|
||||||
|
EmailAddress.create!(user: u, email: 'perm@example.com')
|
||||||
|
u
|
||||||
|
end
|
||||||
|
let(:id) { user.id }
|
||||||
|
let(:permission) { { admin_level: 'superadmin' } }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(404, 'not found handled') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:id) { '0' }
|
||||||
|
let(:permission) { { admin_level: 'superadmin' } }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(422, 'validation error handled') do
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:user) do
|
||||||
|
u = User.create!(username: 'perm_val_user')
|
||||||
|
EmailAddress.create!(user: u, email: 'perm_val@example.com')
|
||||||
|
u
|
||||||
|
end
|
||||||
|
let(:id) { user.id }
|
||||||
|
let(:permission) { { admin_level: 'invalid' } }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
249
spec/requests/api/hackatime/v1/compatibility_spec.rb
Normal file
249
spec/requests/api/hackatime/v1/compatibility_spec.rb
Normal file
|
|
@ -0,0 +1,249 @@
|
||||||
|
require 'swagger_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Api::Hackatime::V1::Compatibility', type: :request do
|
||||||
|
path '/api/hackatime/v1/users/{id}/heartbeats' do
|
||||||
|
post('Push heartbeats (WakaTime compatible)') do
|
||||||
|
tags 'WakaTime Compatibility'
|
||||||
|
description 'Endpoint used by WakaTime plugins to send heartbeat data to the server. This is the core endpoint for tracking time.'
|
||||||
|
consumes 'application/json'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
|
||||||
|
parameter name: :id, in: :path, type: :string, description: 'User ID or "current" (recommended)'
|
||||||
|
parameter name: :heartbeats, in: :body, schema: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
entity: { type: :string },
|
||||||
|
type: { type: :string },
|
||||||
|
time: { type: :number },
|
||||||
|
project: { type: :string },
|
||||||
|
branch: { type: :string },
|
||||||
|
language: { type: :string },
|
||||||
|
is_write: { type: :boolean },
|
||||||
|
lineno: { type: :integer },
|
||||||
|
cursorpos: { type: :integer },
|
||||||
|
lines: { type: :integer },
|
||||||
|
category: { type: :string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
response(202, 'accepted') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:id) { 'current' }
|
||||||
|
let(:heartbeats) { [ { entity: 'file.rb', time: Time.now.to_f } ] }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
entity: { type: :string },
|
||||||
|
type: { type: :string, nullable: true },
|
||||||
|
time: { type: :number },
|
||||||
|
project: { type: :string, nullable: true },
|
||||||
|
branch: { type: :string, nullable: true },
|
||||||
|
language: { type: :string, nullable: true },
|
||||||
|
is_write: { type: :boolean, nullable: true },
|
||||||
|
lineno: { type: :integer, nullable: true },
|
||||||
|
cursorpos: { type: :integer, nullable: true },
|
||||||
|
lines: { type: :integer, nullable: true },
|
||||||
|
category: { type: :string },
|
||||||
|
created_at: { type: :string, format: :date_time },
|
||||||
|
user_id: { type: :integer },
|
||||||
|
editor: { type: :string, nullable: true },
|
||||||
|
operating_system: { type: :string, nullable: true },
|
||||||
|
machine: { type: :string, nullable: true },
|
||||||
|
user_agent: { type: :string, nullable: true }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(401, 'unauthorized') do
|
||||||
|
let(:Authorization) { 'Bearer invalid' }
|
||||||
|
let(:api_key) { 'invalid' }
|
||||||
|
let(:id) { 'current' }
|
||||||
|
let(:heartbeats) { [] }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/hackatime/v1/users/{id}/statusbar/today' do
|
||||||
|
get('Get status bar today') do
|
||||||
|
tags 'WakaTime Compatibility'
|
||||||
|
description 'Returns the total coding time for today. Used by editor plugins to display the status bar widget.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :id, in: :path, type: :string, description: 'User ID or "current"'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:id) { 'current' }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
data: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
grand_total: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
total_seconds: { type: :number, example: 7200.0 },
|
||||||
|
text: { type: :string, example: '2 hrs' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(401, 'unauthorized') do
|
||||||
|
let(:Authorization) { 'Bearer invalid' }
|
||||||
|
let(:api_key) { 'invalid' }
|
||||||
|
let(:id) { 'current' }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/hackatime/v1/users/current/stats/last_7_days' do
|
||||||
|
get('Get last 7 days stats') do
|
||||||
|
tags 'WakaTime Compatibility'
|
||||||
|
description 'Returns coding statistics for the last 7 days. Used by some WakaTime dashboards.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
data: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
username: { type: :string },
|
||||||
|
user_id: { type: :string },
|
||||||
|
start: { type: :string, format: :date_time },
|
||||||
|
end: { type: :string, format: :date_time },
|
||||||
|
status: { type: :string },
|
||||||
|
total_seconds: { type: :number },
|
||||||
|
daily_average: { type: :number },
|
||||||
|
days_including_holidays: { type: :integer },
|
||||||
|
range: { type: :string },
|
||||||
|
human_readable_range: { type: :string },
|
||||||
|
human_readable_total: { type: :string },
|
||||||
|
human_readable_daily_average: { type: :string },
|
||||||
|
is_coding_activity_visible: { type: :boolean },
|
||||||
|
is_other_usage_visible: { type: :boolean },
|
||||||
|
editors: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string },
|
||||||
|
total_seconds: { type: :integer },
|
||||||
|
percent: { type: :number },
|
||||||
|
digital: { type: :string },
|
||||||
|
text: { type: :string },
|
||||||
|
hours: { type: :integer },
|
||||||
|
minutes: { type: :integer },
|
||||||
|
seconds: { type: :integer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
languages: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string },
|
||||||
|
total_seconds: { type: :integer },
|
||||||
|
percent: { type: :number },
|
||||||
|
digital: { type: :string },
|
||||||
|
text: { type: :string },
|
||||||
|
hours: { type: :integer },
|
||||||
|
minutes: { type: :integer },
|
||||||
|
seconds: { type: :integer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
machines: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string },
|
||||||
|
total_seconds: { type: :integer },
|
||||||
|
percent: { type: :number },
|
||||||
|
digital: { type: :string },
|
||||||
|
text: { type: :string },
|
||||||
|
hours: { type: :integer },
|
||||||
|
minutes: { type: :integer },
|
||||||
|
seconds: { type: :integer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string },
|
||||||
|
total_seconds: { type: :integer },
|
||||||
|
percent: { type: :number },
|
||||||
|
digital: { type: :string },
|
||||||
|
text: { type: :string },
|
||||||
|
hours: { type: :integer },
|
||||||
|
minutes: { type: :integer },
|
||||||
|
seconds: { type: :integer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
operating_systems: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string },
|
||||||
|
total_seconds: { type: :integer },
|
||||||
|
percent: { type: :number },
|
||||||
|
digital: { type: :string },
|
||||||
|
text: { type: :string },
|
||||||
|
hours: { type: :integer },
|
||||||
|
minutes: { type: :integer },
|
||||||
|
seconds: { type: :integer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
categories: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string },
|
||||||
|
total_seconds: { type: :integer },
|
||||||
|
percent: { type: :number },
|
||||||
|
digital: { type: :string },
|
||||||
|
text: { type: :string },
|
||||||
|
hours: { type: :integer },
|
||||||
|
minutes: { type: :integer },
|
||||||
|
seconds: { type: :integer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(401, 'unauthorized') do
|
||||||
|
let(:Authorization) { 'Bearer invalid' }
|
||||||
|
let(:api_key) { 'invalid' }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
105
spec/requests/api/internal/internal_spec.rb
Normal file
105
spec/requests/api/internal/internal_spec.rb
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
require 'swagger_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Api::Internal', type: :request do
|
||||||
|
path '/api/internal/revoke' do
|
||||||
|
post('Revoke access') do
|
||||||
|
tags 'Internal'
|
||||||
|
description 'Internal endpoint to revoke access tokens. Use with caution. Requires HKA_REVOCATION_KEY environment variable authentication. This is used for Revoker to allow security researchers to revoke compromised tokens.'
|
||||||
|
security [ InternalToken: [] ]
|
||||||
|
consumes 'application/json'
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :payload, in: :body, schema: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
token: { type: :string }
|
||||||
|
},
|
||||||
|
required: [ 'token' ]
|
||||||
|
}
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer test_revocation_key" }
|
||||||
|
let(:payload) { { token: 'some_token' } }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(ENV).to receive(:[]).and_call_original
|
||||||
|
allow(ENV).to receive(:[]).with("HKA_REVOCATION_KEY").and_return("test_revocation_key")
|
||||||
|
allow(ActiveSupport::SecurityUtils).to receive(:secure_compare).with("test_revocation_key", "test_revocation_key").and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
success: { type: :boolean },
|
||||||
|
owner_email: { type: :string, nullable: true },
|
||||||
|
key_name: { type: :string, nullable: true }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(400, 'bad request') do
|
||||||
|
let(:Authorization) { "Bearer test_revocation_key" }
|
||||||
|
let(:payload) { { token: nil } }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(ENV).to receive(:[]).and_call_original
|
||||||
|
allow(ENV).to receive(:[]).with("HKA_REVOCATION_KEY").and_return("test_revocation_key")
|
||||||
|
allow(ActiveSupport::SecurityUtils).to receive(:secure_compare).with("test_revocation_key", "test_revocation_key").and_return(true)
|
||||||
|
end
|
||||||
|
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/internal/can_i_have_a_magic_link_for/{id}' do
|
||||||
|
post('Create magic link') do
|
||||||
|
tags 'Internal'
|
||||||
|
description 'Internal endpoint to generate magic login links for users via Slack UID and Email.'
|
||||||
|
security [ InternalToken: [] ]
|
||||||
|
consumes 'application/json'
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :id, in: :path, type: :string, description: 'Slack UID'
|
||||||
|
parameter name: :payload, in: :body, schema: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
email: { type: :string, format: :email },
|
||||||
|
continue_param: { type: :string },
|
||||||
|
return_data: { type: :object }
|
||||||
|
},
|
||||||
|
required: [ 'email' ]
|
||||||
|
}
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:id) { 'U123456' }
|
||||||
|
let(:payload) { { email: 'test@example.com' } }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(ENV).to receive(:[]).and_call_original
|
||||||
|
allow(ENV).to receive(:[]).with("INTERNAL_API_KEYS").and_return("dev-api-key-12345")
|
||||||
|
end
|
||||||
|
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
magic_link: { type: :string },
|
||||||
|
existing: { type: :boolean }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(400, 'bad request') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:id) { 'U123456' }
|
||||||
|
let(:payload) { { email: '' } }
|
||||||
|
|
||||||
|
before do
|
||||||
|
allow(ENV).to receive(:[]).and_call_original
|
||||||
|
allow(ENV).to receive(:[]).with("INTERNAL_API_KEYS").and_return("dev-api-key-12345")
|
||||||
|
end
|
||||||
|
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
73
spec/requests/api/summary_spec.rb
Normal file
73
spec/requests/api/summary_spec.rb
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
require 'swagger_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Api::Summary', type: :request do
|
||||||
|
path '/api/summary' do
|
||||||
|
get('Get WakaTime-compatible summary') do
|
||||||
|
tags 'WakaTime Compatibility'
|
||||||
|
description 'Returns a summary of coding activity in a format compatible with WakaTime clients. This endpoint supports querying by date range, interval, or specific user (admin/privileged only).'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :start, in: :query, type: :string, format: :date, description: 'Start date (YYYY-MM-DD)'
|
||||||
|
parameter name: :end, in: :query, type: :string, format: :date, description: 'End date (YYYY-MM-DD)'
|
||||||
|
parameter name: :interval, in: :query, type: :string, description: 'Interval (e.g. today, yesterday, week, month)'
|
||||||
|
parameter name: :project, in: :query, type: :string, description: 'Project name (optional)'
|
||||||
|
parameter name: :user, in: :query, type: :string, description: 'Slack UID of the user (optional, for admin use)'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:start) { '2023-01-01' }
|
||||||
|
let(:end) { '2023-01-31' }
|
||||||
|
let(:interval) { nil }
|
||||||
|
let(:project) { nil }
|
||||||
|
let(:user) { nil }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
user_id: { type: :string, nullable: true },
|
||||||
|
from: { type: :string, format: :date_time },
|
||||||
|
to: { type: :string, format: :date_time },
|
||||||
|
projects: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
key: { type: :string },
|
||||||
|
total: { type: :number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
languages: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
key: { type: :string },
|
||||||
|
total: { type: :number }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
editors: { type: :object, nullable: true },
|
||||||
|
operating_systems: { type: :object, nullable: true },
|
||||||
|
machines: { type: :object, nullable: true },
|
||||||
|
categories: { type: :object, nullable: true },
|
||||||
|
branches: { type: :object, nullable: true },
|
||||||
|
entities: { type: :object, nullable: true },
|
||||||
|
labels: { type: :object, nullable: true }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(400, 'bad request') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:start) { 'invalid-date' }
|
||||||
|
let(:end) { '2023-01-31' }
|
||||||
|
let(:interval) { nil }
|
||||||
|
let(:project) { nil }
|
||||||
|
let(:user) { nil }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
205
spec/requests/api/v1/authenticated_spec.rb
Normal file
205
spec/requests/api/v1/authenticated_spec.rb
Normal file
|
|
@ -0,0 +1,205 @@
|
||||||
|
require 'swagger_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Api::V1::Authenticated', type: :request do
|
||||||
|
path '/api/v1/authenticated/me' do
|
||||||
|
get('Get current user info') do
|
||||||
|
tags 'Authenticated'
|
||||||
|
description 'Returns detailed information about the currently authenticated user.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
emails: { type: :array, items: { type: :string } },
|
||||||
|
slack_id: { type: :string, nullable: true },
|
||||||
|
github_username: { type: :string, nullable: true },
|
||||||
|
trust_factor: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
trust_level: { type: :string },
|
||||||
|
trust_value: { type: :integer }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(401, 'unauthorized') do
|
||||||
|
let(:Authorization) { 'Bearer invalid' }
|
||||||
|
let(:api_key) { "invalid" }
|
||||||
|
# schema '$ref' => '#/components/schemas/Error'
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/authenticated/hours' do
|
||||||
|
get('Get hours') do
|
||||||
|
tags 'Authenticated'
|
||||||
|
description 'Returns the total coding hours for the authenticated user.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :start_date, in: :query, type: :string, format: :date, description: 'Start date (YYYY-MM-DD)'
|
||||||
|
parameter name: :end_date, in: :query, type: :string, format: :date, description: 'End date (YYYY-MM-DD)'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:start_date) { 7.days.ago.to_date.to_s }
|
||||||
|
let(:end_date) { Date.today.to_s }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
start_date: { type: :string, format: :date, example: '2024-03-13' },
|
||||||
|
end_date: { type: :string, format: :date, example: '2024-03-20' },
|
||||||
|
total_seconds: { type: :number, example: 153000.0 }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(401, 'unauthorized') do
|
||||||
|
let(:Authorization) { 'Bearer invalid' }
|
||||||
|
let(:api_key) { "invalid" }
|
||||||
|
let(:start_date) { 7.days.ago.to_date.to_s }
|
||||||
|
let(:end_date) { Date.today.to_s }
|
||||||
|
# schema '$ref' => '#/components/schemas/Error'
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/authenticated/streak' do
|
||||||
|
get('Get streak') do
|
||||||
|
tags 'Authenticated'
|
||||||
|
description 'Returns the current streak information (days coded in a row).'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
streak_days: { type: :integer, example: 5 }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(401, 'unauthorized') do
|
||||||
|
let(:Authorization) { 'Bearer invalid' }
|
||||||
|
let(:api_key) { "invalid" }
|
||||||
|
let(:start_date) { 7.days.ago.to_date.to_s }
|
||||||
|
let(:end_date) { Date.today.to_s }
|
||||||
|
# schema '$ref' => '#/components/schemas/Error'
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/authenticated/projects' do
|
||||||
|
get('Get projects') do
|
||||||
|
tags 'Authenticated'
|
||||||
|
description 'Returns a list of projects associated with the authenticated user.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :include_archived, in: :query, type: :boolean, description: 'Include archived projects (true/false)'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:include_archived) { false }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
projects: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string, example: 'hackatime' },
|
||||||
|
total_seconds: { type: :number, example: 3600.0 },
|
||||||
|
most_recent_heartbeat: { type: :string, format: :date_time, nullable: true },
|
||||||
|
languages: { type: :array, items: { type: :string } },
|
||||||
|
archived: { type: :boolean }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(401, 'unauthorized') do
|
||||||
|
let(:Authorization) { 'Bearer invalid' }
|
||||||
|
let(:api_key) { "invalid" }
|
||||||
|
let(:include_archived) { false }
|
||||||
|
# schema '$ref' => '#/components/schemas/Error'
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/authenticated/api_keys' do
|
||||||
|
get('Get API keys') do
|
||||||
|
tags 'Authenticated'
|
||||||
|
description 'Returns the API keys for the authenticated user. Warning: This returns sensitive information.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
token: { type: :string, example: 'waka_...' }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(401, 'unauthorized') do
|
||||||
|
let(:Authorization) { 'Bearer invalid' }
|
||||||
|
let(:api_key) { "invalid" }
|
||||||
|
# schema '$ref' => '#/components/schemas/Error'
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/authenticated/heartbeats/latest' do
|
||||||
|
get('Get latest heartbeat') do
|
||||||
|
tags 'Authenticated'
|
||||||
|
description 'Returns the absolutely latest heartbeat processed for the user.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer },
|
||||||
|
created_at: { type: :string, format: :date_time },
|
||||||
|
time: { type: :number },
|
||||||
|
category: { type: :string },
|
||||||
|
project: { type: :string },
|
||||||
|
language: { type: :string },
|
||||||
|
editor: { type: :string },
|
||||||
|
operating_system: { type: :string },
|
||||||
|
machine: { type: :string },
|
||||||
|
entity: { type: :string }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(401, 'unauthorized') do
|
||||||
|
let(:Authorization) { 'Bearer invalid' }
|
||||||
|
let(:api_key) { "invalid" }
|
||||||
|
# schema '$ref' => '#/components/schemas/Error'
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
164
spec/requests/api/v1/external_spec.rb
Normal file
164
spec/requests/api/v1/external_spec.rb
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
require 'swagger_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Api::V1::External', type: :request do
|
||||||
|
path '/api/v1/external/slack/oauth' do
|
||||||
|
post('Create user from Slack OAuth') do
|
||||||
|
tags 'External Integrations'
|
||||||
|
description 'Callback endpoint for Slack OAuth to create or update a user.'
|
||||||
|
consumes 'application/json'
|
||||||
|
produces 'application/json'
|
||||||
|
security [ Bearer: [] ]
|
||||||
|
|
||||||
|
parameter name: :payload, in: :body, schema: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
token: { type: :string, description: 'Slack OAuth Token' }
|
||||||
|
},
|
||||||
|
required: [ 'token' ]
|
||||||
|
}
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
before { ENV['STATS_API_KEY'] = 'dev-admin-api-key-12345' }
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:payload) { { token: 'valid_slack_token' } }
|
||||||
|
before do
|
||||||
|
allow(ENV).to receive(:[]).and_call_original
|
||||||
|
allow(ENV).to receive(:[]).with("STATS_API_KEY").and_return('dev-admin-api-key-12345')
|
||||||
|
allow(HTTP).to receive(:auth).and_return(double(get: double(body: { ok: true, user_id: 'U123456' }.to_json)))
|
||||||
|
allow_any_instance_of(User).to receive(:raw_slack_user_info).and_return({
|
||||||
|
"profile" => { "email" => "test@example.com" },
|
||||||
|
"tz" => "UTC"
|
||||||
|
})
|
||||||
|
allow_any_instance_of(User).to receive(:update_from_slack)
|
||||||
|
end
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
user_id: { type: :integer },
|
||||||
|
username: { type: :string },
|
||||||
|
email: { type: :string, nullable: true }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(400, 'bad request') do
|
||||||
|
before do
|
||||||
|
allow(ENV).to receive(:[]).and_call_original
|
||||||
|
allow(ENV).to receive(:[]).with("STATS_API_KEY").and_return('dev-admin-api-key-12345')
|
||||||
|
end
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:payload) { { token: nil } }
|
||||||
|
run_test! do |response|
|
||||||
|
expect(response.status).to eq(400)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/ysws_programs' do
|
||||||
|
get('List YSWS Programs') do
|
||||||
|
tags 'YSWS Programs'
|
||||||
|
description 'List available YSWS (Your Ship We Ship) programs.'
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
schema type: :array,
|
||||||
|
items: { type: :string, example: 'onboard' }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/ysws_programs/claim' do
|
||||||
|
post('Claim YSWS Program') do
|
||||||
|
tags 'YSWS Programs'
|
||||||
|
description 'Claim a YSWS program reward.'
|
||||||
|
consumes 'application/json'
|
||||||
|
produces 'application/json'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
|
||||||
|
parameter name: :payload, in: :body, schema: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
program_id: { type: :integer, description: 'YSWS Program ID' },
|
||||||
|
user_id: { type: :string, description: 'User ID or Slack UID' },
|
||||||
|
start_time: { type: :string, format: :date_time, description: 'Start time of the claim period' },
|
||||||
|
end_time: { type: :string, format: :date_time, description: 'End time of the claim period' },
|
||||||
|
project: { type: :string, description: 'Project name (optional)', nullable: true }
|
||||||
|
},
|
||||||
|
required: [ 'program_id', 'user_id', 'start_time', 'end_time' ]
|
||||||
|
}
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
before { ENV['STATS_API_KEY'] = 'dev-admin-api-key-12345' }
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-admin-api-key-12345" }
|
||||||
|
let(:user) {
|
||||||
|
User.find_by(username: 'testuser') || begin
|
||||||
|
u = User.create!(username: 'testuser', slack_uid: 'U123456')
|
||||||
|
u.email_addresses.create!(email: 'test@example.com')
|
||||||
|
u
|
||||||
|
end
|
||||||
|
}
|
||||||
|
let(:payload) { {
|
||||||
|
program_id: 1,
|
||||||
|
user_id: user.id.to_s,
|
||||||
|
start_time: (Time.now - 1.hour).iso8601,
|
||||||
|
end_time: (Time.now + 1.hour).iso8601,
|
||||||
|
project: 'test'
|
||||||
|
} }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
message: { type: :string, example: 'Successfully claimed 100 heartbeats' },
|
||||||
|
claimed_count: { type: :integer, example: 100 }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(409, 'conflict') do
|
||||||
|
before { ENV['STATS_API_KEY'] = 'dev-admin-api-key-12345' }
|
||||||
|
let(:Authorization) { "Bearer dev-admin-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-admin-api-key-12345" }
|
||||||
|
let(:user) {
|
||||||
|
User.find_by(username: 'testuser') || begin
|
||||||
|
u = User.create!(username: 'testuser', slack_uid: 'U123456')
|
||||||
|
u.email_addresses.create!(email: 'test@example.com')
|
||||||
|
u
|
||||||
|
end
|
||||||
|
}
|
||||||
|
let(:time_base) { Time.current }
|
||||||
|
let(:payload) { {
|
||||||
|
program_id: 1,
|
||||||
|
user_id: user.id.to_s,
|
||||||
|
start_time: (time_base - 1.hour).iso8601,
|
||||||
|
end_time: (time_base + 1.hour).iso8601,
|
||||||
|
project: 'test'
|
||||||
|
} }
|
||||||
|
|
||||||
|
before do
|
||||||
|
Heartbeat.create!(
|
||||||
|
user: user,
|
||||||
|
time: time_base,
|
||||||
|
ysws_program: :high_seas,
|
||||||
|
project: 'test',
|
||||||
|
language: 'Ruby',
|
||||||
|
editor: 'VS Code',
|
||||||
|
source_type: :direct_entry,
|
||||||
|
branch: 'main',
|
||||||
|
category: 'coding',
|
||||||
|
is_write: true,
|
||||||
|
user_agent: 'test',
|
||||||
|
operating_system: 'linux',
|
||||||
|
machine: 'test-machine'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
error: { type: :string },
|
||||||
|
conflicts: { type: :array, items: { type: :array } }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
157
spec/requests/api/v1/leaderboards_spec.rb
Normal file
157
spec/requests/api/v1/leaderboards_spec.rb
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
require 'swagger_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Api::V1::Leaderboard', type: :request do
|
||||||
|
path '/api/v1/leaderboard' do
|
||||||
|
get('Get daily leaderboard (Alias)') do
|
||||||
|
tags 'Leaderboard'
|
||||||
|
description 'Alias for /api/v1/leaderboard/daily. Returns the daily leaderboard.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
before do
|
||||||
|
entry_double = double(user: double(id: 1, display_name: 'testuser', avatar_url: 'http://example.com/avatar'), total_seconds: 3600)
|
||||||
|
entries_relation = double
|
||||||
|
allow(entries_relation).to receive(:includes).with(:user).and_return(entries_relation)
|
||||||
|
allow(entries_relation).to receive(:order).with(total_seconds: :desc).and_return([ entry_double ])
|
||||||
|
|
||||||
|
allow(LeaderboardService).to receive(:get).and_return(
|
||||||
|
double(
|
||||||
|
period_type: 'daily',
|
||||||
|
start_date: Date.today,
|
||||||
|
date_range_text: 'Today',
|
||||||
|
finished_generating_at: Time.now,
|
||||||
|
entries: entries_relation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
period: { type: :string, example: 'daily' },
|
||||||
|
start_date: { type: :string, format: :date, example: '2024-03-20' },
|
||||||
|
date_range: { type: :string, example: 'Wed, Mar 20, 2024' },
|
||||||
|
generated_at: { type: :string, format: :date_time, example: '2024-03-20T10:00:00Z' },
|
||||||
|
entries: {
|
||||||
|
type: :array,
|
||||||
|
items: { '$ref' => '#/components/schemas/LeaderboardEntry' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/leaderboard/daily' do
|
||||||
|
get('Get daily leaderboard') do
|
||||||
|
tags 'Leaderboard'
|
||||||
|
description 'Returns the daily leaderboard of coding time. Requires STATS_API_KEY. The leaderboard is cached and regenerated periodically.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
before do
|
||||||
|
entry_double = double(user: double(id: 1, display_name: 'testuser', avatar_url: 'http://example.com/avatar'), total_seconds: 3600)
|
||||||
|
entries_relation = double
|
||||||
|
allow(entries_relation).to receive(:includes).with(:user).and_return(entries_relation)
|
||||||
|
allow(entries_relation).to receive(:order).with(total_seconds: :desc).and_return([ entry_double ])
|
||||||
|
|
||||||
|
allow(LeaderboardService).to receive(:get).and_return(
|
||||||
|
double(
|
||||||
|
period_type: 'daily',
|
||||||
|
start_date: Date.today,
|
||||||
|
date_range_text: 'Today',
|
||||||
|
finished_generating_at: Time.now,
|
||||||
|
entries: entries_relation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
period: { type: :string, example: 'daily' },
|
||||||
|
start_date: { type: :string, format: :date, example: '2024-03-20' },
|
||||||
|
date_range: { type: :string, example: 'Wed, Mar 20, 2024' },
|
||||||
|
generated_at: { type: :string, format: :date_time, example: '2024-03-20T10:00:00Z' },
|
||||||
|
entries: {
|
||||||
|
type: :array,
|
||||||
|
items: { '$ref' => '#/components/schemas/LeaderboardEntry' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
# response(401, 'unauthorized') do
|
||||||
|
# let(:Authorization) { 'Bearer invalid_token' }
|
||||||
|
# let(:api_key) { "invalid" }
|
||||||
|
# schema '$ref' => '#/components/schemas/Error'
|
||||||
|
# run_test!
|
||||||
|
# end
|
||||||
|
|
||||||
|
response(503, 'service unavailable') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
description 'Leaderboard is currently being generated'
|
||||||
|
before do
|
||||||
|
allow(LeaderboardService).to receive(:get).and_return(nil)
|
||||||
|
end
|
||||||
|
schema '$ref' => '#/components/schemas/Error'
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/leaderboard/weekly' do
|
||||||
|
get('Get weekly leaderboard') do
|
||||||
|
tags 'Leaderboard'
|
||||||
|
description 'Returns the weekly leaderboard of coding time (last 7 days). Requires STATS_API_KEY.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
before do
|
||||||
|
entry_double = double(user: double(id: 1, display_name: 'testuser', avatar_url: 'http://example.com/avatar'), total_seconds: 3600 * 7)
|
||||||
|
entries_relation = double
|
||||||
|
allow(entries_relation).to receive(:includes).with(:user).and_return(entries_relation)
|
||||||
|
allow(entries_relation).to receive(:order).with(total_seconds: :desc).and_return([ entry_double ])
|
||||||
|
|
||||||
|
allow(LeaderboardService).to receive(:get).and_return(
|
||||||
|
double(
|
||||||
|
period_type: 'last_7_days',
|
||||||
|
start_date: Date.today - 7.days,
|
||||||
|
date_range_text: 'Last 7 Days',
|
||||||
|
finished_generating_at: Time.now,
|
||||||
|
entries: entries_relation
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
period: { type: :string, example: 'last_7_days' },
|
||||||
|
start_date: { type: :string, format: :date, example: '2024-03-13' },
|
||||||
|
date_range: { type: :string, example: 'Mar 13 - Mar 20, 2024' },
|
||||||
|
generated_at: { type: :string, format: :date_time, example: '2024-03-20T10:00:00Z' },
|
||||||
|
entries: {
|
||||||
|
type: :array,
|
||||||
|
items: { '$ref' => '#/components/schemas/LeaderboardEntry' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
# response(401, 'unauthorized') do
|
||||||
|
# let(:Authorization) { 'Bearer invalid_token' }
|
||||||
|
# let(:api_key) { "invalid" }
|
||||||
|
# schema '$ref' => '#/components/schemas/Error'
|
||||||
|
# run_test!
|
||||||
|
# end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
303
spec/requests/api/v1/my_spec.rb
Normal file
303
spec/requests/api/v1/my_spec.rb
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
require 'swagger_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Api::V1::My', type: :request do
|
||||||
|
let(:user) do
|
||||||
|
User.find_by(slack_uid: 'TEST123456') || User.create!(
|
||||||
|
slack_uid: 'TEST123456',
|
||||||
|
username: 'testuser',
|
||||||
|
slack_username: 'testuser',
|
||||||
|
timezone: 'America/New_York'
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def login_browser_user
|
||||||
|
allow_any_instance_of(ActionController::Base).to receive(:protect_against_forgery?).and_return(false)
|
||||||
|
allow_any_instance_of(ApplicationController).to receive(:current_user).and_return(user)
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/my/heartbeats/most_recent' do
|
||||||
|
get('Get most recent heartbeat') do
|
||||||
|
tags 'My Data'
|
||||||
|
description 'Returns the most recent heartbeat for the authenticated user. Useful for checking if the user is currently active.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :source_type, in: :query, type: :string, description: 'Filter by source type (e.g. "direct_entry")'
|
||||||
|
parameter name: :editor, in: :query, type: :string, description: 'Filter by editor name (e.g. "VSCode")'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { 'dev-api-key-12345' }
|
||||||
|
let(:source_type) { 'direct_entry' }
|
||||||
|
let(:editor) { 'VSCode' }
|
||||||
|
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(401, 'unauthorized') do
|
||||||
|
let(:Authorization) { 'Bearer invalid' }
|
||||||
|
let(:api_key) { 'invalid' }
|
||||||
|
let(:source_type) { 'direct_entry' }
|
||||||
|
let(:editor) { 'VSCode' }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/my/heartbeats' do
|
||||||
|
get('Get heartbeats') do
|
||||||
|
tags 'My Data'
|
||||||
|
description 'Returns a list of heartbeats for the authenticated user within a time range. This is the raw data stream.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :start_time, in: :query, type: :string, format: :date_time, description: 'Start time (ISO 8601)'
|
||||||
|
parameter name: :end_time, in: :query, type: :string, format: :date_time, description: 'End time (ISO 8601)'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { 'dev-api-key-12345' }
|
||||||
|
let(:start_time) { 1.day.ago.iso8601 }
|
||||||
|
let(:end_time) { Time.now.iso8601 }
|
||||||
|
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(401, 'unauthorized') do
|
||||||
|
let(:Authorization) { 'Bearer invalid' }
|
||||||
|
let(:api_key) { 'invalid' }
|
||||||
|
let(:start_time) { 1.day.ago.iso8601 }
|
||||||
|
let(:end_time) { Time.now.iso8601 }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/my/heartbeats/export' do
|
||||||
|
get('Export Heartbeats') do
|
||||||
|
tags 'My Data'
|
||||||
|
description 'Export your heartbeats as a JSON file.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :all_data, in: :query, type: :boolean, description: 'Export all data (true/false)'
|
||||||
|
parameter name: :start_date, in: :query, type: :string, format: :date, description: 'Start date (YYYY-MM-DD)'
|
||||||
|
parameter name: :end_date, in: :query, type: :string, format: :date, description: 'End date (YYYY-MM-DD)'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { 'dev-api-key-12345' }
|
||||||
|
let(:all_data) { true }
|
||||||
|
let(:start_date) { Date.today.to_s }
|
||||||
|
let(:end_date) { Date.today.to_s }
|
||||||
|
|
||||||
|
before { login_browser_user }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/my/heartbeats/import' do
|
||||||
|
post('Import Heartbeats') do
|
||||||
|
tags 'My Data'
|
||||||
|
description 'Import heartbeats from a JSON file.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
consumes 'multipart/form-data'
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :heartbeat_file,
|
||||||
|
in: :formData,
|
||||||
|
schema: { type: :string, format: :binary },
|
||||||
|
description: 'JSON file containing heartbeats'
|
||||||
|
|
||||||
|
response(302, 'redirect') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { 'dev-api-key-12345' }
|
||||||
|
let(:heartbeat_file) { Rack::Test::UploadedFile.new(Rails.root.join('spec/fixtures/heartbeats.json'), 'application/json') }
|
||||||
|
|
||||||
|
before do
|
||||||
|
FileUtils.mkdir_p(Rails.root.join('spec/fixtures'))
|
||||||
|
File.write(Rails.root.join('spec/fixtures/heartbeats.json'), '[]') unless File.exist?(Rails.root.join('spec/fixtures/heartbeats.json'))
|
||||||
|
login_browser_user
|
||||||
|
end
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/my/projects' do
|
||||||
|
get('List Project Repo Mappings') do
|
||||||
|
tags 'My Projects'
|
||||||
|
description 'List mappings between local project names and Git repositories.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json', 'text/html'
|
||||||
|
|
||||||
|
parameter name: :interval, in: :query, type: :string, description: 'Time interval (e.g., daily, weekly). Default: daily'
|
||||||
|
parameter name: :from, in: :query, type: :string, format: :date, description: 'Start date (YYYY-MM-DD)'
|
||||||
|
parameter name: :to, in: :query, type: :string, format: :date, description: 'End date (YYYY-MM-DD)'
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { 'dev-api-key-12345' }
|
||||||
|
let(:interval) { 'daily' }
|
||||||
|
let(:from) { Date.today.to_s }
|
||||||
|
let(:to) { Date.today.to_s }
|
||||||
|
|
||||||
|
before { login_browser_user }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/my/project_repo_mappings/{project_name}' do
|
||||||
|
parameter name: :project_name, in: :path, type: :string, description: 'Project name (encoded)'
|
||||||
|
|
||||||
|
patch('Update Project Repo Mapping') do
|
||||||
|
tags 'My Projects'
|
||||||
|
description 'Update the Git repository URL for a project mapping.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
consumes 'application/json'
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :project_repo_mapping, in: :body, schema: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
repo_url: { type: :string, example: 'https://github.com/hackclub/hackatime' }
|
||||||
|
},
|
||||||
|
required: [ 'repo_url' ]
|
||||||
|
}
|
||||||
|
|
||||||
|
response(302, 'redirect') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { 'dev-api-key-12345' }
|
||||||
|
let(:project_name) { 'hackatime' }
|
||||||
|
let(:project_repo_mapping) { { repo_url: 'https://github.com/hackclub/hackatime' } }
|
||||||
|
|
||||||
|
before do
|
||||||
|
login_browser_user
|
||||||
|
user.update(github_uid: '12345')
|
||||||
|
end
|
||||||
|
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/my/project_repo_mappings/{project_name}/archive' do
|
||||||
|
parameter name: :project_name, in: :path, type: :string, description: 'Project name (encoded)'
|
||||||
|
|
||||||
|
patch('Archive Project Mapping') do
|
||||||
|
tags 'My Projects'
|
||||||
|
description 'Archive a project mapping so it does not appear in active lists.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(302, 'redirect') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { 'dev-api-key-12345' }
|
||||||
|
let(:project_name) { 'hackatime' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
login_browser_user
|
||||||
|
user.project_repo_mappings.create!(project_name: 'hackatime')
|
||||||
|
end
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/my/project_repo_mappings/{project_name}/unarchive' do
|
||||||
|
parameter name: :project_name, in: :path, type: :string, description: 'Project name (encoded)'
|
||||||
|
|
||||||
|
patch('Unarchive Project Mapping') do
|
||||||
|
tags 'My Projects'
|
||||||
|
description 'Restore an archived project mapping.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(302, 'redirect') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { 'dev-api-key-12345' }
|
||||||
|
let(:project_name) { 'hackatime' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
login_browser_user
|
||||||
|
p = user.project_repo_mappings.create!(project_name: 'hackatime')
|
||||||
|
p.archive!
|
||||||
|
end
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/my/settings/rotate_api_key' do
|
||||||
|
post('Rotate API Key') do
|
||||||
|
tags 'My Settings'
|
||||||
|
description 'Rotate your API key. Returns the new token. Warning: Old token will stop working immediately.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { 'dev-api-key-12345' }
|
||||||
|
|
||||||
|
before { login_browser_user }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/my/settings/migrate_heartbeats' do
|
||||||
|
post('Migrate Heartbeats') do
|
||||||
|
tags 'My Settings'
|
||||||
|
description 'Trigger a migration of heartbeats from legacy formats or systems.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
response(302, 'redirect') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { 'dev-api-key-12345' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
login_browser_user
|
||||||
|
allow(MigrateUserFromHackatimeJob).to receive(:perform_later)
|
||||||
|
end
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/deletion' do
|
||||||
|
post('Create Deletion Request') do
|
||||||
|
tags 'My Settings'
|
||||||
|
description 'Request deletion of your account and data.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'text/html'
|
||||||
|
|
||||||
|
response(302, 'redirect') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { 'dev-api-key-12345' }
|
||||||
|
|
||||||
|
before { login_browser_user }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
delete('Cancel Deletion Request') do
|
||||||
|
tags 'My Settings'
|
||||||
|
description 'Cancel a pending deletion request.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'text/html'
|
||||||
|
|
||||||
|
response(302, 'redirect') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { 'dev-api-key-12345' }
|
||||||
|
|
||||||
|
before do
|
||||||
|
login_browser_user
|
||||||
|
DeletionRequest.create_for_user!(user)
|
||||||
|
end
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
295
spec/requests/api/v1/stats_spec.rb
Normal file
295
spec/requests/api/v1/stats_spec.rb
Normal file
|
|
@ -0,0 +1,295 @@
|
||||||
|
require 'swagger_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Api::V1::Stats', type: :request do
|
||||||
|
path '/api/v1/stats' do
|
||||||
|
get('Get total coding time (Admin Only)') do
|
||||||
|
tags 'Stats'
|
||||||
|
description 'Returns the total coding time for all users, optionally filtered by user or date range. Requires admin privileges via STATS_API_KEY.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'text/plain'
|
||||||
|
|
||||||
|
parameter name: :start_date, in: :query, type: :string, format: :date, description: 'Start date (YYYY-MM-DD), defaults to 10 years ago'
|
||||||
|
parameter name: :end_date, in: :query, type: :string, format: :date, description: 'End date (YYYY-MM-DD), defaults to today'
|
||||||
|
parameter name: :username, in: :query, type: :string, description: 'Filter by username (optional)'
|
||||||
|
parameter name: :user_email, in: :query, type: :string, description: 'Filter by user email (optional)'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
before { ENV['STATS_API_KEY'] = 'dev-api-key-12345' }
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:start_date) { '2023-01-01' }
|
||||||
|
let(:end_date) { '2023-12-31' }
|
||||||
|
let(:username) { nil }
|
||||||
|
let(:user_email) { nil }
|
||||||
|
schema type: :integer, example: 123456
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(401, 'unauthorized') do
|
||||||
|
before { ENV['STATS_API_KEY'] = 'dev-api-key-12345' }
|
||||||
|
let(:Authorization) { 'Bearer invalid_token' }
|
||||||
|
let(:api_key) { 'invalid' }
|
||||||
|
let(:start_date) { '2023-01-01' }
|
||||||
|
let(:end_date) { '2023-12-31' }
|
||||||
|
let(:username) { nil }
|
||||||
|
let(:user_email) { nil }
|
||||||
|
|
||||||
|
run_test! do |response|
|
||||||
|
expect(response.status).to eq(401)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
response(404, 'user not found') do
|
||||||
|
before { ENV['STATS_API_KEY'] = 'dev-api-key-12345' }
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:start_date) { '2023-01-01' }
|
||||||
|
let(:end_date) { '2023-12-31' }
|
||||||
|
let(:username) { 'non_existent_user' }
|
||||||
|
let(:user_email) { nil }
|
||||||
|
schema '$ref' => '#/components/schemas/Error'
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/users/{username}/heartbeats/spans' do
|
||||||
|
get('Get user heartbeat spans') do
|
||||||
|
tags 'Stats'
|
||||||
|
description 'Returns heartbeat spans for a user, useful for visualizations.'
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :username, in: :path, type: :string
|
||||||
|
parameter name: :start_date, in: :query, type: :string, format: :date
|
||||||
|
parameter name: :end_date, in: :query, type: :string, format: :date
|
||||||
|
parameter name: :project, in: :query, type: :string, description: 'Filter by single project'
|
||||||
|
parameter name: :filter_by_project, in: :query, type: :string, description: 'Filter by multiple projects (comma separated)'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:username) { 'testuser' }
|
||||||
|
let(:start_date) { '2023-01-01' }
|
||||||
|
let(:end_date) { '2023-01-02' }
|
||||||
|
let(:project) { nil }
|
||||||
|
let(:filter_by_project) { nil }
|
||||||
|
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
spans: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
start: { type: :number },
|
||||||
|
end: { type: :number },
|
||||||
|
project: { type: :string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/users/{username}/trust_factor' do
|
||||||
|
get('Get user trust factor') do
|
||||||
|
tags 'Stats'
|
||||||
|
description 'Returns the trust level and value for a user.'
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :username, in: :path, type: :string
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:username) { 'testuser' }
|
||||||
|
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
trust_level: { type: :string, example: 'blue' },
|
||||||
|
trust_value: { type: :integer, example: 3 }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/users/{username}/projects' do
|
||||||
|
get('Get user project names') do
|
||||||
|
tags 'Stats'
|
||||||
|
description 'Returns a list of project names for a user from the last 30 days.'
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :username, in: :path, type: :string
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:username) { 'testuser' }
|
||||||
|
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
projects: {
|
||||||
|
type: :array,
|
||||||
|
items: { type: :string }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/users/{username}/project/{project_name}' do
|
||||||
|
get('Get user project details') do
|
||||||
|
tags 'Stats'
|
||||||
|
description 'Returns details for a specific project.'
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :username, in: :path, type: :string
|
||||||
|
parameter name: :project_name, in: :path, type: :string
|
||||||
|
parameter name: :start_date, in: :query, type: :string, format: :date
|
||||||
|
parameter name: :end_date, in: :query, type: :string, format: :date
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:username) { 'testuser' }
|
||||||
|
let(:project_name) { 'harbor' }
|
||||||
|
let(:start_date) { nil }
|
||||||
|
let(:end_date) { nil }
|
||||||
|
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string },
|
||||||
|
total_seconds: { type: :number },
|
||||||
|
languages: { type: :array, items: { type: :string } },
|
||||||
|
repo_url: { type: :string, nullable: true },
|
||||||
|
total_heartbeats: { type: :integer },
|
||||||
|
first_heartbeat: { type: :string, format: :date_time, nullable: true },
|
||||||
|
last_heartbeat: { type: :string, format: :date_time, nullable: true }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/users/{username}/projects/details' do
|
||||||
|
get('Get details for multiple projects') do
|
||||||
|
tags 'Stats'
|
||||||
|
description 'Returns details for multiple projects, or all projects in a time range.'
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :username, in: :path, type: :string
|
||||||
|
parameter name: :projects, in: :query, type: :string, description: 'Comma-separated project names'
|
||||||
|
parameter name: :since, in: :query, type: :string, format: :date_time
|
||||||
|
parameter name: :until, in: :query, type: :string, format: :date_time
|
||||||
|
parameter name: :start_date, in: :query, type: :string, format: :date
|
||||||
|
parameter name: :end_date, in: :query, type: :string, format: :date
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:username) { 'testuser' }
|
||||||
|
let(:projects) { nil }
|
||||||
|
let(:since) { nil }
|
||||||
|
let(:until) { nil }
|
||||||
|
let(:start_date) { nil }
|
||||||
|
let(:end_date) { nil }
|
||||||
|
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
projects: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string },
|
||||||
|
total_seconds: { type: :number },
|
||||||
|
languages: { type: :array, items: { type: :string } },
|
||||||
|
repo_url: { type: :string, nullable: true },
|
||||||
|
total_heartbeats: { type: :integer },
|
||||||
|
first_heartbeat: { type: :string, format: :date_time, nullable: true },
|
||||||
|
last_heartbeat: { type: :string, format: :date_time, nullable: true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/users/{username}/stats' do
|
||||||
|
get('Get user stats') do
|
||||||
|
tags 'Stats'
|
||||||
|
description 'Returns detailed coding stats for a specific user, including languages, projects, and total time.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :username, in: :path, type: :string, description: 'Username, Slack ID, or User ID'
|
||||||
|
parameter name: :start_date, in: :query, type: :string, format: :date, description: 'Start date (YYYY-MM-DD)'
|
||||||
|
parameter name: :end_date, in: :query, type: :string, format: :date, description: 'End date (YYYY-MM-DD)'
|
||||||
|
parameter name: :limit, in: :query, type: :integer, description: 'Limit number of results'
|
||||||
|
parameter name: :features, in: :query, type: :string, description: 'Comma-separated list of features to include (e.g., languages,projects)'
|
||||||
|
parameter name: :filter_by_project, in: :query, type: :string, description: 'Filter results by specific project names (comma-separated)'
|
||||||
|
parameter name: :filter_by_category, in: :query, type: :string, description: 'Filter results by category (comma-separated)'
|
||||||
|
parameter name: :boundary_aware, in: :query, type: :boolean, description: 'Use boundary aware calculation'
|
||||||
|
parameter name: :total_seconds, in: :query, type: :boolean, description: 'Return only total seconds'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:username) { 'testuser' }
|
||||||
|
let(:start_date) { '2023-01-01' }
|
||||||
|
let(:end_date) { '2023-12-31' }
|
||||||
|
let(:limit) { nil }
|
||||||
|
let(:features) { nil }
|
||||||
|
let(:filter_by_project) { nil }
|
||||||
|
let(:filter_by_category) { nil }
|
||||||
|
let(:boundary_aware) { nil }
|
||||||
|
let(:total_seconds) { nil }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
data: { '$ref' => '#/components/schemas/StatsSummary' },
|
||||||
|
trust_factor: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
trust_level: { type: :string, example: 'blue' },
|
||||||
|
trust_value: { type: :integer, example: 3 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(403, 'forbidden') do
|
||||||
|
before do
|
||||||
|
user = User.create!(username: 'private_user', slack_uid: 'PRIVATE_123', allow_public_stats_lookup: false)
|
||||||
|
user.email_addresses.create!(email: 'private@example.com')
|
||||||
|
end
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:username) { 'private_user' }
|
||||||
|
let(:start_date) { '2023-01-01' }
|
||||||
|
let(:end_date) { '2023-12-31' }
|
||||||
|
let(:limit) { nil }
|
||||||
|
let(:features) { nil }
|
||||||
|
let(:filter_by_project) { nil }
|
||||||
|
let(:filter_by_category) { nil }
|
||||||
|
let(:boundary_aware) { nil }
|
||||||
|
let(:total_seconds) { nil }
|
||||||
|
description 'User has disabled public stats lookup'
|
||||||
|
schema '$ref' => '#/components/schemas/Error'
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(404, 'user not found') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:username) { 'non_existent_user' }
|
||||||
|
let(:start_date) { '2023-01-01' }
|
||||||
|
let(:end_date) { '2023-12-31' }
|
||||||
|
let(:limit) { nil }
|
||||||
|
let(:features) { nil }
|
||||||
|
let(:filter_by_project) { nil }
|
||||||
|
let(:filter_by_category) { nil }
|
||||||
|
let(:boundary_aware) { nil }
|
||||||
|
let(:total_seconds) { nil }
|
||||||
|
schema '$ref' => '#/components/schemas/Error'
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
219
spec/requests/api/v1/users_spec.rb
Normal file
219
spec/requests/api/v1/users_spec.rb
Normal file
|
|
@ -0,0 +1,219 @@
|
||||||
|
require 'swagger_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Api::V1::Users', type: :request do
|
||||||
|
path '/api/v1/users/lookup_email/{email}' do
|
||||||
|
get('Lookup user by email') do
|
||||||
|
tags 'Users'
|
||||||
|
description 'Find a user ID by their email address. Useful for integrations that need to map emails to Hackatime users. Requires STATS_API_KEY.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :email, in: :path, type: :string, description: 'Email address to lookup'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
before { ENV['STATS_API_KEY'] = 'dev-api-key-12345' }
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:email) { 'test@example.com' }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
user_id: { type: :integer, example: 42 },
|
||||||
|
email: { type: :string, example: 'test@example.com' }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(404, 'not found') do
|
||||||
|
before { ENV['STATS_API_KEY'] = 'dev-api-key-12345' }
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:email) { 'unknown@example.com' }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
error: { type: :string, example: 'User not found' },
|
||||||
|
email: { type: :string, example: 'unknown@example.com' }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/users/lookup_slack_uid/{slack_uid}' do
|
||||||
|
get('Lookup user by Slack UID') do
|
||||||
|
tags 'Users'
|
||||||
|
description 'Find a user ID by their Slack User ID. Requires STATS_API_KEY.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :slack_uid, in: :path, type: :string, description: 'Slack User ID (e.g. U123456)'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
before { ENV['STATS_API_KEY'] = 'dev-api-key-12345' }
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:slack_uid) { 'TEST123456' }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
user_id: { type: :integer, example: 42 },
|
||||||
|
slack_uid: { type: :string, example: 'TEST123456' }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(404, 'not found') do
|
||||||
|
before { ENV['STATS_API_KEY'] = 'dev-api-key-12345' }
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:slack_uid) { 'U000000' }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
error: { type: :string, example: 'User not found' },
|
||||||
|
slack_uid: { type: :string, example: 'U000000' }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/users/{username}/trust_factor' do
|
||||||
|
get('Get trust factor') do
|
||||||
|
tags 'Users'
|
||||||
|
description 'Get the trust level/factor for a user. Higher trust values indicate more verified activity.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :username, in: :path, type: :string, description: 'Username, Slack ID, or User ID'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:username) { 'testuser' }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
trust_level: { type: :string, example: 'verified' },
|
||||||
|
trust_value: { type: :integer, example: 2 }
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(404, 'not found') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:username) { 'unknown_user' }
|
||||||
|
schema '$ref' => '#/components/schemas/Error'
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/users/{username}/projects' do
|
||||||
|
get('Get user projects') do
|
||||||
|
tags 'Users'
|
||||||
|
description 'Get a list of projects a user has coded on recently (last 30 days).'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :username, in: :path, type: :string, description: 'Username'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:username) { 'testuser' }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
projects: {
|
||||||
|
type: :array,
|
||||||
|
items: { type: :string, example: 'hackatime' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
|
||||||
|
response(404, 'not found') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:username) { 'unknown_user' }
|
||||||
|
schema '$ref' => '#/components/schemas/Error'
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/users/{username}/project/{project_name}' do
|
||||||
|
get('Get specific project stats') do
|
||||||
|
tags 'Users'
|
||||||
|
description 'Get detailed stats for a specific project.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :username, in: :path, type: :string, description: 'Username'
|
||||||
|
parameter name: :project_name, in: :path, type: :string, description: 'Project Name'
|
||||||
|
parameter name: :start_date, in: :query, type: :string, format: :date_time, description: 'Start date (ISO 8601) for stats calculation'
|
||||||
|
parameter name: :end_date, in: :query, type: :string, format: :date_time, description: 'End date (ISO 8601) for stats calculation'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:username) { 'testuser' }
|
||||||
|
let(:project_name) { 'harbor' }
|
||||||
|
let(:start_date) { nil }
|
||||||
|
let(:end_date) { nil }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/users/{username}/projects/details' do
|
||||||
|
get('Get detailed project stats') do
|
||||||
|
tags 'Users'
|
||||||
|
description 'Get detailed breakdown of all user projects.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :username, in: :path, type: :string, description: 'Username'
|
||||||
|
parameter name: :projects, in: :query, type: :string, description: 'Comma-separated list of projects to filter'
|
||||||
|
parameter name: :since, in: :query, type: :string, format: :date_time, description: 'Start time (ISO 8601) for project discovery'
|
||||||
|
parameter name: :until_date, in: :query, type: :string, format: :date_time, description: 'End time (ISO 8601) for project discovery'
|
||||||
|
parameter name: :start_date, in: :query, type: :string, format: :date_time, description: 'Start date (ISO 8601) for stats calculation'
|
||||||
|
parameter name: :end_date, in: :query, type: :string, format: :date_time, description: 'End date (ISO 8601) for stats calculation'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:username) { 'testuser' }
|
||||||
|
let(:projects) { nil }
|
||||||
|
let(:since) { nil }
|
||||||
|
let(:until_date) { nil }
|
||||||
|
let(:start_date) { nil }
|
||||||
|
let(:end_date) { nil }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/api/v1/users/{username}/heartbeats/spans' do
|
||||||
|
get('Get heartbeat spans') do
|
||||||
|
tags 'Users'
|
||||||
|
description 'Get time spans of coding activity.'
|
||||||
|
security [ Bearer: [], ApiKeyAuth: [] ]
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :username, in: :path, type: :string, description: 'Username'
|
||||||
|
parameter name: :start_date, in: :query, type: :string, format: :date, description: 'Start date (YYYY-MM-DD)'
|
||||||
|
parameter name: :end_date, in: :query, type: :string, format: :date, description: 'End date (YYYY-MM-DD)'
|
||||||
|
parameter name: :project, in: :query, type: :string, description: 'Filter by specific project'
|
||||||
|
parameter name: :filter_by_project, in: :query, type: :string, description: 'Filter by multiple projects (comma-separated)'
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:Authorization) { "Bearer dev-api-key-12345" }
|
||||||
|
let(:api_key) { "dev-api-key-12345" }
|
||||||
|
let(:username) { 'testuser' }
|
||||||
|
let(:start_date) { Date.today.to_s }
|
||||||
|
let(:end_date) { Date.today.to_s }
|
||||||
|
let(:project) { nil }
|
||||||
|
let(:filter_by_project) { nil }
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
67
spec/requests/slack_spec.rb
Normal file
67
spec/requests/slack_spec.rb
Normal file
|
|
@ -0,0 +1,67 @@
|
||||||
|
require 'swagger_helper'
|
||||||
|
|
||||||
|
RSpec.describe 'Slack Webhooks', type: :request do
|
||||||
|
path '/sailors_log/slack/commands' do
|
||||||
|
post('Handle Sailor\'s Log Command') do
|
||||||
|
tags 'Slack'
|
||||||
|
description 'Handle incoming Slack slash commands for Sailor\'s Log (/sailorslog).'
|
||||||
|
consumes 'application/x-www-form-urlencoded'
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :command, in: :formData, type: :string
|
||||||
|
parameter name: :text, in: :formData, type: :string
|
||||||
|
parameter name: :user_id, in: :formData, type: :string
|
||||||
|
parameter name: :response_url, in: :formData, type: :string
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:command) { '/sailorslog' }
|
||||||
|
let(:text) { 'status update' }
|
||||||
|
let(:user_id) { 'U123456' }
|
||||||
|
let(:response_url) { 'https://hooks.slack.com/commands/1234/5678' }
|
||||||
|
before { allow(Rails.env).to receive(:development?).and_return(true) }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
response_type: { type: :string },
|
||||||
|
text: { type: :string, nullable: true },
|
||||||
|
blocks: {
|
||||||
|
type: :array,
|
||||||
|
items: { type: :object }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
path '/timedump/slack/commands' do
|
||||||
|
post('Handle Timedump Command') do
|
||||||
|
tags 'Slack'
|
||||||
|
description 'Handle incoming Slack slash commands for Timedump (/timedump).'
|
||||||
|
consumes 'application/x-www-form-urlencoded'
|
||||||
|
produces 'application/json'
|
||||||
|
|
||||||
|
parameter name: :command, in: :formData, type: :string
|
||||||
|
parameter name: :text, in: :formData, type: :string
|
||||||
|
parameter name: :user_id, in: :formData, type: :string
|
||||||
|
parameter name: :response_url, in: :formData, type: :string
|
||||||
|
|
||||||
|
response(200, 'successful') do
|
||||||
|
let(:command) { '/timedump' }
|
||||||
|
let(:text) { 'status update' }
|
||||||
|
let(:user_id) { 'U123456' }
|
||||||
|
let(:response_url) { 'https://hooks.slack.com/commands/1234/5678' }
|
||||||
|
before { allow(Rails.env).to receive(:development?).and_return(true) }
|
||||||
|
schema type: :object,
|
||||||
|
properties: {
|
||||||
|
response_type: { type: :string },
|
||||||
|
text: { type: :string, nullable: true },
|
||||||
|
blocks: {
|
||||||
|
type: :array,
|
||||||
|
items: { type: :object }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
run_test!
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
94
spec/spec_helper.rb
Normal file
94
spec/spec_helper.rb
Normal file
|
|
@ -0,0 +1,94 @@
|
||||||
|
# This file was generated by the `rails generate rspec:install` command. Conventionally, all
|
||||||
|
# specs live under a `spec` directory, which RSpec adds to the `$LOAD_PATH`.
|
||||||
|
# The generated `.rspec` file contains `--require spec_helper` which will cause
|
||||||
|
# this file to always be loaded, without a need to explicitly require it in any
|
||||||
|
# files.
|
||||||
|
#
|
||||||
|
# Given that it is always loaded, you are encouraged to keep this file as
|
||||||
|
# light-weight as possible. Requiring heavyweight dependencies from this file
|
||||||
|
# will add to the boot time of your test suite on EVERY test run, even for an
|
||||||
|
# individual file that may not need all of that loaded. Instead, consider making
|
||||||
|
# a separate helper file that requires the additional dependencies and performs
|
||||||
|
# the additional setup, and require it from the spec files that actually need
|
||||||
|
# it.
|
||||||
|
#
|
||||||
|
# See https://rubydoc.info/gems/rspec-core/RSpec/Core/Configuration
|
||||||
|
RSpec.configure do |config|
|
||||||
|
# rspec-expectations config goes here. You can use an alternate
|
||||||
|
# assertion/expectation library such as wrong or the stdlib/minitest
|
||||||
|
# assertions if you prefer.
|
||||||
|
config.expect_with :rspec do |expectations|
|
||||||
|
# This option will default to `true` in RSpec 4. It makes the `description`
|
||||||
|
# and `failure_message` of custom matchers include text for helper methods
|
||||||
|
# defined using `chain`, e.g.:
|
||||||
|
# be_bigger_than(2).and_smaller_than(4).description
|
||||||
|
# # => "be bigger than 2 and smaller than 4"
|
||||||
|
# ...rather than:
|
||||||
|
# # => "be bigger than 2"
|
||||||
|
expectations.include_chain_clauses_in_custom_matcher_descriptions = true
|
||||||
|
end
|
||||||
|
|
||||||
|
# rspec-mocks config goes here. You can use an alternate test double
|
||||||
|
# library (such as bogus or mocha) by changing the `mock_with` option here.
|
||||||
|
config.mock_with :rspec do |mocks|
|
||||||
|
# Prevents you from mocking or stubbing a method that does not exist on
|
||||||
|
# a real object. This is generally recommended, and will default to
|
||||||
|
# `true` in RSpec 4.
|
||||||
|
mocks.verify_partial_doubles = true
|
||||||
|
end
|
||||||
|
|
||||||
|
# This option will default to `:apply_to_host_groups` in RSpec 4 (and will
|
||||||
|
# have no way to turn it off -- the option exists only for backwards
|
||||||
|
# compatibility in RSpec 3). It causes shared context metadata to be
|
||||||
|
# inherited by the metadata hash of host groups and examples, rather than
|
||||||
|
# triggering implicit auto-inclusion in groups with matching metadata.
|
||||||
|
config.shared_context_metadata_behavior = :apply_to_host_groups
|
||||||
|
|
||||||
|
# The settings below are suggested to provide a good initial experience
|
||||||
|
# with RSpec, but feel free to customize to your heart's content.
|
||||||
|
=begin
|
||||||
|
# This allows you to limit a spec run to individual examples or groups
|
||||||
|
# you care about by tagging them with `:focus` metadata. When nothing
|
||||||
|
# is tagged with `:focus`, all examples get run. RSpec also provides
|
||||||
|
# aliases for `it`, `describe`, and `context` that include `:focus`
|
||||||
|
# metadata: `fit`, `fdescribe` and `fcontext`, respectively.
|
||||||
|
config.filter_run_when_matching :focus
|
||||||
|
|
||||||
|
# Allows RSpec to persist some state between runs in order to support
|
||||||
|
# the `--only-failures` and `--next-failure` CLI options. We recommend
|
||||||
|
# you configure your source control system to ignore this file.
|
||||||
|
config.example_status_persistence_file_path = "spec/examples.txt"
|
||||||
|
|
||||||
|
# Limits the available syntax to the non-monkey patched syntax that is
|
||||||
|
# recommended. For more details, see:
|
||||||
|
# https://rspec.info/features/3-12/rspec-core/configuration/zero-monkey-patching-mode/
|
||||||
|
config.disable_monkey_patching!
|
||||||
|
|
||||||
|
# Many RSpec users commonly either run the entire suite or an individual
|
||||||
|
# file, and it's useful to allow more verbose output when running an
|
||||||
|
# individual spec file.
|
||||||
|
if config.files_to_run.one?
|
||||||
|
# Use the documentation formatter for detailed output,
|
||||||
|
# unless a formatter has already been configured
|
||||||
|
# (e.g. via a command-line flag).
|
||||||
|
config.default_formatter = "doc"
|
||||||
|
end
|
||||||
|
|
||||||
|
# Print the 10 slowest examples and example groups at the
|
||||||
|
# end of the spec run, to help surface which specs are running
|
||||||
|
# particularly slow.
|
||||||
|
config.profile_examples = 10
|
||||||
|
|
||||||
|
# Run specs in random order to surface order dependencies. If you find an
|
||||||
|
# order dependency and want to debug it, you can fix the order by providing
|
||||||
|
# the seed, which is printed after each run.
|
||||||
|
# --seed 1234
|
||||||
|
config.order = :random
|
||||||
|
|
||||||
|
# Seed global randomization in this process using the `--seed` CLI option.
|
||||||
|
# Setting this allows you to use `--seed` to deterministically reproduce
|
||||||
|
# test failures related to randomization by passing the same `--seed` value
|
||||||
|
# as the one that triggered the failure.
|
||||||
|
Kernel.srand config.seed
|
||||||
|
=end
|
||||||
|
end
|
||||||
303
spec/swagger_helper.rb
Normal file
303
spec/swagger_helper.rb
Normal file
|
|
@ -0,0 +1,303 @@
|
||||||
|
# frozen_string_literal: true
|
||||||
|
|
||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.configure do |config|
|
||||||
|
config.openapi_root = Rails.root.join('swagger').to_s
|
||||||
|
|
||||||
|
config.openapi_specs = {
|
||||||
|
'v1/swagger.yaml' => {
|
||||||
|
openapi: '3.0.1',
|
||||||
|
info: {
|
||||||
|
title: 'Hackatime API',
|
||||||
|
version: 'v1',
|
||||||
|
description: <<~DESC
|
||||||
|
Hackatime's API gives access to coding activity data.
|
||||||
|
|
||||||
|
We support the WakaTime spec, allowing you to use existing plugins and tools.
|
||||||
|
DESC
|
||||||
|
},
|
||||||
|
paths: {},
|
||||||
|
components: {
|
||||||
|
securitySchemes: {
|
||||||
|
Bearer: {
|
||||||
|
type: :http,
|
||||||
|
scheme: :bearer,
|
||||||
|
description: 'User API Key from settings, prefixed with "Bearer"'
|
||||||
|
},
|
||||||
|
AdminToken: {
|
||||||
|
type: :http,
|
||||||
|
scheme: :bearer,
|
||||||
|
description: 'Admin API Key, prefixed with "Bearer"'
|
||||||
|
},
|
||||||
|
InternalToken: {
|
||||||
|
type: :http,
|
||||||
|
scheme: :bearer,
|
||||||
|
description: 'Internal API Key from env, prefixed with "Bearer"'
|
||||||
|
},
|
||||||
|
ApiKeyAuth: {
|
||||||
|
type: :apiKey,
|
||||||
|
name: 'api_key',
|
||||||
|
in: :query,
|
||||||
|
description: 'User API Key from settings'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
schemas: {
|
||||||
|
Error: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
error: { type: :string, example: 'Unauthorized' }
|
||||||
|
},
|
||||||
|
required: [ 'error' ]
|
||||||
|
},
|
||||||
|
User: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer, example: 1 },
|
||||||
|
username: { type: :string, example: 'orpheus' },
|
||||||
|
avatar_url: { type: :string, example: 'https://hackatime.hackclub.com/images/athena.png' },
|
||||||
|
display_name: { type: :string, example: 'Orpheus' },
|
||||||
|
is_admin: { type: :boolean, example: false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Heartbeat: {
|
||||||
|
type: :object,
|
||||||
|
description: 'A single unit of coding activity representing a specific moment in time.',
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer, example: 1024 },
|
||||||
|
entity: { type: :string, example: '/Users/orpheus/hackatime/app/services/chaos_monkey_service.rb', description: 'File path or app name being accessed' },
|
||||||
|
type: { type: :string, example: 'file', enum: [ 'file', 'app' ] },
|
||||||
|
category: {
|
||||||
|
type: :string,
|
||||||
|
example: 'coding',
|
||||||
|
enum: [
|
||||||
|
'advising', 'ai coding', 'animating', 'browsing', 'building', 'code reviewing',
|
||||||
|
'coding', 'communicating', 'configuring', 'debugging', 'designing', 'indexing',
|
||||||
|
'learning', 'manual testing', 'meeting', 'notes', 'planning', 'researching',
|
||||||
|
'running tests', 'supporting', 'translating', 'writing docs', 'writing tests'
|
||||||
|
]
|
||||||
|
},
|
||||||
|
time: { type: :number, format: :float, example: 1709251200.0, description: 'Unix timestamp of the activity' },
|
||||||
|
project: { type: :string, example: 'hackatime' },
|
||||||
|
branch: { type: :string, example: 'main' },
|
||||||
|
language: { type: :string, example: 'Ruby' },
|
||||||
|
is_write: { type: :boolean, example: true },
|
||||||
|
editor: { type: :string, example: 'VS Code' },
|
||||||
|
operating_system: { type: :string, example: 'Mac' },
|
||||||
|
machine: { type: :string, example: 'Orpheus-MacBook-Pro' },
|
||||||
|
cursorpos: { type: :integer, example: 123 },
|
||||||
|
lineno: { type: :integer, example: 42 },
|
||||||
|
lines: { type: :integer, example: 100 },
|
||||||
|
line_additions: { type: :integer, example: 5 },
|
||||||
|
line_deletions: { type: :integer, example: 2 }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
LeaderboardEntry: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
rank: { type: :integer, example: 1 },
|
||||||
|
user: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer, example: 42 },
|
||||||
|
username: { type: :string, example: 'goat_heidi' },
|
||||||
|
avatar_url: { type: :string, example: 'https://...' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
total_seconds: { type: :number, example: 14500.5, description: 'Total coding duration in seconds for the period' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
StatsSummary: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
total_seconds: { type: :number, example: 3600.0 },
|
||||||
|
daily_average: { type: :number, example: 1800.0 },
|
||||||
|
languages: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string, example: 'Ruby' },
|
||||||
|
total_seconds: { type: :number, example: 2400.0 },
|
||||||
|
percent: { type: :number, example: 66.6 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
projects: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string, example: 'hackatime' },
|
||||||
|
total_seconds: { type: :number, example: 3600.0 },
|
||||||
|
percent: { type: :number, example: 100.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
editors: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
name: { type: :string, example: 'VS Code' },
|
||||||
|
total_seconds: { type: :number, example: 3600.0 },
|
||||||
|
percent: { type: :number, example: 100.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
AdminApiKey: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer, example: 1 },
|
||||||
|
name: { type: :string, example: 'CI/CD Key' },
|
||||||
|
last_used_at: { type: :string, format: 'date-time', nullable: true },
|
||||||
|
created_at: { type: :string, format: 'date-time' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
DeletionRequest: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer, example: 101 },
|
||||||
|
user_id: { type: :integer, example: 42 },
|
||||||
|
status: { type: :string, example: 'pending', enum: [ 'pending', 'approved', 'cancelled', 'completed' ] },
|
||||||
|
created_at: { type: :string, format: 'date-time' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
TrustLevelAuditLog: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer, example: 505 },
|
||||||
|
user_id: { type: :integer, example: 42 },
|
||||||
|
actor_id: { type: :integer, example: 1 },
|
||||||
|
action: { type: :string, example: 'upgraded_to_verified' },
|
||||||
|
created_at: { type: :string, format: 'date-time' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Permission: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer, example: 1 },
|
||||||
|
role: { type: :string, example: 'admin' },
|
||||||
|
resource_type: { type: :string, example: 'User' },
|
||||||
|
resource_id: { type: :integer, nullable: true }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
WakatimeMirror: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :integer, example: 7 },
|
||||||
|
target_url: { type: :string, example: 'https://api.wakatime.com/api/v1/users/current/heartbeats' },
|
||||||
|
last_sync_at: { type: :string, format: 'date-time', nullable: true },
|
||||||
|
status: { type: :string, example: 'active' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
ProjectRepoMapping: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
project_name: { type: :string, example: 'hackatime' },
|
||||||
|
repository: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
url: { type: :string, example: 'https://github.com/hackclub/hackatime' },
|
||||||
|
homepage: { type: :string, example: 'https://hackatime.hackclub.com' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
is_archived: { type: :boolean, example: false }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Extension: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
id: { type: :string, example: 'vscode' },
|
||||||
|
name: { type: :string, example: 'VS Code' },
|
||||||
|
download_url: { type: :string, example: 'https://marketplace.visualstudio.com/items?itemName=WakaTime.vscode-wakatime' },
|
||||||
|
version: { type: :string, example: '24.0.0' }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
Summary: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
user_id: { type: :string, nullable: true, example: 'U123456' },
|
||||||
|
from: { type: :string, format: 'date-time', example: '2023-01-01T00:00:00Z' },
|
||||||
|
to: { type: :string, format: 'date-time', example: '2023-01-31T23:59:59Z' },
|
||||||
|
projects: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
key: { type: :string, example: 'hackatime' },
|
||||||
|
total: { type: :number, example: 3600.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
languages: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
key: { type: :string, example: 'Ruby' },
|
||||||
|
total: { type: :number, example: 1200.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
editors: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
key: { type: :string, example: 'VS Code' },
|
||||||
|
total: { type: :number, example: 3600.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
operating_systems: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
key: { type: :string, example: 'Mac' },
|
||||||
|
total: { type: :number, example: 3600.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
machines: {
|
||||||
|
type: :array,
|
||||||
|
items: {
|
||||||
|
type: :object,
|
||||||
|
properties: {
|
||||||
|
key: { type: :string, example: 'MacBook-Pro' },
|
||||||
|
total: { type: :number, example: 3600.0 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
servers: [
|
||||||
|
{
|
||||||
|
url: 'https://{defaultHost}',
|
||||||
|
description: 'Production API',
|
||||||
|
variables: {
|
||||||
|
defaultHost: {
|
||||||
|
default: 'hackatime.hackclub.com'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: 'http://{localHost}',
|
||||||
|
description: 'Local Development API',
|
||||||
|
variables: {
|
||||||
|
localHost: {
|
||||||
|
default: 'localhost:3000'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
config.openapi_format = :yaml
|
||||||
|
end
|
||||||
3657
swagger/v1/swagger.yaml
Normal file
3657
swagger/v1/swagger.yaml
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue