Commit 37f98fff authored by Felipe Artur's avatar Felipe Artur

Expose test reports on GraphQL

Expose requirements test reports on GraphQL
parent c2a32118
...@@ -9506,6 +9506,36 @@ type Requirement { ...@@ -9506,6 +9506,36 @@ type Requirement {
""" """
state: RequirementState! state: RequirementState!
"""
Test reports of the requirement
"""
testReports(
"""
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
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
"""
List test reports by sort order
"""
sort: Sort
): TestReportConnection
""" """
Title of the requirement Title of the requirement
""" """
...@@ -10592,6 +10622,78 @@ type TaskCompletionStatus { ...@@ -10592,6 +10622,78 @@ type TaskCompletionStatus {
count: Int! count: Int!
} }
"""
Represents a requirement test report.
"""
type TestReport {
"""
Author of the test report
"""
author: User
"""
Timestamp of when the test report was created
"""
createdAt: Time!
"""
ID of the test report
"""
id: ID!
"""
Pipeline that created the test report
"""
pipeline: Pipeline
"""
State of the test report
"""
state: TestReportState!
}
"""
The connection type for TestReport.
"""
type TestReportConnection {
"""
A list of edges.
"""
edges: [TestReportEdge]
"""
A list of nodes.
"""
nodes: [TestReport]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type TestReportEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: TestReport
}
"""
State of a test report
"""
enum TestReportState {
PASSED
}
""" """
Time represented in ISO 8601 Time represented in ISO 8601
""" """
......
...@@ -27950,6 +27950,69 @@ ...@@ -27950,6 +27950,69 @@
"isDeprecated": false, "isDeprecated": false,
"deprecationReason": null "deprecationReason": null
}, },
{
"name": "testReports",
"description": "Test reports of the requirement",
"args": [
{
"name": "sort",
"description": "List test reports by sort order",
"type": {
"kind": "ENUM",
"name": "Sort",
"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": "OBJECT",
"name": "TestReportConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{ {
"name": "title", "name": "title",
"description": "Title of the requirement", "description": "Title of the requirement",
...@@ -31494,6 +31557,230 @@ ...@@ -31494,6 +31557,230 @@
"enumValues": null, "enumValues": null,
"possibleTypes": null "possibleTypes": null
}, },
{
"kind": "OBJECT",
"name": "TestReport",
"description": "Represents a requirement test report.",
"fields": [
{
"name": "author",
"description": "Author of the test report",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "User",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createdAt",
"description": "Timestamp of when the test report was created",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "ID of the test report",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pipeline",
"description": "Pipeline that created the test report",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "Pipeline",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "state",
"description": "State of the test report",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "ENUM",
"name": "TestReportState",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "TestReportConnection",
"description": "The connection type for TestReport.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "TestReportEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "TestReport",
"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": "TestReportEdge",
"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": "TestReport",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "TestReportState",
"description": "State of a test report",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": [
{
"name": "PASSED",
"description": null,
"isDeprecated": false,
"deprecationReason": null
}
],
"possibleTypes": null
},
{ {
"kind": "SCALAR", "kind": "SCALAR",
"name": "Time", "name": "Time",
...@@ -1589,6 +1589,18 @@ Completion status of tasks ...@@ -1589,6 +1589,18 @@ Completion status of tasks
| `completedCount` | Int! | Number of completed tasks | | `completedCount` | Int! | Number of completed tasks |
| `count` | Int! | Number of total tasks | | `count` | Int! | Number of total tasks |
## TestReport
Represents a requirement test report.
| Name | Type | Description |
| --- | ---- | ---------- |
| `author` | User | Author of the test report |
| `createdAt` | Time! | Timestamp of when the test report was created |
| `id` | ID! | ID of the test report |
| `pipeline` | Pipeline | Pipeline that created the test report |
| `state` | TestReportState! | State of the test report |
## Timelog ## Timelog
| Name | Type | Description | | Name | Type | Description |
......
# frozen_string_literal: true
module Resolvers
module RequirementsManagement
class TestReportsResolver < BaseResolver
argument :sort, Types::SortEnum,
required: false,
description: 'List test reports by sort order'
type Types::RequirementsManagement::TestReportType, null: true
def resolve(**args)
# The requirement could have been loaded in batch by `BatchLoader`.
# At this point we need the `id` of the requirement to query for test reports, so
# make sure it's loaded and not `nil` before continuing.
requirement = object.respond_to?(:sync) ? object.sync : object
requirement.test_reports.order_by(args[:sort])
end
end
end
end
...@@ -22,10 +22,14 @@ module Types ...@@ -22,10 +22,14 @@ module Types
field :project, ProjectType, null: false, field :project, ProjectType, null: false,
description: 'Project to which the requirement belongs', description: 'Project to which the requirement belongs',
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, obj.project_id).find } resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(Project, obj.project_id).find }
field :author, Types::UserType, null: false, field :author, UserType, null: false,
description: 'Author of the requirement', description: 'Author of the requirement',
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find } resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find }
field :test_reports, TestReportType.connection_type, null: true, complexity: 5,
description: 'Test reports of the requirement',
resolver: Resolvers::RequirementsManagement::TestReportsResolver
field :created_at, Types::TimeType, null: false, field :created_at, Types::TimeType, null: false,
description: 'Timestamp of when the requirement was created' description: 'Timestamp of when the requirement was created'
field :updated_at, Types::TimeType, null: false, field :updated_at, Types::TimeType, null: false,
......
# frozen_string_literal: true
module Types
module RequirementsManagement
class TestReportStateEnum < BaseEnum
graphql_name 'TestReportState'
description 'State of a test report'
value 'PASSED', value: 'passed'
end
end
end
# frozen_string_literal: true
module Types
module RequirementsManagement
class TestReportType < BaseObject
graphql_name 'TestReport'
description 'Represents a requirement test report.'
authorize :read_requirement
field :id, GraphQL::ID_TYPE, null: false,
description: 'ID of the test report'
field :state, TestReportStateEnum, null: false,
description: 'State of the test report'
field :author, UserType, null: true,
description: 'Author of the test report',
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(User, obj.author_id).find }
field :pipeline, Ci::PipelineType, null: true,
description: 'Pipeline that created the test report',
resolve: -> (obj, _args, _ctx) { Gitlab::Graphql::Loaders::BatchModelLoader.new(::Ci::Pipeline, obj.pipeline_id).find }
field :created_at, TimeType, null: false,
description: 'Timestamp of when the test report was created'
end
end
end
...@@ -19,7 +19,7 @@ module RequirementsManagement ...@@ -19,7 +19,7 @@ module RequirementsManagement
belongs_to :author, inverse_of: :requirements, class_name: 'User' belongs_to :author, inverse_of: :requirements, class_name: 'User'
belongs_to :project, inverse_of: :requirements belongs_to :project, inverse_of: :requirements
has_many :test_reports, inverse_of: :requirements has_many :test_reports, inverse_of: :requirement
has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.requirements&.maximum(:iid) } has_internal_id :iid, scope: :project, init: ->(s) { s&.project&.requirements&.maximum(:iid) }
......
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
module RequirementsManagement module RequirementsManagement
class TestReport < ApplicationRecord class TestReport < ApplicationRecord
include Sortable
belongs_to :requirement, inverse_of: :test_reports belongs_to :requirement, inverse_of: :test_reports
belongs_to :author, inverse_of: :test_reports, class_name: 'User' belongs_to :author, inverse_of: :test_reports, class_name: 'User'
belongs_to :pipeline, class_name: 'Ci::Pipeline' belongs_to :pipeline, class_name: 'Ci::Pipeline'
......
# frozen_string_literal: true
module RequirementsManagement
class TestReportPolicy < BasePolicy
delegate { @subject.requirement }
end
end
---
title: Expose test reports on GraphQL
merge_request: 32599
author:
type: added
# frozen_string_literal: true
require 'spec_helper'
describe Resolvers::RequirementsManagement::TestReportsResolver do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
context 'with a project' do
let_it_be(:project) { create(:project, :private) }
let_it_be(:requirement) { create(:requirement, project: project, state: :opened, created_at: 5.hours.ago) }
let_it_be(:test_report1) { create(:test_report, requirement: requirement, created_at: 3.hours.ago) }
let_it_be(:test_report2) { create(:test_report, requirement: requirement, created_at: 4.hours.ago) }
before do
stub_licensed_features(requirements: true)
project.add_developer(current_user)
end
describe '#resolve' do
it 'finds all test_reports' do
expect(resolve_test_reports).to contain_exactly(test_report1, test_report2)
end
describe 'sorting' do
context 'when sorting by created_at' do
it 'sorts test reports ascending' do
expect(resolve_test_reports(sort: 'created_asc')).to eq([test_report2, test_report1])
end
it 'sorts test reports descending' do
expect(resolve_test_reports(sort: 'created_desc')).to eq([test_report1, test_report2])
end
end
end
end
end
def resolve_test_reports(args = {}, context = { current_user: current_user })
resolve(described_class, obj: requirement, args: args, ctx: context)
end
end
...@@ -3,7 +3,7 @@ ...@@ -3,7 +3,7 @@
require 'spec_helper' require 'spec_helper'
describe GitlabSchema.types['Requirement'] do describe GitlabSchema.types['Requirement'] do
fields = %i[id iid title state project author created_at updated_at user_permissions] fields = %i[id iid title state project author created_at updated_at user_permissions test_reports]
it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Requirement) } it { expect(described_class).to expose_permissions_using(Types::PermissionTypes::Requirement) }
......
# frozen_string_literal: true
require 'spec_helper'
describe GitlabSchema.types['TestReport'] do
fields = %i[id state pipeline author created_at]
it { expect(described_class.graphql_name).to eq('TestReport') }
it { expect(described_class).to have_graphql_fields(fields) }
end
# frozen_string_literal: true
require 'spec_helper'
describe 'getting test reports of a requirement' do
include GraphqlHelpers
let_it_be(:current_user) { create(:user) }
let_it_be(:project) { create(:project) }
let_it_be(:requirement) { create(:requirement, project: project) }
let_it_be(:test_report_1) { create(:test_report, requirement: requirement, created_at: 3.days.from_now) }
let_it_be(:test_report_2) { create(:test_report, requirement: requirement, created_at: 2.days.from_now) }
let(:test_reports_data) { graphql_data['project']['requirements']['edges'][0]['node']['testReports']['edges'] }
let(:fields) do
<<~QUERY
edges {
node {
testReports {
edges {
node {
#{all_graphql_fields_for('test_reports'.classify)}
}
}
}
}
}
QUERY
end
let(:query) do
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
query_graphql_field('requirements', {}, fields)
)
end
before do
stub_licensed_features(requirements: true)
end
context 'when user can read requirement' do
before do
project.add_developer(current_user)
end
it_behaves_like 'a working graphql query' do
before do
post_graphql(query, current_user: current_user)
end
end
it 'returns test reports successfully' do
post_graphql(query, current_user: current_user)
test_reports_ids = test_reports_data.map { |result| result['node']['id'] }
expected_results = [test_report_1.to_global_id.to_s, test_report_2.to_global_id.to_s]
expect(test_reports_ids).to match_array(expected_results)
end
context 'with pagination' do
let_it_be(:data_path) { [:project, :requirement, :testReports] }
let_it_be(:test_report_3) { create(:test_report, requirement: requirement, created_at: 4.days.ago) }
def pagination_query(params, page_info)
graphql_query_for(
'project',
{ 'fullPath' => project.full_path },
"requirement { testReports(#{params}) { #{page_info} edges { node { id } } } }"
)
end
def pagination_results_data(data)
data.map { |test_report| test_report.dig('node', 'id') }
end
it_behaves_like 'sorted paginated query' do
let(:sort_param) { 'created_asc' }
let(:first_param) { 2 }
let(:expected_results) do
[
test_report_3.to_global_id.to_s,
test_report_2.to_global_id.to_s,
test_report_1.to_global_id.to_s
]
end
end
end
end
context 'when the user does not have access to the requirement' do
it 'returns nil' do
post_graphql(query)
expect(graphql_data['project']).to be_nil
end
end
end
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