Commit c895dc26 authored by Eugenia Grieff's avatar Eugenia Grieff

Add GraphQL endpoint for group issues timelogs

This will return data containing time tracked
for the group issues by group members and within
a time frame (limited to 60 days)

To get the collection of timelogs for a certain
group we include in Group a new concern
HasTimelogsReport that will use Timelog scopes
to filter results.

We use a TimelogPresenter to display additional
fields in TimelogType

Group timelogs is a premium feature so we added
group_timelogs feature to license

TimelogType is authorized with read_group_timelogs
permission that checks for a minimun access level of
reporter and group_timelogs feature being available
Refactor changes to improve code quality

- Include subgroups in Issue and Timelog scopes
- Field timelogs in GroupType is never null
- Add error to object validation in TimelogResolver
- Use fields UserType and IssueType  in TimelogType
- Improve rule in GroupPolicy
- Remove redundant TimelogPresenter
- Add missing specs for new scopes
- Extend specs for GroupType and  TimelogType
- Add new matcher fon non null graphql fields

Update GraphQL schema
Add index for spent_at in timelogs table

Fix Timelog scope to use group descendants
parent a6ad3119
......@@ -8,6 +8,18 @@ class Timelog < ApplicationRecord
belongs_to :merge_request, touch: true
belongs_to :user
scope :for_issues_in_group, -> (group) do
joins(:issue).where(
'EXISTS (?)',
Project.select(1).where(namespace: group.self_and_descendants)
.where('issues.project_id = projects.id')
)
end
scope :between_dates, -> (start_date, end_date) do
where('spent_at BETWEEN ? AND ?', start_date, end_date)
end
def issuable
issue || merge_request
end
......
# frozen_string_literal: true
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddTimelogSpentAtIndex < ActiveRecord::Migration[5.2]
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
disable_ddl_transaction!
def up
add_concurrent_index :timelogs, :spent_at, where: 'spent_at IS NOT NULL'
end
def down
remove_concurrent_index :timelogs, :spent_at, where: 'spent_at IS NOT NULL'
end
end
......@@ -3812,6 +3812,7 @@ ActiveRecord::Schema.define(version: 2019_12_04_093410) do
t.datetime "spent_at"
t.index ["issue_id"], name: "index_timelogs_on_issue_id"
t.index ["merge_request_id"], name: "index_timelogs_on_merge_request_id"
t.index ["spent_at"], name: "index_timelogs_on_spent_at", where: "(spent_at IS NOT NULL)"
t.index ["user_id"], name: "index_timelogs_on_user_id"
end
......
......@@ -2156,6 +2156,10 @@ type Group {
"""
state: EpicState
): EpicConnection
"""
Indicates if Epics are enabled for namespace
"""
epicsEnabled: Boolean
"""
......@@ -2168,6 +2172,11 @@ type Group {
"""
fullPath: ID!
"""
Indicates if Group timelogs are enabled for namespace
"""
groupTimelogsEnabled: Boolean
"""
ID of the namespace
"""
......@@ -2233,6 +2242,41 @@ type Group {
"""
rootStorageStatistics: RootStorageStatistics
"""
Time logged in issues by group members
"""
timelogs(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
List time logs within a time range where the logged date is before end_date parameter.
"""
endDate: Time!
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
List time logs within a time range where the logged date is after start_date parameter.
"""
startDate: Time!
): TimelogConnection!
"""
Permissions for the current user on the resource
"""
......@@ -5484,6 +5528,63 @@ Time represented in ISO 8601
"""
scalar Time
type Timelog {
"""
The date when the time tracked was spent at
"""
date: Time!
"""
The issue that logged time was added to
"""
issue: Issue
"""
The time spent displayed in seconds
"""
timeSpent: Int!
"""
The user that logged the time
"""
user: User!
}
"""
The connection type for Timelog.
"""
type TimelogConnection {
"""
A list of edges.
"""
edges: [TimelogEdge]
"""
A list of nodes.
"""
nodes: [Timelog]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type TimelogEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: Timelog
}
"""
Representing a todo entry
"""
......
......@@ -3223,7 +3223,7 @@
},
{
"name": "epicsEnabled",
"description": null,
"description": "Indicates if Epics are enabled for namespace",
"args": [
],
......@@ -3271,6 +3271,20 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "groupTimelogsEnabled",
"description": "Indicates if Group timelogs are enabled for namespace",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "ID of the namespace",
......@@ -3448,6 +3462,91 @@
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "timelogs",
"description": "Time logged in issues by group members",
"args": [
{
"name": "startDate",
"description": "List time logs within a time range where the logged date is after start_date parameter.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "endDate",
"description": "List time logs within a time range where the logged date is before end_date parameter.",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
"defaultValue": null
},
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "TimelogConnection",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "userPermissions",
"description": "Permissions for the current user on the resource",
......@@ -10813,6 +10912,199 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "TimelogConnection",
"description": "The connection type for Timelog.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "TimelogEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "Timelog",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "TimelogEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Timelog",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "Timelog",
"description": null,
"fields": [
{
"name": "date",
"description": "The date when the time tracked was spent at",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "issue",
"description": "The issue that logged time was added to",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Issue",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "timeSpent",
"description": "The time spent displayed in seconds",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "user",
"description": "The user that logged the time",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "User",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ProjectStatistics",
......
......@@ -320,7 +320,8 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph
| `webUrl` | String! | Web URL of the group |
| `avatarUrl` | String | Avatar URL of the group |
| `parent` | Group | Parent group |
| `epicsEnabled` | Boolean | |
| `epicsEnabled` | Boolean | Indicates if Epics are enabled for namespace |
| `groupTimelogsEnabled` | Boolean | Indicates if Group timelogs are enabled for namespace |
| `epic` | Epic | |
### GroupPermissions
......@@ -836,6 +837,15 @@ The API can be explored interactively using the [GraphiQL IDE](../index.md#graph
| `count` | Int! | Number of total tasks |
| `completedCount` | Int! | Number of completed tasks |
### Timelog
| Name | Type | Description |
| --- | ---- | ---------- |
| `date` | Time! | The date when the time tracked was spent at |
| `timeSpent` | Int! | The time spent displayed in seconds |
| `user` | User! | The user that logged the time |
| `issue` | Issue | The issue that logged time was added to |
### Todo
| Name | Type | Description |
......
......@@ -6,10 +6,10 @@ module EE
extend ActiveSupport::Concern
prepended do
%i[epics].each do |feature|
field "#{feature}_enabled", GraphQL::BOOLEAN_TYPE, null: true, resolve: -> (group, args, ctx) do # rubocop:disable Graphql/Descriptions
%i[epics group_timelogs].each do |feature|
field "#{feature}_enabled", GraphQL::BOOLEAN_TYPE, null: true, resolve: -> (group, args, ctx) do
group.feature_available?(feature)
end
end, description: "Indicates if #{feature.to_s.humanize} are enabled for namespace"
end
field :epic, # rubocop:disable Graphql/Descriptions
......@@ -22,6 +22,12 @@ module EE
null: true,
max_page_size: 2000,
resolver: ::Resolvers::EpicResolver
field :timelogs,
::Types::TimelogType.connection_type,
null: false, complexity: 5,
resolver: ::Resolvers::TimelogResolver,
description: 'Time logged in issues by group members'
end
end
end
......
# frozen_string_literal: true
module Resolvers
class TimelogResolver < BaseResolver
argument :start_date, Types::TimeType,
required: true,
description: 'List time logs within a time range where the logged date is after start_date parameter.'
argument :end_date, Types::TimeType,
required: true,
description: 'List time logs within a time range where the logged date is before end_date parameter.'
def resolve(**args)
validate_date_params!(args)
authorize_group_timelogs!
find_timelogs(args)
end
private
def find_timelogs(args)
group.timelogs(args[:start_date], args[:end_date])
end
def validate_date_params!(args)
validate_dates_present!(args[:start_date], args[:end_date])
validate_dates_difference!(args[:start_date], args[:end_date])
validate_date_range!(args[:start_date], args[:end_date])
end
def valid_object?
group.present? &&
group&.feature_available?(:group_timelogs) &&
group&.user_can_access_group_timelogs?(context[:current_user])
end
def authorize_group_timelogs!
unless valid_object?
raise Gitlab::Graphql::Errors::ResourceNotAvailable,
"The resource is not available or you don't have permission to perform this action"
end
end
def validate_dates_present!(start_date, end_date)
return if start_date.present? && end_date.present?
raise_argument_error('Both start_date and end_date must be present.')
end
def validate_dates_difference!(start_date, end_date)
return if end_date > start_date
raise_argument_error('start_date must be earlier than end_date.')
end
def validate_date_range!(start_date, end_date)
return if end_date - start_date <= 60.days
raise_argument_error('The date range period cannot contain more than 60 days')
end
def raise_argument_error(message)
raise Gitlab::Graphql::Errors::ArgumentError, message
end
def group
@group ||= object.respond_to?(:sync) ? object.sync : object
end
end
end
# frozen_string_literal: true
module Types
class TimelogType < BaseObject
graphql_name 'Timelog'
authorize :read_group_timelogs
field :date,
Types::TimeType,
null: false,
method: :spent_at,
description: 'The date when the time tracked was spent at'
field :time_spent,
GraphQL::INT_TYPE,
null: false,
description: 'The time spent displayed in seconds'
field :user,
Types::UserType,
null: false,
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.user_id).find },
description: 'The user that logged the time'
field :issue,
Types::IssueType,
null: true,
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Issue, obj.issue_id).find },
description: 'The issue that logged time was added to'
end
end
# frozen_string_literal: true
module HasTimelogsReport
extend ActiveSupport::Concern
def timelogs(start_date, end_date)
@timelogs ||= timelogs_for(start_date, end_date)
end
def user_can_access_group_timelogs?(current_user)
return unless feature_available?(:group_timelogs)
Ability.allowed?(current_user, :read_group_timelogs, self)
end
private
def timelogs_for(start_date, end_date)
Timelog.between_dates(start_date, end_date).for_issues_in_group(self)
end
end
......@@ -13,6 +13,7 @@ module EE
include Vulnerable
include TokenAuthenticatable
include InsightsFeature
include HasTimelogsReport
add_authentication_token_field :saml_discovery_token, unique: false, token_generator: -> { Devise.friendly_token(8) }
......
......@@ -96,6 +96,7 @@ class License < ApplicationRecord
scoped_labels
service_desk
smartcard_auth
group_timelogs
type_of_work_analytics
unprotection_restrictions
ci_project_subscriptions
......
......@@ -49,6 +49,10 @@ module EE
@subject.saml_provider&.enabled?
end
condition(:group_timelogs_available) do
@subject.feature_available?(:group_timelogs)
end
rule { reporter }.policy do
enable :admin_list
enable :admin_board
......@@ -56,6 +60,7 @@ module EE
enable :view_code_analytics
enable :view_productivity_analytics
enable :view_type_of_work_charts
enable :read_group_timelogs
end
rule { maintainer }.policy do
......@@ -143,6 +148,8 @@ module EE
rule { owner & group_saml_enabled }.policy do
enable :read_group_saml_identity
end
rule { ~group_timelogs_available }.prevent :read_group_timelogs
end
override :lookup_access_level!
......
# frozen_string_literal: true
class TimelogPolicy < BasePolicy
delegate { @subject.issuable.resource_parent.group }
delegate { @subject.issuable.resource_parent }
end
---
title: Expose time logs for group issues via the GraphQL API
merge_request: 18689
author:
type: added
......@@ -8,4 +8,17 @@ describe GitlabSchema.types['Group'] do
it { expect(described_class).to have_graphql_field(:epics) }
it { expect(described_class).to have_graphql_field(:epic) }
end
it { expect(described_class).to have_graphql_field(:groupTimelogsEnabled) }
it { expect(described_class).to have_graphql_field(:timelogs, complexity: 5) }
describe 'timelogs field' do
subject { described_class.fields['timelogs'] }
it 'finds timelogs between start date and end date' do
is_expected.to have_graphql_arguments(:start_date, :end_date, :after, :before, :first, :last)
is_expected.to have_graphql_resolver(Resolvers::TimelogResolver)
is_expected.to have_non_null_graphql_type(Types::TimelogType.connection_type)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::TimelogResolver do
include GraphqlHelpers
context "within a group" do
let(:current_user) { create(:user) }
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, :public, group: group) }
before do
group.add_users([current_user, user], :developer)
project.add_developer(user)
stub_licensed_features(group_timelogs: true)
end
describe '#resolve' do
let(:issue) { create(:issue, project: project) }
let(:issue2) { create(:issue, project: project) }
let!(:timelog1) { create(:timelog, issue: issue, user: user, spent_at: 5.days.ago) }
let!(:timelog2) { create(:timelog, issue: issue2, user: user, spent_at: 10.days.ago) }
let(:start_date) { 6.days.ago }
let(:end_date) { 2.days.ago }
shared_examples 'validation fails with error' do
it 'raises error with correct message' do
expect { resolve_timelogs(start_date: start_date, end_date: end_date) }
.to raise_error(
error_type,
message
)
end
end
it 'finds all timelogs within given dates' do
timelogs = resolve_timelogs(start_date: start_date, end_date: end_date)
expect(timelogs).to contain_exactly(timelog1)
end
context 'when arguments are invalid' do
let(:error_type) { Gitlab::Graphql::Errors::ArgumentError }
context 'when only start_date is present' do
let(:end_date) { nil }
let(:message) { 'Both start_date and end_date must be present.' }
it_behaves_like 'validation fails with error'
end
context 'when only end_date is present' do
let(:start_date) { nil }
let(:message) { 'Both start_date and end_date must be present.' }
it_behaves_like 'validation fails with error'
end
context 'when start_date is later than end_date' do
let(:start_date) { 3.days.ago }
let(:end_date) { 5.days.ago }
let(:message) { 'start_date must be earlier than end_date.' }
it_behaves_like 'validation fails with error'
end
context 'when time range is more than 60 days' do
let(:start_date) { 3.months.ago }
let(:end_date) { 1.day.ago }
let(:message) { 'The date range period cannot contain more than 60 days' }
it_behaves_like 'validation fails with error'
end
end
context 'when resource is not available' do
let(:error_type) { Gitlab::Graphql::Errors::ResourceNotAvailable }
let(:message) { "The resource is not available or you don't have permission to perform this action" }
context 'when feature is disabled' do
before do
stub_licensed_features(group_timelogs: false)
end
it_behaves_like 'validation fails with error'
end
context "when user has insufficient permissions" do
before do
group.add_guest(current_user)
end
it_behaves_like 'validation fails with error'
end
end
end
end
def resolve_timelogs(args = {}, context = { current_user: current_user })
resolve(described_class, obj: group, args: args, ctx: context)
end
end
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['Timelog'] do
let(:fields) { %i[date time_spent user issue] }
it { expect(described_class.graphql_name).to eq('Timelog') }
it { expect(described_class).to have_graphql_fields(fields) }
it { expect(described_class).to require_graphql_authorizations(:read_group_timelogs) }
describe 'user field' do
subject { described_class.fields['user'] }
it 'returns user' do
is_expected.to have_non_null_graphql_type(Types::UserType)
end
end
describe 'issue field' do
subject { described_class.fields['issue'] }
it 'returns issue' do
is_expected.to have_graphql_type(Types::IssueType)
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe HasTimelogsReport do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:issue) { create(:issue, project: create(:project, :public, group: group)) }
context '#timelogs' do
let!(:timelog1) { create_timelog(15.days.ago) }
let!(:timelog2) { create_timelog(10.days.ago) }
let!(:timelog3) { create_timelog(5.days.ago) }
let(:start_date) { 20.days.ago }
let(:end_date) { 8.days.ago }
before do
group.add_developer(user)
end
it 'returns collection of timelogs between given dates' do
expect(group.timelogs(start_date, end_date).to_a).to match_array([timelog1, timelog2])
end
it 'returns empty collection if dates are not present' do
expect(group.timelogs(nil, nil)).to be_empty
end
it 'returns empty collection if date range is invalid' do
expect(group.timelogs(end_date, start_date)).to be_empty
end
end
context '#user_can_access_group_timelogs?' do
before do
group.add_developer(user)
stub_licensed_features(group_timelogs: true)
end
it 'returns true if user can access group timelogs' do
expect(group.user_can_access_group_timelogs?(user)).to be_truthy
end
it 'returns false if feature group_timelogs is disabled' do
stub_licensed_features(group_timelogs: false)
expect(group.user_can_access_group_timelogs?(user)).to be_falsey
end
it 'returns false if user has insufficient permissions' do
group.add_guest(user)
expect(group.user_can_access_group_timelogs?(user)).to be_falsey
end
end
def create_timelog(date)
create(:timelog, issue: issue, user: user, spent_at: date)
end
end
......@@ -61,6 +61,64 @@ describe GroupPolicy do
it { is_expected.not_to be_allowed(:read_group_contribution_analytics) }
end
context 'when timelogs report feature is enabled' do
before do
stub_licensed_features(group_timelogs: true)
end
context 'admin' do
let(:current_user) { admin }
it { is_expected.to be_allowed(:read_group_timelogs) }
end
context 'with owner' do
let(:current_user) { owner }
it { is_expected.to be_allowed(:read_group_timelogs) }
end
context 'with maintainer' do
let(:current_user) { maintainer }
it { is_expected.to be_allowed(:read_group_timelogs) }
end
context 'with reporter' do
let(:current_user) { reporter }
it { is_expected.to be_allowed(:read_group_timelogs) }
end
context 'with guest' do
let(:current_user) { guest }
it { is_expected.to be_disallowed(:read_group_timelogs) }
end
context 'with non member' do
let(:current_user) { create(:user) }
it { is_expected.to be_disallowed(:read_group_timelogs) }
end
context 'with anonymous' do
let(:current_user) { nil }
it { is_expected.to be_disallowed(:read_group_timelogs) }
end
end
context 'when timelogs report feature is disabled' do
let(:current_user) { admin }
before do
stub_licensed_features(group_timelogs: false)
end
it { is_expected.to be_disallowed(:read_group_timelogs) }
end
describe 'per group SAML' do
let(:current_user) { maintainer }
......
# frozen_string_literal: true
require 'spec_helper'
describe 'Timelogs through GroupQuery' do
include GraphqlHelpers
describe 'Get list of timelogs from a group issues' do
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, :public, group: group) }
let(:milestone) { create(:milestone, group: group) }
let(:epic) { create(:epic, group: group) }
let(:issue) { create(:issue, project: project, milestone: milestone, epic: epic) }
let!(:timelog1) { create(:timelog, issue: issue, user: user, spent_at: 10.days.ago) }
let!(:timelog2) { create(:timelog, spent_at: 15.days.ago) }
let(:timelogs_data) { graphql_data['group']['timelogs']['edges'] }
let(:query) do
timelog_node = <<~NODE
edges {
node {
date
timeSpent
user {
username
}
issue {
title
milestone {
title
}
epic {
title
}
}
}
}
NODE
graphql_query_for("group", { "fullPath" => group.full_path },
['groupTimelogsEnabled', query_graphql_field(
"timelogs",
{ startDate: "#{13.days.ago.to_date}", endDate: "#{2.days.ago.to_date}" },
timelog_node
)]
)
end
before do
group.add_developer(user)
stub_licensed_features(group_timelogs: true, epics: true)
end
context 'when the request is correct' do
before do
post_graphql(query, current_user: user)
end
it_behaves_like 'a working graphql query'
it 'returns timelogs successfully' do
expect(response).to have_gitlab_http_status(200)
expect(graphql_errors).to be_nil
expect(node_array.size).to eq 1
expect(graphql_data['group']['groupTimelogsEnabled']).to be_truthy
end
it 'contains correct data' do
username = node_array.map {|data| data['user']['username'] }
date = node_array.map { |data| data['date'].to_date.to_s }
time_spent = node_array.map { |data| data['timeSpent'] }
issue_title = node_array.map {|data| data['issue']['title'] }
milestone_title = node_array.map {|data| data['issue']['milestone']['title'] }
epic_title = node_array.map {|data| data['issue']['epic']['title'] }
expect(username).to eq([user.username])
expect(date).to eq([timelog1.spent_at.to_date.to_s])
expect(time_spent).to eq([timelog1.time_spent])
expect(issue_title).to eq([issue.title])
expect(milestone_title).to eq([milestone.title])
expect(epic_title).to eq([epic.title])
end
end
context 'when requests has errors' do
let(:error_message) do
"The resource is not available or you don't have permission to perform this action"
end
context 'when group_timelogs feature is disabled' do
before do
stub_licensed_features(group_timelogs: false)
end
it 'returns empty' do
post_graphql(query, current_user: user)
expect(response).to have_gitlab_http_status(:success)
expect(graphql_errors).to include(a_hash_including('message' => error_message))
expect(graphql_data['group']).to be_nil
end
end
context 'when there are no timelogs present' do
before do
Timelog.delete_all
end
it 'returns empty result' do
post_graphql(query, current_user: user)
expect(response).to have_gitlab_http_status(:success)
expect(graphql_errors).to be_nil
expect(timelogs_data).to be_empty
expect(graphql_data['group']['groupTimelogsEnabled']).to be_truthy
end
end
context 'when user has no permission to read group timelogs' do
it 'returns empty result' do
guest = create(:user)
group.add_guest(guest)
post_graphql(query, current_user: guest)
expect(response).to have_gitlab_http_status(:success)
expect(graphql_errors).to include(a_hash_including('message' => error_message))
expect(graphql_data['group']).to be_nil
end
end
end
end
def node_array(extract_attribute = nil)
timelogs_data.map do |item|
extract_attribute ? item['node'][extract_attribute] : item['node']
end
end
end
......@@ -41,4 +41,30 @@ RSpec.describe Timelog do
expect(subject).to be_valid
end
end
describe 'scopes' do
describe 'for_issues_in_group' do
it 'return timelogs created for group issues' do
group = create(:group)
subgroup = create(:group, parent: group)
create(:timelog, issue: create(:issue, project: create(:project)))
timelog1 = create(:timelog, issue: create(:issue, project: create(:project, group: group)))
timelog2 = create(:timelog, issue: create(:issue, project: create(:project, group: subgroup)))
expect(described_class.for_issues_in_group(group)).to contain_exactly(timelog1, timelog2)
end
end
describe 'between_dates' do
it 'returns collection of timelogs within given dates' do
create(:timelog, spent_at: 65.days.ago)
timelog1 = create(:timelog, spent_at: 15.days.ago)
timelog2 = create(:timelog, spent_at: 5.days.ago)
timelogs = described_class.between_dates(20.days.ago, 1.day.ago)
expect(timelogs).to contain_exactly(timelog1, timelog2)
end
end
end
end
......@@ -64,6 +64,12 @@ RSpec::Matchers.define :have_graphql_type do |expected|
end
end
RSpec::Matchers.define :have_non_null_graphql_type do |expected|
match do |field|
expect(field.type).to eq(!expected.to_graphql)
end
end
RSpec::Matchers.define :have_graphql_resolver do |expected|
match do |field|
case expected
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment