Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/controllers/api/v1/certification/base_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
class Api::V1::Certification::BaseController < Api::V1::BaseController
private

def credential_api_keys
Array.wrap(Rails.application.credentials.dig(:certification_shipwrights, :api_keys))
end
end
102 changes: 102 additions & 0 deletions app/controllers/api/v1/certification/ships_controller.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
class Api::V1::Certification::ShipsController < Api::V1::Certification::BaseController
class InvalidParam < StandardError; end

rescue_from InvalidParam do |error|
render json: { error: error.message }, status: :bad_request
end

# GET /api/v1/certification/ships
#
# Returns ship certification reviews within a time window (default: the last 24 hours).
#
# Query params:
# hours (positive integer, default 24; ignored when since is given)
# since (ISO 8601 datetime)
# until (ISO 8601 datetime, default now)
# status (pending|approved|returned|all, default all)
def index
window_start, window_end = parse_time_window
status_filter = params[:status].presence_in(%w[pending approved returned]) || "all"

scope = ::Certification::Ship
.joins(:project)
.where(projects: { deleted_at: nil })
.where(created_at: window_start..window_end)
.includes(:reviewer, project: { memberships: :user })
scope = scope.where(status: status_filter) unless status_filter == "all"

ships = scope.order(created_at: :desc).map { |ship| serialize_ship(ship) }

render json: {
window: { from: window_start.iso8601, to: window_end.iso8601 },
status_filter: status_filter,
count: ships.size,
ships: ships
}
end

private

def parse_time_window
window_end = parse_time_param(:until) || Time.current
window_start = parse_time_param(:since) || window_end - window_hours.hours
raise InvalidParam, "since must be earlier than until" if window_start > window_end

[ window_start, window_end ]
end

def window_hours
return 24 if params[:hours].blank?

hours = Integer(params[:hours], exception: false)
raise InvalidParam, "hours must be a positive integer" unless hours&.positive?

hours
end

def parse_time_param(key)
value = params[key]
return nil if value.blank?

Time.zone.parse(value.to_s) || raise(InvalidParam, "#{key} is not a valid ISO 8601 datetime")
rescue ArgumentError, TypeError
raise InvalidParam, "#{key} is not a valid ISO 8601 datetime"
end

def serialize_ship(ship)
project = ship.project
owner_membership = project.memberships.find { |m| m.role == "owner" }
owner = owner_membership&.user
reviewer = ship.reviewer

{
id: ship.id,
status: ship.status,
created_at: ship.created_at.iso8601,
updated_at: ship.updated_at.iso8601,
decided_at: ship.decided_at&.iso8601,
claimed_at: ship.claimed_at&.iso8601,
feedback: ship.feedback,
project: {
id: project.id,
title: project.title,
ship_status: project.ship_status,
demo_url: project.demo_url,
repo_url: project.repo_url,
description: project.description,
duration_seconds: project.duration_seconds,
shipped_at: project.shipped_at&.iso8601
},
owner: owner ? {
id: owner.id,
display_name: owner.display_name,
slack_id: owner.slack_id
} : nil,
reviewer: reviewer ? {
id: reviewer.id,
display_name: reviewer.display_name,
slack_id: reviewer.slack_id
} : nil
}
end
end
4 changes: 4 additions & 0 deletions config/routes.rb
Original file line number Diff line number Diff line change
Expand Up @@ -440,6 +440,10 @@
namespace :api, defaults: { format: :json } do
namespace :v1 do
resources :ambassador_referrals, only: [ :index, :show ]

namespace :certification do
resources :ships, only: [ :index ]
end
end
end

Expand Down
96 changes: 96 additions & 0 deletions test/controllers/api/v1/certification/ships_controller_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
require "test_helper"

class Api::V1::Certification::ShipsControllerTest < ActionDispatch::IntegrationTest
API_KEY = "test-shipwrights-key"

setup do
credentials_options[:certification_shipwrights] = { api_keys: [ API_KEY ] }

@owner = create_user(slack_id: "U0OWNER", display_name: "owner")
@reviewer = create_user(slack_id: "U0REVIEWER", display_name: "reviewer")

@project = Project.create!(title: "certifiable")
@project.memberships.create!(user: @owner, role: :owner)
@ship = Certification::Ship.create!(project: @project)
end

test "rejects requests without a valid api key" do
fetch_ships(key: nil)
assert_response :unauthorized

fetch_ships(key: "wrong-key")
assert_response :unauthorized
end

test "returns ships in the default 24 hour window with owner and reviewer" do
@ship.update_columns(reviewer_id: @reviewer.id)

old_ship = Certification::Ship.create!(project: Project.create!(title: "old"))
old_ship.update_columns(created_at: 3.days.ago, updated_at: 3.days.ago)

fetch_ships
assert_response :success

body = response.parsed_body
assert_equal 1, body["count"]
ship = body["ships"].first
assert_equal @ship.id, ship["id"]
assert_equal "pending", ship["status"]
assert_equal @owner.slack_id, ship.dig("owner", "slack_id")
assert_equal @reviewer.slack_id, ship.dig("reviewer", "slack_id")
end

test "filters by status" do
approved = Certification::Ship.create!(project: Project.create!(title: "done"))
approved.update_columns(status: 1)

fetch_ships(params: { status: "approved" })
assert_response :success
assert_equal [ approved.id ], response.parsed_body["ships"].map { |s| s["id"] }
end

test "respects an explicit since/until window" do
@ship.update_columns(created_at: 10.days.ago, updated_at: 10.days.ago)

fetch_ships(params: { since: 11.days.ago.iso8601, until: 9.days.ago.iso8601 })
assert_response :success
assert_equal [ @ship.id ], response.parsed_body["ships"].map { |s| s["id"] }
end

test "excludes ships of soft-deleted projects" do
@project.update_columns(deleted_at: Time.current)

fetch_ships
assert_response :success
assert_equal 0, response.parsed_body["count"]
end

test "rejects invalid time params" do
fetch_ships(params: { since: "banana" })
assert_response :bad_request

fetch_ships(params: { hours: "0" })
assert_response :bad_request

fetch_ships(params: { since: 1.hour.ago.iso8601, until: 2.hours.ago.iso8601 })
assert_response :bad_request
end

teardown do
credentials_options.delete(:certification_shipwrights)
end

private

# Credentials are read through a memoized options hash; mutating it is the
# only way to inject test keys, since EncryptedConfiguration's method_missing
# swallows minitest's Object#stub as a credential lookup.
def credentials_options
Rails.application.credentials.send(:options)
end

def fetch_ships(key: API_KEY, params: {})
headers = key ? { "Authorization" => "Bearer #{key}" } : {}
get api_v1_certification_ships_path, params: params, headers: headers
end
end